diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 8641f3a9..752099e5 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -20,6 +20,22 @@ class AppState: ObservableObject { case wired } + enum AppConnectionMode: String, CaseIterable, Identifiable { + case network = "network" + case dynamic = "dynamic" + case nearby = "nearby" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .network: return L("connectionMode.network") + case .dynamic: return L("connectionMode.dynamic") + case .nearby: return L("connectionMode.nearby") + } + } + } + private var clipboardCancellable: AnyCancellable? private var lastClipboardValue: String? = nil private var lastClipboardChangeCount: Int = -1 @@ -143,7 +159,19 @@ class AppState: ObservableObject { self.licenseDetails = AppState.loadLicenseDetailsFromUserDefaults() - self.isBLEEnabled = UserDefaults.standard.bool(forKey: "isBLEEnabled") + let savedConnectionModeStr = UserDefaults.standard.string(forKey: "appConnectionMode") + if let savedMode = savedConnectionModeStr.flatMap(AppConnectionMode.init(rawValue:)) { + self.connectionMode = savedMode + self.isBLEEnabled = (savedMode != .network) + } else if UserDefaults.standard.object(forKey: "isBLEEnabled") != nil { + let legacyBle = UserDefaults.standard.bool(forKey: "isBLEEnabled") + self.connectionMode = legacyBle ? .dynamic : .network + self.isBLEEnabled = legacyBle + } else { + self.connectionMode = .dynamic + self.isBLEEnabled = true + } + self.isBLEAutoConnectEnabled = UserDefaults.standard.object(forKey: "isBLEAutoConnectEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isBLEAutoConnectEnabled") self.isAutoSwitchWithBLEEnabled = UserDefaults.standard.object(forKey: "isAutoSwitchWithBLEEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isAutoSwitchWithBLEEnabled") @@ -618,6 +646,23 @@ class AppState: ObservableObject { } } + @Published var connectionMode: AppConnectionMode { + didSet { + UserDefaults.standard.set(connectionMode.rawValue, forKey: "appConnectionMode") + let bleShouldBeEnabled = (connectionMode != .network) + if self.isBLEEnabled != bleShouldBeEnabled { + self.isBLEEnabled = bleShouldBeEnabled + } + if connectionMode == .nearby { + DiscoveryManager.shared.stop() + } else { + if device == nil || device?.isBLE == true { + DiscoveryManager.shared.start() + } + } + } + } + @Published var isBLEEnabled: Bool { didSet { UserDefaults.standard.set(isBLEEnabled, forKey: "isBLEEnabled") diff --git a/airsync-mac/Core/BLE/BLECentralManager.swift b/airsync-mac/Core/BLE/BLECentralManager.swift index db9d0e61..ed33f18d 100644 --- a/airsync-mac/Core/BLE/BLECentralManager.swift +++ b/airsync-mac/Core/BLE/BLECentralManager.swift @@ -11,6 +11,7 @@ class BLECentralManager: NSObject, ObservableObject { private var characteristics: [CBUUID: CBCharacteristic] = [:] private var chunkBuffers: [CBUUID: [Int: Data]] = [:] private var discoveredServices: Set = [] + private var requestedServices: Set = [] private var hasAttemptedAuth = false private let expectedServiceCount = 4 @@ -201,6 +202,11 @@ class BLECentralManager: NSObject, ObservableObject { return } + guard connectionStatus == .disconnected || connectionStatus == .scanning else { + print("[BLE] Already connecting or connected (status: \(connectionStatus))") + return + } + guard let record = discoveredPeripherals[uuidStr] else { return } let peripheral = record.peripheral print("[BLE] Manual connection requested for \(peripheral.name ?? "Unknown")") @@ -325,6 +331,8 @@ extension BLECentralManager: CBCentralManagerDelegate { connectionTimer = nil connectingDeviceUUID = nil discoveredServices.removeAll() + requestedServices.removeAll() + characteristics.removeAll() hasAttemptedAuth = false let name = peripheral.name ?? "Unknown Device" let maxWrite = peripheral.maximumWriteValueLength(for: .withoutResponse) @@ -371,6 +379,7 @@ extension BLECentralManager: CBCentralManagerDelegate { characteristics.removeAll() chunkBuffers.removeAll() discoveredServices.removeAll() + requestedServices.removeAll() hasAttemptedAuth = false DispatchQueue.main.async { @@ -396,7 +405,10 @@ extension BLECentralManager: CBPeripheralDelegate { guard let rawServices = peripheral.services as NSArray? else { return } let validServices = rawServices.compactMap { $0 as? CBService } for service in validServices { - peripheral.discoverCharacteristics(nil, for: service) + if !requestedServices.contains(service.uuid) { + requestedServices.insert(service.uuid) + peripheral.discoverCharacteristics(nil, for: service) + } } } @@ -411,7 +423,7 @@ extension BLECentralManager: CBPeripheralDelegate { for char in validChars { characteristics[char.uuid] = char - if char.properties.contains(.notify) { + if char.properties.contains(.notify) && !char.isNotifying { print("[BLE] Subscribing to \(char.uuid)") peripheral.setNotifyValue(true, for: char) } @@ -420,7 +432,7 @@ extension BLECentralManager: CBPeripheralDelegate { discoveredServices.insert(service.uuid) print("[BLE] Services discovered: \(discoveredServices.count)/\(expectedServiceCount)") - // Only attempt auth once after ALL services are discovered + // Attempt auth once after expected services are discovered and auth characteristic is ready if discoveredServices.count >= expectedServiceCount && !hasAttemptedAuth { if characteristics[BLEConstants.charAuthToken] != nil { hasAttemptedAuth = true diff --git a/airsync-mac/Core/Discovery/DiscoveryManager.swift b/airsync-mac/Core/Discovery/DiscoveryManager.swift index 84eb8ae7..680576b2 100644 --- a/airsync-mac/Core/Discovery/DiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/DiscoveryManager.swift @@ -173,6 +173,10 @@ class DiscoveryManager: ObservableObject { func start() { guard !isRunning else { return } + if AppState.shared.connectionMode == .nearby { + print("[Discovery] Connection mode is Nearby only. Skipping DiscoveryManager start.") + return + } isRunning = true print("[Discovery] Starting DiscoveryManager") diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index cee5e9b4..5ab6063f 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -340,10 +340,6 @@ extension WebSocketServer { if let data = payload.data(using: .utf8) { BLECentralManager.shared.write(characteristicUUID: BLEConstants.charMacBattery, data: data) } - - // Also send name if we have it - let name = Host.current().localizedName ?? "My Mac" - BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charDeviceName, payload: name) } func sendCallAction(eventId: String, action: String) { diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index d48581a9..c27f3e8a 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -200,5 +200,12 @@ "whatsnew.nearby.message": "Click to connect", "connection.wiredAdb": "Wired ADB", "connection.selectDevice": "Select Device", - "connection.wireless": "Wireless" + "connection.wireless": "Wireless", + "settings.connectionMode": "Connection Mode", + "connectionMode.network": "Network", + "connectionMode.dynamic": "Dynamic", + "connectionMode.nearby": "Nearby", + "settings.connectionMode.network.info": "Uses local area network only. Bluetooth LE is disabled.", + "settings.connectionMode.dynamic.info": "Uses Wi-Fi and Bluetooth LE with automatic fallback and switching.", + "settings.connectionMode.nearby.info": "Uses Bluetooth LE only. Local network discovery is disabled." } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 478d1b7b..c32cad61 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -240,17 +240,21 @@ struct ConnectionPillPopover: View { Spacer() } } else { - VStack(alignment: .leading, spacing: 8) { - - HStack { - Label("Bluetooth LE Discovery", image: "logo.bluetooth") - .font(.system(size: 12)) - Spacer() - Toggle("", isOn: $appState.isBLEEnabled) - .toggleStyle(.switch) - .controlSize(.small) + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(loc: "settings.connectionMode") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + + Picker("", selection: $appState.connectionMode) { + ForEach(AppState.AppConnectionMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .controlSize(.large) } - + HStack { Label("Auto-connect", systemImage: "arrow.triangle.2.circlepath") .font(.system(size: 12)) diff --git a/airsync-mac/Screens/Settings/MyMacSettingsView.swift b/airsync-mac/Screens/Settings/MyMacSettingsView.swift index 415f9a5f..8bfb9f61 100644 --- a/airsync-mac/Screens/Settings/MyMacSettingsView.swift +++ b/airsync-mac/Screens/Settings/MyMacSettingsView.swift @@ -19,7 +19,29 @@ struct MyMacSettingsView: View { .padding() .glassBoxIfAvailable(radius: 18) - // 2. Server settings + // 2. Connection Mode + SettingsHeaderView(title: L("settings.connectionMode"), icon: "antenna.radiowaves.left.and.right") + VStack(alignment: .leading, spacing: 12) { + HStack{ + Picker("", selection: $appState.connectionMode) { + ForEach(AppState.AppConnectionMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .controlSize(.large) + + Spacer() + } + + Text(connectionModeDescription) + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .glassBoxIfAvailable(radius: 18) + + // 3. Server settings SettingsHeaderView(title: "Server Configuration", icon: "server.rack") VStack(spacing: 12) { HStack { @@ -88,28 +110,9 @@ struct MyMacSettingsView: View { ) } - // 3. Connection settings - SettingsHeaderView(title: "Nearby", icon: "antenna.radiowaves.left.and.right") + // 4. Connection settings + SettingsHeaderView(title: "Nearby", icon: "dot.radiowaves.left.and.right") VStack(spacing: 12) { - HStack(spacing: 12) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.title3) - .foregroundColor(.accentColor) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("Nearby (Bluetooth LE)") - .font(.body) - Text("Allow nearby connection over Bluetooth LE when Wi-Fi is unavailable") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - Toggle("", isOn: $appState.isBLEEnabled) - .toggleStyle(.switch) - .controlSize(.small) - } - HStack(spacing: 12) { Image(systemName: "arrow.triangle.2.circlepath") .font(.title3) @@ -168,4 +171,15 @@ struct MyMacSettingsView: View { } } } + + private var connectionModeDescription: String { + switch appState.connectionMode { + case .network: + return L("settings.connectionMode.network.info") + case .dynamic: + return L("settings.connectionMode.dynamic.info") + case .nearby: + return L("settings.connectionMode.nearby.info") + } + } }