Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")
Expand Down
18 changes: 15 additions & 3 deletions airsync-mac/Core/BLE/BLECentralManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class BLECentralManager: NSObject, ObservableObject {
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var chunkBuffers: [CBUUID: [Int: Data]] = [:]
private var discoveredServices: Set<CBUUID> = []
private var requestedServices: Set<CBUUID> = []
private var hasAttemptedAuth = false
private let expectedServiceCount = 4

Expand Down Expand Up @@ -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")")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -371,6 +379,7 @@ extension BLECentralManager: CBCentralManagerDelegate {
characteristics.removeAll()
chunkBuffers.removeAll()
discoveredServices.removeAll()
requestedServices.removeAll()
hasAttemptedAuth = false

DispatchQueue.main.async {
Expand All @@ -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)
}
}
}

Expand All @@ -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)
}
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions airsync-mac/Core/Discovery/DiscoveryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 0 additions & 4 deletions airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion airsync-mac/Localization/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
24 changes: 14 additions & 10 deletions airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
58 changes: 36 additions & 22 deletions airsync-mac/Screens/Settings/MyMacSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
}
}