@mentra/bluetooth-sdk 3.2.0-dev.125 → 3.2.0-dev.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,8 @@ The package includes:
8
8
  - React hooks under `@mentra/bluetooth-sdk/react` for common scan,
9
9
  connection, status, and event lifecycles.
10
10
  - Native Android code published as `com.mentraglass:bluetooth-sdk`.
11
- - Native iOS code available as the `MentraBluetoothSDK` Swift package.
11
+ - Native iOS and macOS code available as the `MentraBluetoothSDK` Swift package.
12
+ - Native macOS Expo Modules adapter for `react-native-macos` hosts.
12
13
  - An Expo config plugin that wires the native dependencies into generated Android and iOS projects.
13
14
 
14
15
  Use a development build or production native build. Expo Go cannot load this package because the SDK contains native code.
@@ -19,6 +20,9 @@ Use a development build or production native build. Expo Go cannot load this pac
19
20
  - Expo `49+` when using Expo.
20
21
  - Android min SDK `28+`.
21
22
  - iOS deployment target `15.1+`.
23
+ - Native macOS deployment target `13+`. The Starter Kit's separate
24
+ `react-native-macos` host requires macOS `14+` and a matching React Native / Expo
25
+ Modules toolchain; the iOS/Android config plugin does not generate a Mac host.
22
26
  - A physical phone for real Bluetooth testing.
23
27
  - Bluetooth permissions, plus Android location permission where BLE scanning requires it.
24
28
 
@@ -407,10 +411,6 @@ automatically.
407
411
 
408
412
  OTA requires Mentra Live glasses firmware that supports the ASG OTA protocol and network access from the glasses. During install, normal BLE traffic can be interrupted and the glasses may restart; keep the app connected and avoid sending unrelated commands until `ota_status.status` is `complete` or `failed`.
409
413
 
410
- For release selection, bundle verification, analytics opt-out, internal server
411
- requirements, and disconnected validation, see the
412
- [air-gapped deployment guide](https://docs.mentraglass.com/bluetooth-sdk/air-gapped-deployment).
413
-
414
414
  Mentra Live also rejects `ota_start` before acknowledgement when its known battery level is below 5%, emitting a failed `ota_status` with `error_message: "battery_low"`. Unknown battery state remains fail-open, so apps should keep their own (typically stricter) user-facing battery policy.
415
415
 
416
416
  ## Photo Upload
@@ -526,13 +526,15 @@ project, not the local Gradle entrypoint. The check script prepares
526
526
  `mobile/android` and uses its Gradle wrapper with `-PmentraPublicSdk=true`,
527
527
  matching the CI release workflow's public SDK dependency mode.
528
528
 
529
- For bare native iOS apps, use the public SwiftPM repository:
529
+ For bare native iOS or macOS apps, use the public SwiftPM repository:
530
530
 
531
531
  ```text
532
532
  https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
533
533
  ```
534
534
 
535
- Select version `0.1.20`, then add the `MentraBluetoothSDK` product to your app target.
535
+ Select a coordinated SDK version that includes your target platform, then add
536
+ the `MentraBluetoothSDK` product to your app target. Native macOS support is new
537
+ on `dev`; use the local source override until a supporting release is published.
536
538
 
537
539
  For local SDK development, add this package folder directly in Xcode:
538
540
 
@@ -542,6 +544,14 @@ For local SDK development, add this package folder directly in Xcode:
542
544
 
543
545
  The core Swift package intentionally excludes optional local STT, Nex/SwiftProtobuf, Vuzix/Ultralite, and tar.bz2 extraction code paths.
544
546
 
547
+ Native Mac hosts need Bluetooth and local-network usage descriptions, and
548
+ sandbox entitlements for Bluetooth and outbound networking. Add microphone
549
+ permission/audio-input capability for host microphone capture and network-server
550
+ capability for local SDK servers. macOS uses CoreAudio instead of
551
+ `AVAudioSession`; it does not provide iOS background modes or ANCS relay. See
552
+ [the macOS integration guide](../../../mintlify-docs/bluetooth-sdk/macos.mdx)
553
+ and the Starter Kit's `examples/macos` and `examples/react-native-macos` hosts.
554
+
545
555
  Maintainers publishing the public SwiftPM mirror should follow
546
556
  [RELEASING_IOS_SPM.md](./RELEASING_IOS_SPM.md).
547
557
 
@@ -3,9 +3,9 @@ package com.mentra.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "3.2.0"
6
- const val RELEASE_IDENTITY: String = "3.2.0-dev.125"
7
- const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.125"
8
- const val SOURCE_COMMIT: String = "5ae6d6bf777ef7343c4c7d16d6145404d619e2de"
9
- const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.125.json"
10
- const val OTA_MANIFEST_SHA256: String = "78851cf939b81f514854ffec083a089c1f41616934decfc843971bdd8037d820"
6
+ const val RELEASE_IDENTITY: String = "3.2.0-dev.136"
7
+ const val RELEASE_SET_ID: String = "mentra-3.2.0-dev.136"
8
+ const val SOURCE_COMMIT: String = "9d3e676966ad4633e1a4438a716f9c6601c5a24a"
9
+ const val OTA_MANIFEST_URL: String = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.136.json"
10
+ const val OTA_MANIFEST_SHA256: String = "1d258b8f4c011ddc1f1eb1ab3c86d9b059da5ed5da1e5218d60875867e570134"
11
11
  }
@@ -1,10 +1,10 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "3.2.0",
4
- "releaseIdentity": "3.2.0-dev.125",
5
- "releaseSetId": "mentra-3.2.0-dev.125",
6
- "sourceCommit": "5ae6d6bf777ef7343c4c7d16d6145404d619e2de",
7
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.125.json",
8
- "otaManifestSha256": "78851cf939b81f514854ffec083a089c1f41616934decfc843971bdd8037d820"
4
+ "releaseIdentity": "3.2.0-dev.136",
5
+ "releaseSetId": "mentra-3.2.0-dev.136",
6
+ "sourceCommit": "9d3e676966ad4633e1a4438a716f9c6601c5a24a",
7
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.136.json",
8
+ "otaManifestSha256": "1d258b8f4c011ddc1f1eb1ab3c86d9b059da5ed5da1e5218d60875867e570134"
9
9
  });
10
10
  //# sourceMappingURL=releaseMetadata.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.125\",\n \"releaseSetId\": \"mentra-3.2.0-dev.125\",\n \"sourceCommit\": \"5ae6d6bf777ef7343c4c7d16d6145404d619e2de\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.125.json\",\n \"otaManifestSha256\": \"78851cf939b81f514854ffec083a089c1f41616934decfc843971bdd8037d820\"\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,sBAAsB;IACtC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,wHAAwH;IAC1I,mBAAmB,EAAE,kEAAkE;CACxF,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"3.2.0\",\n \"releaseIdentity\": \"3.2.0-dev.136\",\n \"releaseSetId\": \"mentra-3.2.0-dev.136\",\n \"sourceCommit\": \"9d3e676966ad4633e1a4438a716f9c6601c5a24a\",\n \"otaManifestUrl\": \"https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.136.json\",\n \"otaManifestSha256\": \"1d258b8f4c011ddc1f1eb1ab3c86d9b059da5ed5da1e5218d60875867e570134\"\n})\n"]}
@@ -695,6 +695,7 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
695
695
 
696
696
  // MARK: - STT Model Management
697
697
 
698
+ #if !os(macOS)
698
699
  AsyncFunction("setSttModelDetails") { (path: String, languageCode: String) in
699
700
  STTTools.setSttModelDetails(path, languageCode)
700
701
  }
@@ -747,6 +748,7 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
747
748
  speed: speed
748
749
  )
749
750
  }
751
+ #endif
750
752
  }
751
753
 
752
754
  @MainActor
@@ -936,4 +938,3 @@ private extension ConnectOptions {
936
938
 
937
939
 
938
940
 
939
-
@@ -12,7 +12,8 @@ Pod::Spec.new do |s|
12
12
  s.author = package['author']
13
13
  s.homepage = package['homepage']
14
14
  s.platforms = {
15
- :ios => '15.1'
15
+ :ios => '15.1',
16
+ :osx => '13.0'
16
17
  }
17
18
  s.swift_version = '5.9'
18
19
  s.source = {
@@ -23,10 +24,10 @@ Pod::Spec.new do |s|
23
24
 
24
25
  # External dependencies required by Bluetooth SDK native code
25
26
  s.dependency 'ExpoModulesCore' if include_expo_adapter
26
- s.dependency 'SWCompression', '~> 4.8.0'
27
- s.dependency 'SwiftProtobuf', '~> 1.0'
28
- s.dependency 'onnxruntime-objc', '1.18.0'
29
- s.dependency 'UltraliteSDK'
27
+ s.ios.dependency 'SWCompression', '~> 4.8.0'
28
+ s.ios.dependency 'SwiftProtobuf', '~> 1.0'
29
+ s.ios.dependency 'onnxruntime-objc', '1.18.0'
30
+ s.ios.dependency 'UltraliteSDK'
30
31
 
31
32
  # Swift/Objective-C compatibility
32
33
  s.pod_target_xcconfig = {
@@ -39,13 +40,14 @@ Pod::Spec.new do |s|
39
40
  # iOS frameworks required by Bluetooth SDK
40
41
  ios_frameworks = ['AVFoundation', 'CoreBluetooth', 'UIKit', 'CoreGraphics']
41
42
  ios_frameworks << 'Network' if include_expo_adapter
42
- s.frameworks = ios_frameworks
43
+ s.ios.frameworks = ios_frameworks
44
+ s.osx.frameworks = ['AVFoundation', 'CoreBluetooth', 'CoreAudio', 'AudioToolbox', 'ImageIO', 'JavaScriptCore', 'Network']
43
45
 
44
46
  # System libraries required by MentraOS
45
- s.library = 'bz2'
47
+ s.ios.library = 'bz2'
46
48
 
47
49
  # Vendored frameworks
48
- s.vendored_frameworks = 'Packages/SherpaOnnx/sherpa-onnx.xcframework'
50
+ s.ios.vendored_frameworks = 'Packages/SherpaOnnx/sherpa-onnx.xcframework'
49
51
 
50
52
  s.resource_bundles = {
51
53
  'BluetoothSDKPrivacy' => ['Source/PrivacyInfo.xcprivacy']
@@ -60,7 +62,7 @@ Pod::Spec.new do |s|
60
62
  "Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/**/*.{h,hpp}",
61
63
  "Packages/libbz2/shim.h"
62
64
  ]
63
- native_source_files.concat([
65
+ adapter_source_files = [
64
66
  "BluetoothSdkModule.swift",
65
67
  "LocalPhotoUploadServer.swift",
66
68
  "MentraPhotoReceiverModule.swift",
@@ -68,8 +70,24 @@ Pod::Spec.new do |s|
68
70
  "LocalOtaServer.swift",
69
71
  "BackgroundOtaArtifactDownloader.swift",
70
72
  "MentraOtaServerModule.swift"
71
- ]) if include_expo_adapter
72
- s.source_files = native_source_files
73
+ ]
74
+ native_source_files.concat(adapter_source_files) if include_expo_adapter
75
+ s.ios.source_files = native_source_files
76
+ s.osx.source_files = [
77
+ 'Source/**/*.{h,m,mm,swift,hpp,cpp,c}',
78
+ 'Packages/CoreObjC/**/*.{h,m,mm,hpp,cpp,c}'
79
+ ] + (include_expo_adapter ? adapter_source_files : [])
80
+ s.osx.exclude_files = [
81
+ 'Source/Bridging-Header.h',
82
+ 'Source/sgcs/Mach1.swift',
83
+ 'Source/sgcs/MentraNex.swift',
84
+ 'Source/sgcs/mentraos_ble.pb.swift',
85
+ 'Source/stt/**/*',
86
+ 'Source/tts/**/*',
87
+ 'Source/utils/TarBz2Extractor.swift',
88
+ 'Source/BridgeModule.{h,m}',
89
+ 'Source/Bridge.m'
90
+ ]
73
91
 
74
92
  # Explicitly mark C++ headers and internal headers as private to prevent exposure in public interface
75
93
  s.private_header_files = [
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
11
11
  static let voiceActivityDetectionEnabled = false
12
12
  static let loudnessGateEnabled = true
13
13
  private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
- private static let swiftPackageSdkVersion = "3.2.0-dev.125"
14
+ private static let swiftPackageSdkVersion = "3.2.0-dev.136"
15
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
16
16
 
17
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -9,7 +9,12 @@ import AVFoundation
9
9
  import Combine
10
10
  import CoreBluetooth
11
11
  import Foundation
12
+ #if os(macOS)
13
+ import CoreAudio
14
+ #endif
15
+ #if canImport(UIKit)
12
16
  import UIKit
17
+ #endif
13
18
  #if SWIFT_PACKAGE
14
19
  import MentraBluetoothSDKCoreObjC
15
20
  #endif
@@ -43,6 +48,9 @@ struct ViewState {
43
48
  // MARK: - Unique (iOS)
44
49
 
45
50
  private var cancellables = Set<AnyCancellable>()
51
+ #if os(macOS)
52
+ private var audioRouteObserver: MacAudioRouteObserver?
53
+ #endif
46
54
  var sendStateWorkItem: DispatchWorkItem?
47
55
  let sendStateQueue = DispatchQueue(label: "sendStateQueue", qos: .userInitiated)
48
56
 
@@ -51,6 +59,7 @@ struct ViewState {
51
59
  * Attempts to automatically activate Mentra Live as the system audio device
52
60
  * If not paired yet, prompts user to pair in Settings
53
61
  */
62
+ #if !os(macOS)
54
63
  func setupAudioPairing(deviceName _: String) {
55
64
  // Don't configure audio session - PhoneMic.swift handles that
56
65
  // Just check if audio session supports Bluetooth (informational only)
@@ -83,6 +92,7 @@ struct ViewState {
83
92
  // Not found in availableInputs - not paired yet
84
93
 
85
94
  // Start monitoring for when user pairs manually
95
+ #if !os(macOS)
86
96
  AudioSessionMonitor.startMonitoring(devicePattern: audioDevicePattern) {
87
97
  [weak self] (connected: Bool, _: String?) in
88
98
  guard let self = self else { return }
@@ -96,9 +106,12 @@ struct ViewState {
96
106
  self.glassesBluetoothClassicConnected = false
97
107
  }
98
108
  }
109
+ #endif
99
110
  }
100
111
  }
101
112
 
113
+ #endif
114
+
102
115
  // MARK: - End Unique
103
116
 
104
117
  // MARK: - Properties
@@ -314,7 +327,7 @@ struct ViewState {
314
327
  private var micReinitTimer: Timer?
315
328
 
316
329
  /// STT:
317
- #if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
330
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
318
331
  private var transcriber: SherpaOnnxTranscriber?
319
332
  #endif
320
333
 
@@ -351,7 +364,7 @@ struct ViewState {
351
364
  // MemoryMonitor.start()
352
365
 
353
366
  // Initialize SherpaOnnx Transcriber
354
- #if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
367
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
355
368
  if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
356
369
  let window = windowScene.windows.first,
357
370
  let rootViewController = window.rootViewController
@@ -500,7 +513,7 @@ struct ViewState {
500
513
  handleSendingPcm(pcmData)
501
514
 
502
515
  // Send PCM to local transcriber.
503
- #if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
516
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
504
517
  if shouldSendTranscript || localSttFallbackActive {
505
518
  transcriber?.acceptAudio(pcm16le: pcmData)
506
519
  }
@@ -517,11 +530,13 @@ struct ViewState {
517
530
 
518
531
  var phoneMicUnavailable = systemMicUnavailable
519
532
 
533
+ #if !os(macOS)
520
534
  let appState = UIApplication.shared.applicationState
521
535
  if appState == .background {
522
536
  // Bridge.log("App is in background - onboard mic unavailable to start!")
523
537
  phoneMicUnavailable = true
524
538
  }
539
+ #endif
525
540
 
526
541
  if micEnabled {
527
542
  for micMode in micRanking {
@@ -710,12 +725,12 @@ struct ViewState {
710
725
  } else if wearable.contains(DeviceTypes.FRAME) {
711
726
  // sgc = FrameManager()
712
727
  }
713
- #if !SWIFT_PACKAGE || MENTRA_FEATURE_NEX
728
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_NEX)
714
729
  if sgc == nil && wearable.contains(DeviceTypes.NEX) {
715
730
  sgc = MentraNexSGC.getInstance()
716
731
  }
717
732
  #endif
718
- #if !SWIFT_PACKAGE || MENTRA_FEATURE_VUZIX
733
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_VUZIX)
719
734
  if sgc == nil {
720
735
  if wearable.contains(DeviceTypes.MACH1) {
721
736
  sgc = Mach1()
@@ -927,6 +942,10 @@ struct ViewState {
927
942
  return
928
943
  }
929
944
 
945
+ #if os(macOS)
946
+ glassesBluetoothClassicConnected = AudioSessionMonitor.isAudioDeviceConnected(devicePattern: audioDevicePattern)
947
+ otherBtConnected = AudioSessionMonitor.isOtherAudioDeviceConnected(devicePattern: audioDevicePattern)
948
+ #else
930
949
  // check if the device disconnected:
931
950
  let isConnected = AudioSessionMonitor.isAudioDeviceConnected(
932
951
  devicePattern: audioDevicePattern
@@ -948,17 +967,15 @@ struct ViewState {
948
967
 
949
968
  let isPaired = AudioSessionMonitor.isDevicePaired(devicePattern: audioDevicePattern)
950
969
  if isPaired {
951
- let session = AVAudioSession.sharedInstance()
952
- let deviceName = session.availableInputs?.first(where: {
953
- $0.portName.localizedCaseInsensitiveContains(audioDevicePattern)
954
- })?.portName
955
- Bridge.log("MAN: Successfully detected newly paired device '\(deviceName)'")
970
+ Bridge.log("MAN: Successfully detected newly paired device '\(audioDevicePattern)'")
956
971
  glassesBluetoothClassicConnected = true
957
972
  } else {
958
973
  glassesBluetoothClassicConnected = false
959
974
  }
975
+ #endif
960
976
  }
961
977
 
978
+ #if !os(macOS)
962
979
  func onRouteChange(
963
980
  reason: AVAudioSession.RouteChangeReason, availableInputs: [AVAudioSessionPortDescription]
964
981
  ) {
@@ -980,6 +997,7 @@ struct ViewState {
980
997
 
981
998
  updateMicState()
982
999
  }
1000
+ #endif
983
1001
 
984
1002
  func onInterruption(began: Bool) {
985
1003
  Bridge.log("MAN: Interruption: \(began)")
@@ -988,7 +1006,7 @@ struct ViewState {
988
1006
  }
989
1007
 
990
1008
  func restartTranscriber() {
991
- #if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
1009
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
992
1010
  Bridge.log("MAN: Restarting SherpaOnnxTranscriber via command")
993
1011
  transcriber?.restart()
994
1012
  #else
@@ -1054,6 +1072,16 @@ struct ViewState {
1054
1072
  }
1055
1073
 
1056
1074
  // check current audio device:
1075
+ #if os(macOS)
1076
+ audioRouteObserver = MacAudioRouteObserver(selectors: [kAudioHardwarePropertyDevices,
1077
+ kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice]) { [weak self] in
1078
+ Task { @MainActor in
1079
+ guard let self, self.audioRouteObserver != nil else { return }
1080
+ self.checkCurrentAudioDevice()
1081
+ self.updateMicState()
1082
+ }
1083
+ }
1084
+ #endif
1057
1085
  checkCurrentAudioDevice()
1058
1086
 
1059
1087
  // save the default_wearable now that we're connected:
@@ -1146,6 +1174,9 @@ struct ViewState {
1146
1174
 
1147
1175
  func handleDeviceDisconnected() {
1148
1176
  Bridge.log("MAN: Device disconnected")
1177
+ #if os(macOS)
1178
+ audioRouteObserver = nil
1179
+ #endif
1149
1180
  resetSystemTimeSync()
1150
1181
  resetMicHealth()
1151
1182
  DeviceStore.shared.apply("glasses", "headUp", false)
@@ -1825,6 +1856,11 @@ struct ViewState {
1825
1856
  }
1826
1857
 
1827
1858
  func disconnect(clearPendingConnection: Bool = true) {
1859
+ #if os(macOS)
1860
+ audioRouteObserver = nil
1861
+ glassesBluetoothClassicConnected = false
1862
+ otherBtConnected = false
1863
+ #endif
1828
1864
  sgc?.clearDisplay() // clear the screen
1829
1865
  sgc?.disconnect()
1830
1866
  sgc = nil // Clear the SGC reference after disconnect
@@ -1932,8 +1968,12 @@ struct ViewState {
1932
1968
  }
1933
1969
 
1934
1970
  func cleanup() {
1971
+ #if os(macOS)
1972
+ audioRouteObserver = nil
1973
+ PhoneMic.shared.cleanup()
1974
+ #endif
1935
1975
  // Clean up transcriber resources
1936
- #if !SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT
1976
+ #if !os(macOS) && (!SWIFT_PACKAGE || MENTRA_FEATURE_LOCAL_STT)
1937
1977
  transcriber?.shutdown()
1938
1978
  transcriber = nil
1939
1979
  #endif
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "3.2.0"
6
- static let releaseIdentity = "3.2.0-dev.125"
7
- static let releaseSetId = "mentra-3.2.0-dev.125"
8
- static let sourceCommit = "5ae6d6bf777ef7343c4c7d16d6145404d619e2de"
9
- static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.125.json"
10
- static let otaManifestSha256 = "78851cf939b81f514854ffec083a089c1f41616934decfc843971bdd8037d820"
6
+ static let releaseIdentity = "3.2.0-dev.136"
7
+ static let releaseSetId = "mentra-3.2.0-dev.136"
8
+ static let sourceCommit = "9d3e676966ad4633e1a4438a716f9c6601c5a24a"
9
+ static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.136.json"
10
+ static let otaManifestSha256 = "1d258b8f4c011ddc1f1eb1ab3c86d9b059da5ed5da1e5218d60875867e570134"
11
11
  }
@@ -261,6 +261,17 @@ final class PcmStreamPlayer: @unchecked Sendable {
261
261
 
262
262
  private func installAudioSessionObservers() {
263
263
  let center = NotificationCenter.default
264
+ #if os(macOS)
265
+ observers.append(center.addObserver(
266
+ forName: .AVAudioEngineConfigurationChange, object: engine, queue: nil
267
+ ) { [weak self] _ in
268
+ self?.stateQueue.async {
269
+ // A device change invalidates scheduled buffers. Reject pending writes/close
270
+ // instead of leaving callers waiting for callbacks that cannot arrive.
271
+ self?.fail(PcmStreamError.native("Audio device changed; reopen the PCM stream"))
272
+ }
273
+ })
274
+ #else
264
275
  observers.append(
265
276
  center.addObserver(
266
277
  forName: AVAudioSession.interruptionNotification,
@@ -294,6 +305,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
294
305
  }
295
306
  }
296
307
  )
308
+ #endif
297
309
  }
298
310
 
299
311
  private func removeAudioSessionObservers() {
@@ -301,6 +313,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
301
313
  observers.removeAll()
302
314
  }
303
315
 
316
+ #if !os(macOS)
304
317
  private func handleInterruption(_ notification: Notification) {
305
318
  guard
306
319
  let raw = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
@@ -321,6 +334,7 @@ final class PcmStreamPlayer: @unchecked Sendable {
321
334
  break
322
335
  }
323
336
  }
337
+ #endif
324
338
  }
325
339
 
326
340
  /// Process-wide registry matching the Android PCM stream bridge contract.
@@ -1,6 +1,8 @@
1
1
  import Foundation
2
2
  import os.log
3
+ #if canImport(UIKit)
3
4
  import UIKit
5
+ #endif
4
6
 
5
7
  enum BleTraceLogger {
6
8
  private static let log = OSLog(subsystem: "com.mentra.bluetoothsdk", category: "MentraBleTrace")
@@ -66,9 +68,14 @@ enum BleTraceLogger {
66
68
  "event": event,
67
69
  "component": component,
68
70
  "pid": ProcessInfo.processInfo.processIdentifier,
69
- "model": UIDevice.current.model,
70
- "systemVersion": UIDevice.current.systemVersion,
71
71
  ]
72
+ #if os(macOS)
73
+ payload["model"] = "Mac"
74
+ payload["systemVersion"] = ProcessInfo.processInfo.operatingSystemVersionString
75
+ #else
76
+ payload["model"] = UIDevice.current.model
77
+ payload["systemVersion"] = UIDevice.current.systemVersion
78
+ #endif
72
79
  for (key, value) in extra {
73
80
  payload[key] = value
74
81
  }
@@ -8,7 +8,11 @@ public struct BluetoothSdkAnalyticsConfiguration {
8
8
 
9
9
  public init(enabled: Bool = true) {
10
10
  self.enabled = enabled
11
+ #if os(macOS)
12
+ surface = "macos"
13
+ #else
11
14
  surface = "ios"
15
+ #endif
12
16
  }
13
17
 
14
18
  var isReady: Bool {
@@ -141,14 +145,19 @@ final class BluetoothSdkAnalytics {
141
145
  }
142
146
 
143
147
  private func baseProperties(configuration: BluetoothSdkAnalyticsConfiguration) -> [String: Any] {
148
+ #if os(macOS)
149
+ let platform = "macos"
150
+ #else
151
+ let platform = "ios"
152
+ #endif
144
153
  var properties: [String: Any] = [
145
154
  "$process_person_profile": false,
146
155
  "event_source": "mentra_bluetooth_sdk",
147
- "sdk_platform": "ios",
156
+ "sdk_platform": platform,
148
157
  "sdk_surface": configuration.surface,
149
158
  "app_identifier": Bundle.main.bundleIdentifier ?? "",
150
159
  "app_bundle_identifier": Bundle.main.bundleIdentifier ?? "",
151
- "os_platform": "ios",
160
+ "os_platform": platform,
152
161
  "os_version": ProcessInfo.processInfo.operatingSystemVersionString,
153
162
  ]
154
163
  if let sdkVersion = BluetoothSdkDefaults.sdkVersion {
@@ -0,0 +1,96 @@
1
+ #if os(macOS)
2
+ import CoreAudio
3
+ import Foundation
4
+
5
+ struct MacAudioDevice {
6
+ let id: AudioDeviceID
7
+ let name: String
8
+ let transport: UInt32
9
+ let hasInput: Bool
10
+
11
+ var isBluetooth: Bool {
12
+ transport == kAudioDeviceTransportTypeBluetooth || transport == kAudioDeviceTransportTypeBluetoothLE
13
+ }
14
+
15
+ static func input(for mode: String, devices: [MacAudioDevice], defaultInput: AudioDeviceID?) -> AudioDeviceID? {
16
+ let inputs = devices.filter(\.hasInput)
17
+ switch mode {
18
+ case MicTypes.PHONE_INTERNAL:
19
+ let builtIn = inputs.filter { $0.transport == kAudioDeviceTransportTypeBuiltIn }
20
+ return builtIn.first { $0.id == defaultInput }?.id ?? builtIn.map(\.id).min()
21
+ case MicTypes.BLUETOOTH, MicTypes.BLUETOOTH_CLASSIC:
22
+ // Respect the user's Sound settings; never pick an arbitrary nearby headset.
23
+ return inputs.first { $0.id == defaultInput && $0.isBluetooth }?.id
24
+ default:
25
+ return nil
26
+ }
27
+ }
28
+
29
+ static var available: [MacAudioDevice] {
30
+ var address = address(kAudioHardwarePropertyDevices)
31
+ var size: UInt32 = 0
32
+ guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size) == noErr
33
+ else { return [] }
34
+ var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
35
+ guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &ids) == noErr
36
+ else { return [] }
37
+ return ids.compactMap { id in
38
+ var nameAddress = self.address(kAudioObjectPropertyName)
39
+ var name: Unmanaged<CFString>?
40
+ var nameSize = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
41
+ guard AudioObjectGetPropertyData(id, &nameAddress, 0, nil, &nameSize, &name) == noErr
42
+ else { return nil }
43
+ guard let name = name?.takeRetainedValue() else { return nil }
44
+ var inputAddress = self.address(kAudioDevicePropertyStreams, scope: kAudioObjectPropertyScopeInput)
45
+ var inputSize: UInt32 = 0
46
+ let hasInput = AudioObjectGetPropertyDataSize(id, &inputAddress, 0, nil, &inputSize) == noErr && inputSize > 0
47
+ return MacAudioDevice(id: id, name: name as String,
48
+ transport: value(id, kAudioDevicePropertyTransportType) ?? 0, hasInput: hasInput)
49
+ }
50
+ }
51
+
52
+ static var defaultInput: AudioDeviceID? {
53
+ value(AudioObjectID(kAudioObjectSystemObject), kAudioHardwarePropertyDefaultInputDevice)
54
+ }
55
+
56
+ static var defaultOutput: AudioDeviceID? {
57
+ value(AudioObjectID(kAudioObjectSystemObject), kAudioHardwarePropertyDefaultOutputDevice)
58
+ }
59
+
60
+ static func value(_ object: AudioObjectID, _ selector: AudioObjectPropertySelector) -> UInt32? {
61
+ var address = address(selector)
62
+ var value: UInt32 = 0
63
+ var size = UInt32(MemoryLayout<UInt32>.size)
64
+ return AudioObjectGetPropertyData(object, &address, 0, nil, &size, &value) == noErr ? value : nil
65
+ }
66
+
67
+ static func address(_ selector: AudioObjectPropertySelector,
68
+ scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal) -> AudioObjectPropertyAddress
69
+ {
70
+ AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain)
71
+ }
72
+ }
73
+
74
+ /// Observe route changes without selecting a system-wide audio device.
75
+ final class MacAudioRouteObserver {
76
+ private let listener: AudioObjectPropertyListenerBlock
77
+ private var selectors: [AudioObjectPropertySelector] = []
78
+
79
+ init(selectors: [AudioObjectPropertySelector], onChange: @escaping () -> Void) {
80
+ listener = { _, _ in onChange() }
81
+ for selector in selectors {
82
+ var address = MacAudioDevice.address(selector)
83
+ if AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, listener) == noErr {
84
+ self.selectors.append(selector)
85
+ }
86
+ }
87
+ }
88
+
89
+ deinit {
90
+ for selector in selectors {
91
+ var address = MacAudioDevice.address(selector)
92
+ AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, listener)
93
+ }
94
+ }
95
+ }
96
+ #endif
@@ -0,0 +1,138 @@
1
+ #if os(macOS)
2
+ import AudioUnit
3
+ import AVFoundation
4
+ import CoreAudio
5
+ import Foundation
6
+
7
+ @MainActor
8
+ final class PhoneMic {
9
+ static let shared = PhoneMic()
10
+ private var engine: AVAudioEngine?
11
+ private var configurationObserver: NSObjectProtocol?
12
+ private var routeObserver: MacAudioRouteObserver?
13
+ private var currentMode = ""
14
+ private var activeDevice: AudioDeviceID?
15
+ var isRecording: Bool {
16
+ engine?.isRunning == true
17
+ }
18
+
19
+ func checkPermissions() -> Bool {
20
+ AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
21
+ }
22
+
23
+ func requestPermissions() async -> Bool {
24
+ checkPermissions()
25
+ }
26
+
27
+ func getAvailableInputDevices() -> [String: String] {
28
+ Dictionary(uniqueKeysWithValues: MacAudioDevice.available.filter(\.hasInput).map { (String($0.id), $0.name) })
29
+ }
30
+
31
+ func isRecordingWithMode(_ mode: String) -> Bool {
32
+ isRecording && currentMode == mode
33
+ }
34
+
35
+ func startMode(_ mode: String) -> Bool {
36
+ if isRecordingWithMode(mode) { return true }
37
+ guard !isRecording, checkPermissions() else { return false }
38
+ guard let device = MacAudioDevice.input(for: mode, devices: MacAudioDevice.available,
39
+ defaultInput: MacAudioDevice.defaultInput) else { return false }
40
+ return start(device: device, mode: mode)
41
+ }
42
+
43
+ func startRecording() -> Bool {
44
+ startMode(MicTypes.PHONE_INTERNAL)
45
+ }
46
+
47
+ private func start(device: AudioDeviceID, mode: String) -> Bool {
48
+ let engine = AVAudioEngine()
49
+ let input = engine.inputNode
50
+ guard let unit = input.audioUnit else { return false }
51
+ var device = device
52
+ guard AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global,
53
+ 0, &device, UInt32(MemoryLayout<AudioDeviceID>.size)) == noErr
54
+ else { return false }
55
+ let format = input.outputFormat(forBus: 0)
56
+ guard format.sampleRate > 0, format.channelCount > 0,
57
+ let output = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: false),
58
+ let converter = AVAudioConverter(from: format, to: output)
59
+ else { return false }
60
+ input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self, weak engine] buffer, _ in
61
+ let capacity = AVAudioFrameCount(ceil(Double(buffer.frameLength) * output.sampleRate / format.sampleRate))
62
+ guard let converted = AVAudioPCMBuffer(pcmFormat: output, frameCapacity: capacity) else { return }
63
+ var supplied = false
64
+ var error: NSError?
65
+ let result = converter.convert(to: converted, error: &error) { _, status in
66
+ guard !supplied else { status.pointee = .noDataNow; return nil }
67
+ supplied = true
68
+ status.pointee = .haveData
69
+ return buffer
70
+ }
71
+ guard result != .error, error == nil, converted.frameLength > 0,
72
+ let samples = converted.int16ChannelData?[0] else { return }
73
+ let pcm = Data(bytes: samples, count: Int(converted.frameLength) * MemoryLayout<Int16>.size)
74
+ Task { @MainActor [weak self, weak engine] in
75
+ guard let engine, self?.engine === engine else { return }
76
+ DeviceManager.shared.handlePcm(pcm)
77
+ }
78
+ }
79
+ do {
80
+ try engine.start()
81
+ } catch {
82
+ input.removeTap(onBus: 0)
83
+ Bridge.log("MIC: macOS input failed: \(error.localizedDescription)")
84
+ return false
85
+ }
86
+ self.engine = engine
87
+ activeDevice = device
88
+ currentMode = mode
89
+ routeObserver = MacAudioRouteObserver(selectors: [kAudioHardwarePropertyDevices, kAudioHardwarePropertyDefaultInputDevice]) { [weak self, weak engine] in
90
+ Task { @MainActor in
91
+ guard let self, let engine, self.engine === engine else { return }
92
+ let desired = MacAudioDevice.input(for: self.currentMode, devices: MacAudioDevice.available,
93
+ defaultInput: MacAudioDevice.defaultInput)
94
+ guard desired != self.activeDevice else { return }
95
+ self.stopRecording()
96
+ DeviceManager.shared.updateMicState()
97
+ }
98
+ }
99
+ configurationObserver = NotificationCenter.default.addObserver(
100
+ forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main
101
+ ) { [weak self, weak engine] _ in
102
+ Task { @MainActor in
103
+ guard let engine, self?.engine === engine else { return }
104
+ self?.stopRecording()
105
+ DeviceManager.shared.updateMicState()
106
+ }
107
+ }
108
+ return true
109
+ }
110
+
111
+ func getActiveInputDevice() -> String? {
112
+ MacAudioDevice.available.first(where: { $0.id == activeDevice })?.name
113
+ }
114
+
115
+ func stopMode(_ mode: String) -> Bool {
116
+ guard currentMode == mode else { return false }
117
+ stopRecording()
118
+ return true
119
+ }
120
+
121
+ func stopRecording() {
122
+ routeObserver = nil
123
+ if let configurationObserver {
124
+ NotificationCenter.default.removeObserver(configurationObserver)
125
+ }
126
+ configurationObserver = nil
127
+ engine?.inputNode.removeTap(onBus: 0)
128
+ engine?.stop()
129
+ engine = nil
130
+ currentMode = ""
131
+ activeDevice = nil
132
+ }
133
+
134
+ func cleanup() {
135
+ stopRecording()
136
+ }
137
+ }
138
+ #endif
@@ -5,6 +5,7 @@
5
5
  // Created on 3/8/25.
6
6
  //
7
7
 
8
+ #if !os(macOS)
8
9
  import AVFoundation
9
10
  import Combine
10
11
  import Foundation
@@ -661,3 +662,4 @@ class PhoneMic {
661
662
  stopRecording()
662
663
  }
663
664
  }
665
+ #endif
@@ -8,7 +8,7 @@
8
8
  import Combine
9
9
  import CoreBluetooth
10
10
  import Foundation
11
- import UIKit
11
+ import CoreGraphics
12
12
 
13
13
  extension Data {
14
14
  func chunked(into size: Int) -> [Data] {
@@ -1822,7 +1822,7 @@ extension G1 {
1822
1822
  /// Mirrors G2.convertToG2Bmp() for local miniapp bitmap display support.
1823
1823
  /// G1 uses 1-bit monochrome BMP at 640x400 (576x135 active area).
1824
1824
  func convertToG1Bmp(_ imageData: Data, width: Int = 576, height: Int = 135) -> Data? {
1825
- guard let image = UIImage(data: imageData), let cgImage = image.cgImage else {
1825
+ guard let image = SdkImage(data: imageData), let cgImage = image.cgImage else {
1826
1826
  Bridge.log("G1: convertToG1Bmp - could not decode image")
1827
1827
  return nil
1828
1828
  }
@@ -9,7 +9,7 @@
9
9
  import Combine
10
10
  import CoreBluetooth
11
11
  import Foundation
12
- import UIKit
12
+ import CoreGraphics
13
13
 
14
14
  /// Keep a low-rate, non-audio EvenHub sensor stream active so iOS continues receiving BLE
15
15
  /// notifications while the Mentra App is backgrounded. The samples stay internal unless IMU was
@@ -2565,7 +2565,7 @@ class G2: NSObject, SGCManager {
2565
2565
  /// (aspect-fit, centered on black) — the front half of `convertToG2Bmp`,
2566
2566
  /// exposed for the tiler which encodes per-tile BMPs from one render.
2567
2567
  private func renderG2Grayscale(_ data: Data, targetWidth: Int, targetHeight: Int) -> Data? {
2568
- guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil }
2568
+ guard let image = SdkImage(data: data), let cgImage = image.cgImage else { return nil }
2569
2569
  let scale = min(
2570
2570
  Double(targetWidth) / Double(cgImage.width), Double(targetHeight) / Double(cgImage.height)
2571
2571
  )
@@ -3101,7 +3101,7 @@ class G2: NSObject, SGCManager {
3101
3101
  /// Scale source image to fit within containerWidth x containerHeight (maintaining aspect ratio),
3102
3102
  /// centered on a black background. Output BMP always matches container dimensions exactly.
3103
3103
  private func convertToG2Bmp(_ data: Data, containerWidth: Int, containerHeight: Int) -> Data? {
3104
- guard let image = UIImage(data: data), let cgImage = image.cgImage else {
3104
+ guard let image = SdkImage(data: data), let cgImage = image.cgImage else {
3105
3105
  Bridge.log("G2: convertToG2Bmp - could not decode image")
3106
3106
  return nil
3107
3107
  }
@@ -16,7 +16,6 @@ import Combine
16
16
  import CoreBluetooth
17
17
  import Foundation
18
18
  import ImageIO
19
- import UIKit
20
19
 
21
20
  // MARK: - Supporting Types
22
21
 
@@ -355,11 +354,11 @@ class BlePhotoUploadService {
355
354
  }
356
355
 
357
356
  /**
358
- * Decode image data (AVIF or JPEG) to UIImage.
357
+ * Decode image data (AVIF or JPEG).
359
358
  * AVIF arriving from glasses has a TIFF EXIF block appended to {@code mdat}; iOS ImageIO
360
359
  * rejects those bytes the same way Android does. Strip the Exif tail before decoding.
361
360
  */
362
- private static func decodeImage(imageData: Data) -> UIImage? {
361
+ private static func decodeImage(imageData: Data) -> SdkImage? {
363
362
  let isAvif = isAvifData(imageData)
364
363
  var decodeData = imageData
365
364
  if isAvif && containsExifMarker(in: imageData) {
@@ -372,13 +371,13 @@ class BlePhotoUploadService {
372
371
  }
373
372
  }
374
373
 
375
- if let image = UIImage(data: decodeData) {
374
+ if let image = SdkImage(data: decodeData) {
376
375
  return image
377
376
  }
378
377
 
379
378
  if isAvif {
380
- if #available(iOS 16.0, *) {
381
- return UIImage(data: decodeData)
379
+ if #available(iOS 16.0, macOS 13.0, *) {
380
+ return SdkImage(data: decodeData)
382
381
  } else {
383
382
  Bridge.log("\(TAG): AVIF decoding not supported on iOS < 16")
384
383
  return nil
@@ -1022,6 +1021,7 @@ extension MentraLive: CBCentralManagerDelegate {
1022
1021
  }
1023
1022
  }
1024
1023
 
1024
+ #if !os(macOS)
1025
1025
  nonisolated func centralManager(
1026
1026
  _: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral
1027
1027
  ) {
@@ -1033,6 +1033,7 @@ extension MentraLive: CBCentralManagerDelegate {
1033
1033
  self.enableAncsRelayIfAuthorized()
1034
1034
  }
1035
1035
  }
1036
+ #endif
1036
1037
 
1037
1038
  nonisolated func centralManager(
1038
1039
  _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
@@ -2443,6 +2444,9 @@ class MentraLive: NSObject, SGCManager {
2443
2444
  // Set connection timeout
2444
2445
  startConnectionTimeout()
2445
2446
 
2447
+ #if os(macOS)
2448
+ centralManager?.connect(peripheral, options: nil)
2449
+ #else
2446
2450
  // ANCS is hosted by iOS and is only exposed to authorized accessories.
2447
2451
  // Requiring it at connect time lets the system complete that authorization
2448
2452
  // flow before the glasses subscribe to the ANCS characteristics.
@@ -2450,12 +2454,14 @@ class MentraLive: NSObject, SGCManager {
2450
2454
  peripheral,
2451
2455
  options: [CBConnectPeripheralOptionRequiresANCS: true]
2452
2456
  )
2457
+ #endif
2453
2458
  }
2454
2459
 
2455
2460
  /// Opt the firmware into ANCS only after iOS has authorized this accessory.
2456
2461
  /// Old firmware safely ignores the command, while new firmware stays inert
2457
2462
  /// for old mobile clients that never send it.
2458
2463
  private func enableAncsRelayIfAuthorized() {
2464
+ #if !os(macOS)
2459
2465
  guard !ancsRelayEnableRequested,
2460
2466
  let peripheral = connectedPeripheral,
2461
2467
  txCharacteristic != nil,
@@ -2473,6 +2479,7 @@ class MentraLive: NSObject, SGCManager {
2473
2479
  ancsRelayEnableRequested = true
2474
2480
  Bridge.log("LIVE: Requested ANCS relay from compatible firmware")
2475
2481
  }
2482
+ #endif
2476
2483
  }
2477
2484
 
2478
2485
  private func handleReconnection() {
@@ -10,7 +10,7 @@ import AVFoundation
10
10
  import Compression
11
11
  import CoreBluetooth
12
12
  import Foundation
13
- import UIKit
13
+ import CoreGraphics
14
14
 
15
15
  // MARK: - Nimo BLE Constants
16
16
 
@@ -846,7 +846,7 @@ class Nimo: NSObject, SGCManager {
846
846
  let targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
847
847
  Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=\(navAppEntered))")
848
848
  guard let imageData = Data(base64Encoded: base64ImageData),
849
- let image = UIImage(data: imageData)
849
+ let image = SdkImage(data: imageData)
850
850
  else {
851
851
  Bridge.log("NIMO: displayBitmap — could not decode image")
852
852
  return false
@@ -1640,7 +1640,7 @@ class Nimo: NSObject, SGCManager {
1640
1640
  // MARK: - Bitmap Helpers
1641
1641
 
1642
1642
  /// Aspect-fit the image into `width`x`height` on black, then convert to L8 grayscale.
1643
- private func bitmapToGrayscale(_ image: UIImage, width: Int, height: Int) -> Data? {
1643
+ private func bitmapToGrayscale(_ image: SdkImage, width: Int, height: Int) -> Data? {
1644
1644
  guard let cgImage = image.cgImage else { return nil }
1645
1645
  let colorSpace = CGColorSpaceCreateDeviceGray()
1646
1646
  guard let context = CGContext(
@@ -6,6 +6,7 @@
6
6
  // Used to detect when Mentra Live glasses are paired/connected for audio
7
7
  //
8
8
 
9
+ #if !os(macOS)
9
10
  import AVFoundation
10
11
  import Foundation
11
12
  import UIKit
@@ -281,3 +282,4 @@ class AudioSessionMonitor {
281
282
  }
282
283
  }
283
284
  }
285
+ #endif
@@ -102,7 +102,7 @@ private func jlog(_ message: String) {
102
102
  ctx.name = "MentraJS: \(id)"
103
103
  // Inspectable in dev builds only. iOS 16.4+ guarded.
104
104
  #if DEBUG
105
- if #available(iOS 16.4, *) {
105
+ if #available(iOS 16.4, macOS 13.3, *) {
106
106
  ctx.isInspectable = true
107
107
  }
108
108
  #endif
@@ -0,0 +1,18 @@
1
+ #if os(macOS)
2
+ import Foundation
3
+
4
+ enum AudioSessionMonitor {
5
+ static func isAudioDeviceConnected(devicePattern: String) -> Bool {
6
+ MacAudioDevice.available.contains {
7
+ $0.isBluetooth && $0.name.localizedCaseInsensitiveContains(devicePattern)
8
+ }
9
+ }
10
+
11
+ static func isOtherAudioDeviceConnected(devicePattern: String) -> Bool {
12
+ let output = MacAudioDevice.defaultOutput
13
+ return MacAudioDevice.available.contains {
14
+ $0.id == output && $0.isBluetooth && !$0.name.localizedCaseInsensitiveContains(devicePattern)
15
+ }
16
+ }
17
+ }
18
+ #endif
@@ -50,9 +50,15 @@ class PhoneAudioMonitor {
50
50
 
51
51
  /// Check if any audio is currently playing on the device (including our own app)
52
52
  func isPlaying() -> Bool {
53
+ #if os(macOS)
54
+ // macOS has no AVAudioSession audio-focus contract. Explicit SDK playback
55
+ // is tracked; other applications retain independent system audio routes.
56
+ return ownAppAudioPlaying
57
+ #else
53
58
  let session = AVAudioSession.sharedInstance()
54
59
  // Combine: other apps playing OR our own app playing
55
60
  return session.isOtherAudioPlaying || ownAppAudioPlaying
61
+ #endif
56
62
  }
57
63
 
58
64
  func isOwnAppAudioPlaying() -> Bool {
@@ -88,6 +94,7 @@ class PhoneAudioMonitor {
88
94
  "PhoneAudioMonitor: Starting audio playback monitoring (initial state: \(lastKnownState ? "playing" : "not playing"))"
89
95
  )
90
96
 
97
+ #if !os(macOS)
91
98
  // Register for silenceSecondaryAudioHint notification
92
99
  // This is fired when other apps start/stop playing audio
93
100
  NotificationCenter.default.addObserver(
@@ -108,6 +115,7 @@ class PhoneAudioMonitor {
108
115
  // Start polling as a fallback mechanism
109
116
  // Some audio sources might not trigger notifications reliably
110
117
  startPolling()
118
+ #endif
111
119
  }
112
120
 
113
121
  /// Stop monitoring for phone audio playback changes
@@ -119,6 +127,7 @@ class PhoneAudioMonitor {
119
127
 
120
128
  Bridge.log("PhoneAudioMonitor: Stopping audio playback monitoring")
121
129
 
130
+ #if !os(macOS)
122
131
  NotificationCenter.default.removeObserver(
123
132
  self,
124
133
  name: AVAudioSession.silenceSecondaryAudioHintNotification,
@@ -132,11 +141,13 @@ class PhoneAudioMonitor {
132
141
  )
133
142
 
134
143
  stopPolling()
144
+ #endif
135
145
 
136
146
  listener = nil
137
147
  isMonitoring = false
138
148
  }
139
149
 
150
+ #if !os(macOS)
140
151
  /// Handle silenceSecondaryAudioHint notification
141
152
  @objc private func handleSilenceSecondaryAudioHint(_ notification: Notification) {
142
153
  guard let userInfo = notification.userInfo,
@@ -191,6 +202,8 @@ class PhoneAudioMonitor {
191
202
  }
192
203
  }
193
204
 
205
+ #endif
206
+
194
207
  /// Start polling isOtherAudioPlaying as a fallback mechanism
195
208
  private func startPolling() {
196
209
  pollingTimer = Timer.scheduledTimer(
@@ -0,0 +1,37 @@
1
+ import Foundation
2
+ import ImageIO
3
+ import UniformTypeIdentifiers
4
+
5
+ /// Decode upright pixels once, so bitmap rendering and JPEG encoding agree on both Apple platforms.
6
+ struct SdkImage {
7
+ let cgImage: CGImage?
8
+ var size: CGSize {
9
+ CGSize(width: cgImage?.width ?? 0, height: cgImage?.height ?? 0)
10
+ }
11
+
12
+ init?(data: Data) {
13
+ guard let source = CGImageSourceCreateWithData(data as CFData, nil),
14
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
15
+ let width = properties[kCGImagePropertyPixelWidth] as? Int,
16
+ let height = properties[kCGImagePropertyPixelHeight] as? Int,
17
+ let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
18
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
19
+ kCGImageSourceCreateThumbnailWithTransform: true,
20
+ kCGImageSourceThumbnailMaxPixelSize: max(width, height),
21
+ ] as CFDictionary)
22
+ else { return nil }
23
+ cgImage = image
24
+ }
25
+
26
+ func jpegData(compressionQuality: CGFloat) -> Data? {
27
+ guard let cgImage else { return nil }
28
+ let data = NSMutableData()
29
+ guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)
30
+ else { return nil }
31
+ CGImageDestinationAddImage(destination, cgImage, [
32
+ kCGImageDestinationLossyCompressionQuality: compressionQuality,
33
+ kCGImagePropertyOrientation: 1,
34
+ ] as CFDictionary)
35
+ return CGImageDestinationFinalize(destination) ? data as Data : nil
36
+ }
37
+ }
@@ -0,0 +1,32 @@
1
+ #if os(macOS)
2
+ import CoreAudio
3
+ @testable import MentraBluetoothSDK
4
+ import XCTest
5
+
6
+ final class MacAudioDevicesTests: XCTestCase {
7
+ private let devices = [
8
+ MacAudioDevice(id: 1, name: "Internal", transport: kAudioDeviceTransportTypeBuiltIn, hasInput: true),
9
+ MacAudioDevice(id: 2, name: "Headset A", transport: kAudioDeviceTransportTypeBluetooth, hasInput: true),
10
+ MacAudioDevice(id: 3, name: "Headset B", transport: kAudioDeviceTransportTypeBluetoothLE, hasInput: true),
11
+ MacAudioDevice(id: 4, name: "Speakers", transport: kAudioDeviceTransportTypeBluetooth, hasInput: false),
12
+ ]
13
+
14
+ func testBluetoothUsesSelectedInputRegardlessOfEnumerationOrder() {
15
+ for mode in [MicTypes.BLUETOOTH, MicTypes.BLUETOOTH_CLASSIC] {
16
+ for inputs in [devices, devices.reversed()] {
17
+ XCTAssertEqual(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 3), 3)
18
+ XCTAssertEqual(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 2), 2)
19
+ XCTAssertNil(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 1))
20
+ XCTAssertNil(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: 4))
21
+ XCTAssertNil(MacAudioDevice.input(for: mode, devices: inputs, defaultInput: nil))
22
+ }
23
+ }
24
+ }
25
+
26
+ func testInternalModeNeverFallsBackToBluetooth() {
27
+ XCTAssertEqual(MacAudioDevice.input(for: MicTypes.PHONE_INTERNAL, devices: devices, defaultInput: 3), 1)
28
+ XCTAssertNil(MacAudioDevice.input(for: MicTypes.PHONE_INTERNAL, devices: Array(devices.dropFirst()), defaultInput: 3))
29
+ XCTAssertNil(MacAudioDevice.input(for: "invalid", devices: devices, defaultInput: 1))
30
+ }
31
+ }
32
+ #endif
@@ -0,0 +1,53 @@
1
+ import CoreGraphics
2
+ import ImageIO
3
+ @testable import MentraBluetoothSDK
4
+ import UniformTypeIdentifiers
5
+ import XCTest
6
+
7
+ final class SdkImageTests: XCTestCase {
8
+ func testInvalidImageIsRejected() {
9
+ XCTAssertNil(SdkImage(data: Data("not an image".utf8)))
10
+ }
11
+
12
+ func testAllExifOrientationsProduceUprightPixelsAndJpegs() throws {
13
+ // Six asymmetric tiles distinguish rotations from reflections.
14
+ let pixels: [UInt8] = [10, 50, 90, 130, 170, 210]
15
+ let raster = (0 ..< 32).flatMap { y in (0 ..< 48).map { x in pixels[(y / 16) * 3 + x / 16] } }
16
+ let provider = try XCTUnwrap(CGDataProvider(data: Data(raster) as CFData))
17
+ let image = try XCTUnwrap(CGImage(width: 48, height: 32, bitsPerComponent: 8, bitsPerPixel: 8,
18
+ bytesPerRow: 48, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: [],
19
+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
20
+ let expected: [[UInt8]] = [
21
+ [10, 50, 90, 130, 170, 210], [90, 50, 10, 210, 170, 130],
22
+ [210, 170, 130, 90, 50, 10], [130, 170, 210, 10, 50, 90],
23
+ [10, 130, 50, 170, 90, 210], [130, 10, 170, 50, 210, 90],
24
+ [210, 90, 170, 50, 130, 10], [90, 210, 50, 170, 10, 130],
25
+ ]
26
+ for orientation in 1 ... 8 {
27
+ let data = NSMutableData()
28
+ let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, UTType.tiff.identifier as CFString, 1, nil))
29
+ CGImageDestinationAddImage(destination, image, [kCGImagePropertyOrientation: orientation] as CFDictionary)
30
+ XCTAssertTrue(CGImageDestinationFinalize(destination))
31
+
32
+ let decoded = try XCTUnwrap(SdkImage(data: data as Data))
33
+ let width = orientation < 5 ? 48 : 32
34
+ let height = orientation < 5 ? 32 : 48
35
+ XCTAssertEqual(decoded.size, CGSize(width: width, height: height))
36
+ let context = try XCTUnwrap(CGContext(data: nil, width: width, height: height, bitsPerComponent: 8,
37
+ bytesPerRow: width, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: 0))
38
+ try context.draw(XCTUnwrap(decoded.cgImage), in: CGRect(x: 0, y: 0, width: width, height: height))
39
+ let bytes = try XCTUnwrap(context.data).assumingMemoryBound(to: UInt8.self)
40
+ let actual = stride(from: 8, to: height, by: 16).flatMap { y in
41
+ stride(from: 8, to: width, by: 16).map { x in bytes[y * context.bytesPerRow + x] }
42
+ }
43
+ XCTAssertEqual(actual, expected[orientation - 1], "EXIF \(orientation)")
44
+
45
+ let jpeg = try XCTUnwrap(decoded.jpegData(compressionQuality: 1))
46
+ let source = try XCTUnwrap(CGImageSourceCreateWithData(jpeg as CFData, nil))
47
+ let properties = try XCTUnwrap(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
48
+ XCTAssertEqual(CGImageSourceGetType(source), UTType.jpeg.identifier as CFString)
49
+ XCTAssertEqual(properties[kCGImagePropertyOrientation] as? Int ?? 1, 1)
50
+ XCTAssertEqual(try XCTUnwrap(SdkImage(data: jpeg)).size, decoded.size)
51
+ }
52
+ }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "3.2.0-dev.125",
3
+ "version": "3.2.0-dev.136",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
12
12
  export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "3.2.0",
15
- "releaseIdentity": "3.2.0-dev.125",
16
- "releaseSetId": "mentra-3.2.0-dev.125",
17
- "sourceCommit": "5ae6d6bf777ef7343c4c7d16d6145404d619e2de",
18
- "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.125.json",
19
- "otaManifestSha256": "78851cf939b81f514854ffec083a089c1f41616934decfc843971bdd8037d820"
15
+ "releaseIdentity": "3.2.0-dev.136",
16
+ "releaseSetId": "mentra-3.2.0-dev.136",
17
+ "sourceCommit": "9d3e676966ad4633e1a4438a716f9c6601c5a24a",
18
+ "otaManifestUrl": "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-builds-v3.2.0/mentra-live-ota-3.2.0-dev.136.json",
19
+ "otaManifestSha256": "1d258b8f4c011ddc1f1eb1ab3c86d9b059da5ed5da1e5218d60875867e570134"
20
20
  })