From 62e0c004447a12636f66d7dad7d2ffec1dfe28b0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 17:35:49 +0530 Subject: [PATCH 01/13] feat: Wired ADB --- airsync-mac/Core/AppState.swift | 6 ++ airsync-mac/Core/Util/CLI/ADBConnector.swift | 68 ++++++++++++++++++- .../WebSocket/WebSocketServer+Handlers.swift | 14 +++- airsync-mac/Localization/en.json | 3 +- .../Settings/SettingsFeaturesView.swift | 27 +++++++- 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 792def7b..8118a963 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -29,6 +29,7 @@ class AppState: ObservableObject { self.adbConnectedIP = UserDefaults.standard.string(forKey: "adbConnectedIP") ?? "" self.mirroringPlus = UserDefaults.standard.bool(forKey: "mirroringPlus") self.adbEnabled = UserDefaults.standard.bool(forKey: "adbEnabled") + self.wiredAdbEnabled = UserDefaults.standard.bool(forKey: "wiredAdbEnabled") self.suppressAdbFailureAlerts = UserDefaults.standard.bool(forKey: "suppressAdbFailureAlerts") let savedFallbackToMdns = UserDefaults.standard.object(forKey: "fallbackToMdns") @@ -234,6 +235,11 @@ class AppState: ObservableObject { UserDefaults.standard.set(adbEnabled, forKey: "adbEnabled") } } + @Published var wiredAdbEnabled: Bool { + didSet { + UserDefaults.standard.set(wiredAdbEnabled, forKey: "wiredAdbEnabled") + } + } @Published var suppressAdbFailureAlerts: Bool { didSet { diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index 20cb5bd0..b6455c2c 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -78,6 +78,43 @@ struct ADBConnector { print("[adb-connector] (Binary Detection) \(message)") } + // Get the serial of a wired (USB) device if available + static func getWiredDeviceSerial() -> String? { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { return nil } + + let process = Process() + process.executableURL = URL(fileURLWithPath: adbPath) + process.arguments = ["devices", "-l"] + + let pipe = Pipe() + process.standardOutput = pipe + + do { + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let lines = output.components(separatedBy: .newlines) + + for line in lines { + // We look for 'usb:' to identify wired devices + if line.contains("device") && line.contains("usb:") { + let parts = line.split(separator: " ").filter { !$0.isEmpty } + if !parts.isEmpty { + let serial = String(parts[0]) + logBinaryDetection("Detected wired ADB device: \(serial)") + return serial + } + } + } + } catch { + print("[adb-connector] Error getting wired devices: \(error)") + } + + return nil + } + private static func clearConnectionFlag() { // Note: This function must ONLY be called while holding the connectionLock // Do NOT try to acquire the lock here @@ -471,13 +508,20 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu var args = [ "--window-title=\(deviceNameFormatted)", - "--tcpip=\(fullAddress)", "--video-bit-rate=\(bitrate)M", "--video-codec=h265", "--max-size=\(resolution)", "--no-power-on" ] + // Prioritize wired ADB if enabled + if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { + args.append("--serial=\(serial)") + logBinaryDetection("Wired ADB prioritized: using serial \(serial)") + } else { + args.append("--tcpip=\(fullAddress)") + } + if manualPosition { args.append("--window-x=\(manualPositionCoords[0])") args.append("--window-y=\(manualPositionCoords[1])") @@ -620,7 +664,16 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu } DispatchQueue.global(qos: .userInitiated).async { - let args = ["-s", fullAddress, "pull", remotePath, destinationURL.path] + var args = ["pull", remotePath, destinationURL.path] + + // Prioritize wired ADB if enabled + if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { + args.insert(contentsOf: ["-s", serial], at: 0) + logBinaryDetection("Wired ADB prioritized for pull: using serial \(serial)") + } else { + args.insert(contentsOf: ["-s", fullAddress], at: 0) + } + logBinaryDetection("Pulling: \(adbPath) \(args.joined(separator: " "))") runADBCommand(adbPath: adbPath, arguments: args, completion: { output in @@ -665,7 +718,16 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu } DispatchQueue.global(qos: .userInitiated).async { - let args = ["-s", fullAddress, "push", localPath, remotePath] + var args = ["push", localPath, remotePath] + + // Prioritize wired ADB if enabled + if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { + args.insert(contentsOf: ["-s", serial], at: 0) + logBinaryDetection("Wired ADB prioritized for push: using serial \(serial)") + } else { + args.insert(contentsOf: ["-s", fullAddress], at: 0) + } + logBinaryDetection("Pushing: \(adbPath) \(args.joined(separator: " "))") runADBCommand(adbPath: adbPath, arguments: args, completion: { output in diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 3b75d2fb..7c83d0ef 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -141,9 +141,17 @@ extension WebSocketServer { } } - if (!AppState.shared.adbConnected && (AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending) && AppState.shared.isPlus) { - ADBConnector.connectToADB(ip: ip) - AppState.shared.manualAdbConnectionPending = false + if (!AppState.shared.adbConnected && (AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending || AppState.shared.wiredAdbEnabled) && AppState.shared.isPlus) { + // If wired ADB is enabled and a wired device is connected, mark as connected + if AppState.shared.wiredAdbEnabled, let serial = ADBConnector.getWiredDeviceSerial() { + AppState.shared.adbConnected = true + AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" + AppState.shared.manualAdbConnectionPending = false + } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { + // Try wireless connection + ADBConnector.connectToADB(ip: ip) + AppState.shared.manualAdbConnectionPending = false + } } if UserDefaults.standard.hasPairedDeviceOnce == false { diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index 8b0b6087..2d79eb0c 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -25,5 +25,6 @@ "status.battery": "Battery", "status.volume": "Volume", "media.play": "Play", - "media.pause": "Pause" + "media.pause": "Pause", + "settings.wiredAdb": "Wired ADB support" } diff --git a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift index 3de10abd..fece7b7f 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -99,7 +99,7 @@ struct SettingsFeaturesView: View { } } .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { - PlusFeaturePopover(message: "Wireless ADB features are available in AirSync+") + PlusFeaturePopover(message: "Wireless and Wired ADB features are available in AirSync+") .onTapGesture { showingPlusPopover = false } @@ -113,6 +113,31 @@ struct SettingsFeaturesView: View { .transition(.opacity) } + HStack { + ZStack { + HStack { + Label(L("settings.wiredAdb"), systemImage: "cable.connector") + Spacer() + Toggle("", isOn: $appState.wiredAdbEnabled) + .toggleStyle(.switch) + .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) + } + + if !AppState.shared.isPlus && AppState.shared.licenseCheck { + HStack { + Spacer() + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + showingPlusPopover = true + } + .frame(width: 500) + } + } + } + } + HStack { Label("Suppress failed messages", systemImage: "bell.slash") Spacer() From bebca9de0c8387403e5528f7ffbe6f1eb00db6d8 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 17:47:00 +0530 Subject: [PATCH 02/13] feat: Connection status pill in screen view --- airsync-mac/Core/AppState.swift | 20 +++++- airsync-mac/Core/Util/CLI/ADBConnector.swift | 7 ++ .../WebSocket/WebSocketServer+Handlers.swift | 2 + .../PhoneView/ConnectionStatusPill.swift | 68 +++++++++++++++++++ .../HomeScreen/PhoneView/ScreenView.swift | 7 +- 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 8118a963..3fcba1b6 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -13,6 +13,11 @@ import AVFoundation class AppState: ObservableObject { static let shared = AppState() + + enum ADBConnectionMode: String, Codable { + case wireless + case wired + } private var clipboardCancellable: AnyCancellable? private var lastClipboardValue: String? = nil @@ -160,11 +165,24 @@ class AppState: ObservableObject { @Published var webSocketStatus: WebSocketStatus = .stopped @Published var selectedTab: TabIdentifier = .qr - @Published var adbConnected: Bool = false + @Published var adbConnected: Bool = false { + didSet { + if !adbConnected { + adbConnectionMode = nil + } + } + } @Published var adbConnecting: Bool = false @Published var manualAdbConnectionPending: Bool = false @Published var currentDeviceWallpaperBase64: String? = nil @Published var isMenubarWindowOpen: Bool = false + @Published var adbConnectionMode: ADBConnectionMode? = nil + + var isConnectedOverLocalNetwork: Bool { + guard let ip = device?.ipAddress else { return true } + // Tailscale IPs usually start with 100. + return !ip.hasPrefix("100.") + } // Audio player for ringtone private var ringtonePlayer: AVAudioPlayer? diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index b6455c2c..c85dbc92 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -240,6 +240,7 @@ struct ADBConnector { AppState.shared.adbPort = port } AppState.shared.adbConnected = true + AppState.shared.adbConnectionMode = .wireless AppState.shared.adbConnectionResult = trimmedOutput logBinaryDetection("(/^▽^)/ ADB connection successful to \(fullAddress)") AppState.shared.adbConnecting = false @@ -517,9 +518,11 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu // Prioritize wired ADB if enabled if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { args.append("--serial=\(serial)") + AppState.shared.adbConnectionMode = .wired logBinaryDetection("Wired ADB prioritized: using serial \(serial)") } else { args.append("--tcpip=\(fullAddress)") + AppState.shared.adbConnectionMode = .wireless } if manualPosition { @@ -669,9 +672,11 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu // Prioritize wired ADB if enabled if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { args.insert(contentsOf: ["-s", serial], at: 0) + AppState.shared.adbConnectionMode = .wired logBinaryDetection("Wired ADB prioritized for pull: using serial \(serial)") } else { args.insert(contentsOf: ["-s", fullAddress], at: 0) + AppState.shared.adbConnectionMode = .wireless } logBinaryDetection("Pulling: \(adbPath) \(args.joined(separator: " "))") @@ -723,9 +728,11 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu // Prioritize wired ADB if enabled if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { args.insert(contentsOf: ["-s", serial], at: 0) + AppState.shared.adbConnectionMode = .wired logBinaryDetection("Wired ADB prioritized for push: using serial \(serial)") } else { args.insert(contentsOf: ["-s", fullAddress], at: 0) + AppState.shared.adbConnectionMode = .wireless } logBinaryDetection("Pushing: \(adbPath) \(args.joined(separator: " "))") diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 7c83d0ef..59ca28b5 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -145,12 +145,14 @@ extension WebSocketServer { // If wired ADB is enabled and a wired device is connected, mark as connected if AppState.shared.wiredAdbEnabled, let serial = ADBConnector.getWiredDeviceSerial() { AppState.shared.adbConnected = true + AppState.shared.adbConnectionMode = .wired AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" AppState.shared.manualAdbConnectionPending = false } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { // Try wireless connection ADBConnector.connectToADB(ip: ip) AppState.shared.manualAdbConnectionPending = false + // Note: adbConnectionMode will be set to .wireless in attemptDirectConnection on success } } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift new file mode 100644 index 00000000..28ab2412 --- /dev/null +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -0,0 +1,68 @@ +// +// ConnectionStatusPill.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-03-11. +// + +import SwiftUI + +struct ConnectionStatusPill: View { + @ObservedObject var appState = AppState.shared + + var body: some View { + HStack(spacing: 8) { + // Network Connection Icon + Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") + .contentTransition(.symbolEffect(.replace)) + .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") + + if appState.adbConnected { + // ADB Indicator + HStack(spacing: 6) { + Image(systemName: "iphone.gen3.crop.circle") + .contentTransition(.symbolEffect(.replace)) + + // ADB Mode Icon + Image(systemName: adbModeIcon) + .contentTransition(.symbolEffect(.replace)) + .help(adbModeHelp) + } + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .applyGlassViewIfAvailable() + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnected) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnectionMode) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.isConnectedOverLocalNetwork) + } + + private var adbModeIcon: String { + switch appState.adbConnectionMode { + case .wired: + return "cable.connector" + case .wireless, .none: + return "airplay.audio" + } + } + + private var adbModeHelp: String { + switch appState.adbConnectionMode { + case .wired: + return "Wired ADB Connection" + case .wireless, .none: + return "Wireless ADB Connection" + } + } +} + +#Preview { + ConnectionStatusPill() + .padding() + .background(Color.black.opacity(0.1)) +} diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift index 882e01ec..c4073540 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift @@ -12,9 +12,12 @@ struct ScreenView: View { @State private var showingPlusPopover = false var body: some View { - VStack{ + VStack { + ConnectionStatusPill() + .padding(.top, 4) + ConnectionStateView() - .padding(.top, 12) + .padding(.top, 4) Spacer() From 7fa7d895bbbc2393115c1584ce34c96ca7e41866 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 17:52:57 +0530 Subject: [PATCH 03/13] feat: ADB connection state in connection status pill --- .../HomeScreen/PhoneView/ConnectionStateView.swift | 9 +-------- .../HomeScreen/PhoneView/ConnectionStatusPill.swift | 9 ++++++++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStateView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStateView.swift index 4fddaa82..ddc41565 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStateView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStateView.swift @@ -14,14 +14,7 @@ struct ConnectionStateView: View { var body: some View { ZStack { - if appState.adbConnecting && !showResult { - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Connecting ADB...") - } - .padding(8) - .applyGlassViewIfAvailable() - } else if showResult { + if showResult { HStack(spacing: 8) { Image(systemName: isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundColor(isSuccess ? .green : .red) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 28ab2412..3000a204 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -17,7 +17,14 @@ struct ConnectionStatusPill: View { .contentTransition(.symbolEffect(.replace)) .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") - if appState.adbConnected { + if appState.adbConnecting { + ProgressView() + .controlSize(.small) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } else if appState.adbConnected { // ADB Indicator HStack(spacing: 6) { Image(systemName: "iphone.gen3.crop.circle") From e78dd46759b25fd7fde73f6020910e6e1d904244 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 18:16:39 +0530 Subject: [PATCH 04/13] feat: Device connection status popover for connection and disconnection --- .../PhoneView/ConnectionStatusPill.swift | 139 ++++++++++++++---- .../Screens/HomeScreen/SidebarView.swift | 26 +--- 2 files changed, 113 insertions(+), 52 deletions(-) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 3000a204..3aced210 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -9,44 +9,61 @@ import SwiftUI struct ConnectionStatusPill: View { @ObservedObject var appState = AppState.shared + @State private var showingPopover = false + @State private var isHovered = false var body: some View { - HStack(spacing: 8) { - // Network Connection Icon - Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") - .contentTransition(.symbolEffect(.replace)) - .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") - - if appState.adbConnecting { - ProgressView() - .controlSize(.small) + Button(action: { + showingPopover.toggle() + }) { + HStack(spacing: 8) { + // Network Connection Icon + Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") + .contentTransition(.symbolEffect(.replace)) + .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") + + if appState.adbConnecting { + ProgressView() + .controlSize(.small) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } else if appState.adbConnected { + // ADB Indicator + HStack(spacing: 6) { + Image(systemName: "iphone.gen3.crop.circle") + .contentTransition(.symbolEffect(.replace)) + + // ADB Mode Icon + Image(systemName: adbModeIcon) + .contentTransition(.symbolEffect(.replace)) + .help(adbModeHelp) + } .transition(.asymmetric( insertion: .scale.combined(with: .opacity), removal: .opacity )) - } else if appState.adbConnected { - // ADB Indicator - HStack(spacing: 6) { - Image(systemName: "iphone.gen3.crop.circle") - .contentTransition(.symbolEffect(.replace)) - - // ADB Mode Icon - Image(systemName: adbModeIcon) - .contentTransition(.symbolEffect(.replace)) - .help(adbModeHelp) } - .transition(.asymmetric( - insertion: .scale.combined(with: .opacity), - removal: .opacity - )) } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .applyGlassViewIfAvailable() + .scaleEffect(isHovered ? 1.05 : 1.0) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnected) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnectionMode) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.isConnectedOverLocalNetwork) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isHovered = hovering + } + } + .popover(isPresented: $showingPopover, arrowEdge: .top) { + ConnectionPillPopover() } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .applyGlassViewIfAvailable() - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnected) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnectionMode) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.isConnectedOverLocalNetwork) } private var adbModeIcon: String { @@ -68,6 +85,70 @@ struct ConnectionStatusPill: View { } } +struct ConnectionPillPopover: View { + @ObservedObject var appState = AppState.shared + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Connection") + .font(.headline) + + if appState.device != nil { + HStack(spacing: 8) { + if appState.adbConnected { + GlassButtonView( + label: "Disconnect ADB", + systemImage: "cable.connector.slash", + iconOnly: false, + primary: false, + action: { + ADBConnector.disconnectADB() + } + ) + .focusable(false) + } else if !appState.adbConnecting { + GlassButtonView( + label: "Connect ADB", + systemImage: "cable.connector", + iconOnly: false, + primary: false, + action: { + ADBConnector.connectToADB(ip: appState.device?.ipAddress ?? "") + } + ) + .focusable(false) + } else { + HStack { + ProgressView().controlSize(.small) + Text("Connecting ADB...") + .font(.caption) + .foregroundColor(.secondary) + } + } + + GlassButtonView( + label: "Disconnect Device", + systemImage: "iphone.slash", + iconOnly: false, + primary: true, + action: { + appState.disconnectDevice() + ADBConnector.disconnectADB() + appState.adbConnected = false + } + ) + .focusable(false) + } + } else { + Text("No device connected") + .foregroundColor(.secondary) + } + } + .padding() + } +} + + #Preview { ConnectionStatusPill() .padding() diff --git a/airsync-mac/Screens/HomeScreen/SidebarView.swift b/airsync-mac/Screens/HomeScreen/SidebarView.swift index 4662c289..4c4f4a66 100644 --- a/airsync-mac/Screens/HomeScreen/SidebarView.swift +++ b/airsync-mac/Screens/HomeScreen/SidebarView.swift @@ -12,7 +12,6 @@ struct SidebarView: View { @ObservedObject var appState = AppState.shared @State private var isExpandedAllSeas: Bool = false - @State private var showDisconnectAlert = false var body: some View { VStack{ @@ -46,35 +45,16 @@ struct SidebarView: View { .frame(minWidth: 280, minHeight: 400) .safeAreaInset(edge: .bottom) { HStack{ - if appState.device != nil { - GlassButtonView( - label: "Disconnect", - systemImage: "xmark", - action: { - showDisconnectAlert = true - } - ) - .transition(.identity) - } else { + if appState.device == nil { Label("Connect your device", systemImage: "arrow.2.circlepath.circle") } } .padding(16) } } - .alert(isPresented: $showDisconnectAlert) { - Alert( - title: Text("Disconnect Device"), - message: Text("Do you want to disconnect \"\(appState.device?.name ?? "device")\"?"), - primaryButton: .destructive(Text("Disconnect")) { - appState.disconnectDevice() - ADBConnector.disconnectADB() - appState.adbConnected = false - }, - secondaryButton: .cancel() - ) - } + } + } #Preview { From 892d772b192bca85e9dd75479f8f070dea4dcbab Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 18:30:23 +0530 Subject: [PATCH 05/13] feat: Performance improvements --- airsync-mac/Core/Util/CLI/ADBConnector.swift | 30 ++++++++++++++------ airsync-mac/Core/Util/Gumroad.swift | 10 +++++-- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index c85dbc92..d7c218c7 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -282,9 +282,8 @@ Possible fixes: Please see the ADB console for more details. """ - let response = alert.runModal() - if response == .alertFirstButtonReturn { - DispatchQueue.main.async { + presentAlertAsynchronously(alert) { response in + if response == .alertFirstButtonReturn { AppState.shared.suppressAdbFailureAlerts = true } } @@ -342,9 +341,8 @@ Suggestions: Please see the ADB console for more details. """ - let response = alert.runModal() - if response == .alertFirstButtonReturn { - DispatchQueue.main.async { + presentAlertAsynchronously(alert) { response in + if response == .alertFirstButtonReturn { AppState.shared.suppressAdbFailureAlerts = true } } @@ -518,11 +516,11 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu // Prioritize wired ADB if enabled if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { args.append("--serial=\(serial)") - AppState.shared.adbConnectionMode = .wired + AppState.shared.adbConnectionMode = AppState.ADBConnectionMode.wired logBinaryDetection("Wired ADB prioritized: using serial \(serial)") } else { args.append("--tcpip=\(fullAddress)") - AppState.shared.adbConnectionMode = .wireless + AppState.shared.adbConnectionMode = AppState.ADBConnectionMode.wireless } if manualPosition { @@ -753,6 +751,20 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu // MARK: - Alert Helper private extension ADBConnector { + static func presentAlertAsynchronously(_ alert: NSAlert, completion: ((NSApplication.ModalResponse) -> Void)? = nil) { + DispatchQueue.main.async { + if let window = NSApp.windows.first(where: { $0.isKeyWindow && $0.isVisible }) ?? NSApp.windows.first(where: { $0.isVisible }) { + alert.beginSheetModal(for: window) { response in + completion?(response) + } + } else { + NSApp.activate(ignoringOtherApps: true) + let response = alert.runModal() + completion?(response) + } + } + } + static func presentScrcpyAlert(title: String, informative: String) { // Present immediately on main thread (caller ensures main queue) let alert = NSAlert() @@ -760,6 +772,6 @@ private extension ADBConnector { alert.messageText = title alert.informativeText = informative + "\n\nCheck the ADB Console in Settings for detailed logs." alert.addButton(withTitle: "OK") - alert.runModal() + presentAlertAsynchronously(alert) } } diff --git a/airsync-mac/Core/Util/Gumroad.swift b/airsync-mac/Core/Util/Gumroad.swift index 0d1309bd..de57b80f 100644 --- a/airsync-mac/Core/Util/Gumroad.swift +++ b/airsync-mac/Core/Util/Gumroad.swift @@ -165,14 +165,20 @@ class Gumroad { UserDefaults.standard.consecutiveNetworkFailureDays = 0 UserDefaults.standard.set(nil, forKey: "lastNetworkFailureDay") - // Inform user with a blocking popup + // Inform user without blocking main thread DispatchQueue.main.async { let alert = NSAlert() alert.alertStyle = .warning alert.addButton(withTitle: "OK") alert.messageText = "AirSync+ Unregistered" alert.informativeText = reason - alert.runModal() + + if let window = NSApp.windows.first(where: { $0.isKeyWindow && $0.isVisible }) ?? NSApp.windows.first(where: { $0.isVisible }) { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } } } From abf937c93944e29b3629c94534779ff2de4c4b89 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 18:33:51 +0530 Subject: [PATCH 06/13] fix: Connect adb button generalization --- .gitignore | 1 + .../HomeScreen/PhoneView/ConnectionStatusPill.swift | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9c121a9c..78144578 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ AGENTS.md .rambles .tend-stack docs/plans/ +build.log diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 3aced210..92e65504 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -113,7 +113,12 @@ struct ConnectionPillPopover: View { iconOnly: false, primary: false, action: { - ADBConnector.connectToADB(ip: appState.device?.ipAddress ?? "") + if !appState.adbConnecting { + appState.adbConnectionResult = "" // Clear console + appState.manualAdbConnectionPending = true + WebSocketServer.shared.sendRefreshAdbPortsRequest() + appState.adbConnectionResult = "Refreshing latest ADB ports from device..." + } } ) .focusable(false) From 9bdeaf0603ce0293ff0e4f6c717858c58d95f1ac Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 18:43:02 +0530 Subject: [PATCH 07/13] feat: More info in teh device connection infor popover --- .../PhoneView/ConnectionStatusPill.swift | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 92e65504..f7279ff1 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -87,13 +87,38 @@ struct ConnectionStatusPill: View { struct ConnectionPillPopover: View { @ObservedObject var appState = AppState.shared + @State private var currentIPAddress: String = "N/A" var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Connection") .font(.headline) - if appState.device != nil { + if let device = appState.device { + VStack(alignment: .leading, spacing: 8) { + ConnectionInfoText( + label: "Device", + icon: "iphone.gen3", + text: device.name + ) + + ConnectionInfoText( + label: "IP Address", + icon: "wifi", + text: currentIPAddress, + activeIp: appState.activeMacIp + ) + + if appState.adbConnected { + ConnectionInfoText( + label: "ADB Connection", + icon: appState.adbConnectionMode == .wired ? "cable.connector" : "airplay.audio", + text: appState.adbConnectionMode == .wired ? "Wired (USB)" : "Wireless" + ) + } + } + .padding(.bottom, 4) + HStack(spacing: 8) { if appState.adbConnected { GlassButtonView( @@ -150,6 +175,9 @@ struct ConnectionPillPopover: View { } } .padding() + .onAppear { + currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" + } } } From ed1212169bbaa8ef6e3847be7b813db2520826d6 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 18:45:45 +0530 Subject: [PATCH 08/13] fix: Ensure plus checks --- .../PhoneView/ConnectionStatusPill.swift | 106 +++++++++--------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index f7279ff1..d8610772 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -22,28 +22,30 @@ struct ConnectionStatusPill: View { .contentTransition(.symbolEffect(.replace)) .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") - if appState.adbConnecting { - ProgressView() - .controlSize(.small) + if appState.isPlus { + if appState.adbConnecting { + ProgressView() + .controlSize(.small) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } else if appState.adbConnected { + // ADB Indicator + HStack(spacing: 6) { + Image(systemName: "iphone.gen3.crop.circle") + .contentTransition(.symbolEffect(.replace)) + + // ADB Mode Icon + Image(systemName: adbModeIcon) + .contentTransition(.symbolEffect(.replace)) + .help(adbModeHelp) + } .transition(.asymmetric( insertion: .scale.combined(with: .opacity), removal: .opacity )) - } else if appState.adbConnected { - // ADB Indicator - HStack(spacing: 6) { - Image(systemName: "iphone.gen3.crop.circle") - .contentTransition(.symbolEffect(.replace)) - - // ADB Mode Icon - Image(systemName: adbModeIcon) - .contentTransition(.symbolEffect(.replace)) - .help(adbModeHelp) } - .transition(.asymmetric( - insertion: .scale.combined(with: .opacity), - removal: .opacity - )) } } .padding(.horizontal, 10) @@ -109,7 +111,7 @@ struct ConnectionPillPopover: View { activeIp: appState.activeMacIp ) - if appState.adbConnected { + if appState.isPlus && appState.adbConnected { ConnectionInfoText( label: "ADB Connection", icon: appState.adbConnectionMode == .wired ? "cable.connector" : "airplay.audio", @@ -120,39 +122,41 @@ struct ConnectionPillPopover: View { .padding(.bottom, 4) HStack(spacing: 8) { - if appState.adbConnected { - GlassButtonView( - label: "Disconnect ADB", - systemImage: "cable.connector.slash", - iconOnly: false, - primary: false, - action: { - ADBConnector.disconnectADB() - } - ) - .focusable(false) - } else if !appState.adbConnecting { - GlassButtonView( - label: "Connect ADB", - systemImage: "cable.connector", - iconOnly: false, - primary: false, - action: { - if !appState.adbConnecting { - appState.adbConnectionResult = "" // Clear console - appState.manualAdbConnectionPending = true - WebSocketServer.shared.sendRefreshAdbPortsRequest() - appState.adbConnectionResult = "Refreshing latest ADB ports from device..." + if appState.isPlus { + if appState.adbConnected { + GlassButtonView( + label: "Disconnect ADB", + systemImage: "cable.connector.slash", + iconOnly: false, + primary: false, + action: { + ADBConnector.disconnectADB() } + ) + .focusable(false) + } else if !appState.adbConnecting { + GlassButtonView( + label: "Connect ADB", + systemImage: "cable.connector", + iconOnly: false, + primary: false, + action: { + if !appState.adbConnecting { + appState.adbConnectionResult = "" // Clear console + appState.manualAdbConnectionPending = true + WebSocketServer.shared.sendRefreshAdbPortsRequest() + appState.adbConnectionResult = "Refreshing latest ADB ports from device..." + } + } + ) + .focusable(false) + } else { + HStack { + ProgressView().controlSize(.small) + Text("Connecting ADB...") + .font(.caption) + .foregroundColor(.secondary) } - ) - .focusable(false) - } else { - HStack { - ProgressView().controlSize(.small) - Text("Connecting ADB...") - .font(.caption) - .foregroundColor(.secondary) } } @@ -163,7 +167,9 @@ struct ConnectionPillPopover: View { primary: true, action: { appState.disconnectDevice() - ADBConnector.disconnectADB() + if appState.isPlus { + ADBConnector.disconnectADB() + } appState.adbConnected = false } ) From e1dc4c2671d0441c80d00981e40ebc1ac78bfabb Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 18:49:49 +0530 Subject: [PATCH 09/13] fix: ADB file transfer --- airsync-mac/Core/Util/CLI/ADBConnector.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index d7c218c7..ecb72aa1 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -670,11 +670,15 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu // Prioritize wired ADB if enabled if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { args.insert(contentsOf: ["-s", serial], at: 0) - AppState.shared.adbConnectionMode = .wired + DispatchQueue.main.async { + AppState.shared.adbConnectionMode = .wired + } logBinaryDetection("Wired ADB prioritized for pull: using serial \(serial)") } else { args.insert(contentsOf: ["-s", fullAddress], at: 0) - AppState.shared.adbConnectionMode = .wireless + DispatchQueue.main.async { + AppState.shared.adbConnectionMode = .wireless + } } logBinaryDetection("Pulling: \(adbPath) \(args.joined(separator: " "))") @@ -726,11 +730,15 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu // Prioritize wired ADB if enabled if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { args.insert(contentsOf: ["-s", serial], at: 0) - AppState.shared.adbConnectionMode = .wired + DispatchQueue.main.async { + AppState.shared.adbConnectionMode = .wired + } logBinaryDetection("Wired ADB prioritized for push: using serial \(serial)") } else { args.insert(contentsOf: ["-s", fullAddress], at: 0) - AppState.shared.adbConnectionMode = .wireless + DispatchQueue.main.async { + AppState.shared.adbConnectionMode = .wireless + } } logBinaryDetection("Pushing: \(adbPath) \(args.joined(separator: " "))") From 82bbf1344d5b64ceaefb10f29f5a4776251fda33 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 19:07:43 +0530 Subject: [PATCH 10/13] fix: ADB Hang --- airsync-mac/Core/Util/CLI/ADBConnector.swift | 685 +++++++----------- .../Util/MacInfo/MacInfoSyncManager.swift | 22 +- .../WebSocket/WebSocketServer+Handlers.swift | 26 +- .../Settings/SettingsFeaturesView.swift | 1 + 4 files changed, 290 insertions(+), 444 deletions(-) diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index ecb72aa1..dd59ae9e 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -78,115 +78,129 @@ struct ADBConnector { print("[adb-connector] (Binary Detection) \(message)") } - // Get the serial of a wired (USB) device if available - static func getWiredDeviceSerial() -> String? { - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { return nil } - - let process = Process() - process.executableURL = URL(fileURLWithPath: adbPath) - process.arguments = ["devices", "-l"] - - let pipe = Pipe() - process.standardOutput = pipe - - do { - try process.run() - process.waitUntilExit() + static func getWiredDeviceSerial(completion: @escaping (String?) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { + completion(nil) + return + } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - let lines = output.components(separatedBy: .newlines) + let process = Process() + process.executableURL = URL(fileURLWithPath: adbPath) + process.arguments = ["devices", "-l"] - for line in lines { - // We look for 'usb:' to identify wired devices - if line.contains("device") && line.contains("usb:") { - let parts = line.split(separator: " ").filter { !$0.isEmpty } - if !parts.isEmpty { - let serial = String(parts[0]) - logBinaryDetection("Detected wired ADB device: \(serial)") - return serial + let pipe = Pipe() + process.standardOutput = pipe + + do { + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let lines = output.components(separatedBy: .newlines) + + for line in lines { + if line.contains("device") && line.contains("usb:") { + let parts = line.split(separator: " ").filter { !$0.isEmpty } + if !parts.isEmpty { + let serial = String(parts[0]) + logBinaryDetection("Detected wired ADB device: \(serial)") + completion(serial) + return + } } } + } catch { + print("[adb-connector] Error getting wired devices: \(error)") } - } catch { - print("[adb-connector] Error getting wired devices: \(error)") + completion(nil) } - - return nil } private static func clearConnectionFlag() { - // Note: This function must ONLY be called while holding the connectionLock - // Do NOT try to acquire the lock here + connectionLock.lock() isConnecting = false + connectionLock.unlock() } static func connectToADB(ip: String) { connectionLock.lock() - defer { connectionLock.unlock() } - - // Prevent concurrent connection attempts if isConnecting { + connectionLock.unlock() logBinaryDetection("ADB connection already in progress, ignoring duplicate request") return } - isConnecting = true + connectionLock.unlock() - // Find adb - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - AppState.shared.adbConnectionResult = "ADB not found. Please install via Homebrew: brew install android-platform-tools" - AppState.shared.adbConnected = false - DispatchQueue.main.async { AppState.shared.adbConnecting = false } - clearConnectionFlag() - return - } + DispatchQueue.global(qos: .userInitiated).async { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "ADB not found. Please install via Homebrew: brew install android-platform-tools" + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + } + clearConnectionFlag() + return + } - DispatchQueue.main.async { AppState.shared.adbConnecting = true } + DispatchQueue.main.async { AppState.shared.adbConnecting = true } - // Get ADB ports from device info - let devicePorts = AppState.shared.device?.adbPorts ?? [] - - if devicePorts.isEmpty { - if AppState.shared.fallbackToMdns { - logBinaryDetection("Device reported no ADB ports, attempting mDNS discovery...") - discoverADBPorts(adbPath: adbPath, ip: ip) { ports in - if ports.isEmpty { - logBinaryDetection("mDNS discovery found no ports for \(ip).") - DispatchQueue.main.async { - AppState.shared.adbConnected = false - AppState.shared.adbConnecting = false - AppState.shared.adbConnectionResult = "No ADB ports reported by device and mDNS discovery failed." + var devicePorts: [String] = [] + var fallbackToMdns = true + + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { + devicePorts = AppState.shared.device?.adbPorts ?? [] + fallbackToMdns = AppState.shared.fallbackToMdns + semaphore.signal() + } + semaphore.wait() + + if devicePorts.isEmpty { + if fallbackToMdns { + logBinaryDetection("Device reported no ADB ports, attempting mDNS discovery...") + discoverADBPorts(adbPath: adbPath, ip: ip) { ports in + if ports.isEmpty { + logBinaryDetection("mDNS discovery found no ports for \(ip).") + DispatchQueue.main.async { + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + AppState.shared.adbConnectionResult = "No ADB ports reported by device and mDNS discovery failed." + } + clearConnectionFlag() + } else { + logBinaryDetection("mDNS discovery found ports: \(ports.map(String.init).joined(separator: ", "))") + self.proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: ports) } - connectionLock.lock() - clearConnectionFlag() - connectionLock.unlock() - } else { - logBinaryDetection("mDNS discovery found ports: \(ports.map(String.init).joined(separator: ", "))") - self.proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: ports) } + } else { + logBinaryDetection("Device reported no ADB ports and mDNS fallback is disabled.") + DispatchQueue.main.async { + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + } + clearConnectionFlag() + } + return + } + + logBinaryDetection("Using ADB ports from device: \(devicePorts.joined(separator: ", "))") + let portsToTry = devicePorts.compactMap { UInt16($0) } + + guard !portsToTry.isEmpty else { + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "Device reported ADB ports but none could be parsed as valid port numbers." + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false } - } else { - logBinaryDetection("Device reported no ADB ports and mDNS fallback is disabled.") - AppState.shared.adbConnected = false - DispatchQueue.main.async { AppState.shared.adbConnecting = false } clearConnectionFlag() + return } - return - } - - logBinaryDetection("Using ADB ports from device: \(devicePorts.joined(separator: ", "))") - let portsToTry = devicePorts.compactMap { UInt16($0) } - - guard !portsToTry.isEmpty else { - AppState.shared.adbConnectionResult = "Device reported ADB ports but none could be parsed as valid port numbers." - AppState.shared.adbConnected = false - DispatchQueue.main.async { AppState.shared.adbConnecting = false } - clearConnectionFlag() - return + + proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: portsToTry) } - - proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: portsToTry) } private static func discoverADBPorts(adbPath: String, ip: String, completion: @escaping ([UInt16]) -> Void) { @@ -195,12 +209,10 @@ struct ADBConnector { var ports: [UInt16] = [] for line in lines { - // Typical line: _adb-tls-connect._tcp. 192.168.1.100:34567 if line.contains(ip) { let parts = line.split(separator: ":") if parts.count >= 2 { let portPart = parts.last?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - // Extract only the numeric part if there's trailing junk let numericPort = portPart.filter { "0123456789".contains($0) } if let port = UInt16(numericPort) { if !ports.contains(port) { @@ -218,89 +230,15 @@ struct ADBConnector { logBinaryDetection("Proceeding with ADB connection attempts to \(ip)...") DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) { - // Try each port until one succeeds attemptConnectionToNextPort(adbPath: adbPath, ip: ip, portsToTry: portsToTry, currentIndex: 0, reportedIP: ip) } } - // Attempt connection using custom port directly without mDNS discovery - private static func attemptDirectConnection(adbPath: String, fullAddress: String) { - logBinaryDetection("Attempting direct connection to custom port: \(adbPath) connect \(fullAddress)") - - runADBCommand(adbPath: adbPath, arguments: ["connect", fullAddress], completion: { output in - let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - - DispatchQueue.main.async { - UserDefaults.standard.lastADBCommand = "adb connect \(fullAddress)" - - if trimmedOutput.contains("connected to") { - // Success! Connection established - if let portStr = fullAddress.split(separator: ":").last, - let port = UInt16(portStr) { - AppState.shared.adbPort = port - } - AppState.shared.adbConnected = true - AppState.shared.adbConnectionMode = .wireless - AppState.shared.adbConnectionResult = trimmedOutput - logBinaryDetection("(/^▽^)/ ADB connection successful to \(fullAddress)") - AppState.shared.adbConnecting = false - clearConnectionFlag() - } else { - // Connection failed - show error popup only on final failure - AppState.shared.adbConnected = false - logBinaryDetection("(T_T) Custom port connection failed: \(trimmedOutput)") - AppState.shared.adbConnectionResult = """ -Failed to connect to custom ADB port. - -Address: \(fullAddress) -Error: \(trimmedOutput) - -Possible fixes: -- Verify the custom port is correct -- Ensure Wireless Debugging is enabled on the device -- Check that the device is reachable at the specified IP -- Disable custom port and use automatic discovery -""" - AppState.shared.adbConnecting = false - clearConnectionFlag() - - // Show alert popup for custom port failure (unless suppressed) - if !AppState.shared.suppressAdbFailureAlerts { - let alert = NSAlert() - alert.alertStyle = .warning - alert.addButton(withTitle: "Don't warn me again") - alert.addButton(withTitle: "OK") - alert.messageText = "Failed to connect to ADB on custom port." - alert.informativeText = """ -Address: \(fullAddress) - -Possible fixes: -- Verify the custom port is correct -- Ensure Wireless Debugging is enabled on the device -- Check that the device is reachable at the specified IP -- Disable custom port and use automatic discovery - -Please see the ADB console for more details. -""" - presentAlertAsynchronously(alert) { response in - if response == .alertFirstButtonReturn { - AppState.shared.suppressAdbFailureAlerts = true - } - } - } - } - } - }) - } - - // Recursive function to try each port until one succeeds private static func attemptConnectionToNextPort(adbPath: String, ip: String, portsToTry: [UInt16], currentIndex: Int, reportedIP: String? = nil) { - // If we've tried all ports, fail if currentIndex >= portsToTry.count { - // If we haven't tried the reported IP yet and it's different from the current IP, try it if let reportedIP = reportedIP, reportedIP != ip { logBinaryDetection("Failed to connect on discovered IP \(ip), attempting fallback to reported IP \(reportedIP)...") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) { attemptConnectionToNextPort(adbPath: adbPath, ip: reportedIP, portsToTry: portsToTry, currentIndex: 0, reportedIP: nil) } return @@ -309,38 +247,17 @@ Please see the ADB console for more details. DispatchQueue.main.async { AppState.shared.adbConnected = false logBinaryDetection("(∩︵∩) ADB connection failed on all ports.") - AppState.shared.adbConnectionResult = (AppState.shared.adbConnectionResult ?? "") + """ - -Failed to connect to device on any available port. - -Tried ports: \(portsToTry.map(String.init).joined(separator: ", ")) - -Possible fixes: -- Ensure device is authorized for adb -- Disconnect and reconnect Wireless Debugging -- Run `adb disconnect` then retry -- It might be connected to another device. - Try killing any external adb instances in mac terminal with 'adb kill-server' command. -""" + AppState.shared.adbConnectionResult = (AppState.shared.adbConnectionResult ?? "") + "\nFailed to connect to device on any available port." AppState.shared.adbConnecting = false - // Show alert popup only when all attempts have been exhausted (unless suppressed) if !AppState.shared.suppressAdbFailureAlerts { let alert = NSAlert() alert.alertStyle = .warning alert.addButton(withTitle: "Don't warn me again") alert.addButton(withTitle: "OK") alert.messageText = "Failed to connect to ADB." - alert.informativeText = """ -Suggestions: -- Ensure your Android device is in Wireless debugging mode -- Try toggling Wireless Debugging off and on again -- Reconnect to the same Wi-Fi as your Mac -- Ensure device is authorized for adb -- Disconnect and reconnect Wireless Debugging - -Please see the ADB console for more details. -""" + alert.informativeText = "Suggestions:\n• Ensure your Android device is in Wireless debugging mode\n• Try toggling Wireless Debugging off and on again\n• Reconnect to the same Wi-Fi as your Mac" + presentAlertAsynchronously(alert) { response in if response == .alertFirstButtonReturn { AppState.shared.suppressAdbFailureAlerts = true @@ -354,10 +271,7 @@ Please see the ADB console for more details. let currentPort = portsToTry[currentIndex] let fullAddress = "\(ip):\(currentPort)" - let portNumber = currentIndex + 1 - let totalPorts = portsToTry.count - - logBinaryDetection("Attempting connection to port \(currentPort) (attempt \(portNumber)/\(totalPorts)): \(adbPath) connect \(fullAddress)") + logBinaryDetection("Attempting connection to port \(currentPort) (attempt \(currentIndex + 1)/\(portsToTry.count)): \(fullAddress)") runADBCommand(adbPath: adbPath, arguments: ["connect", fullAddress], completion: { output in let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -366,37 +280,16 @@ Please see the ADB console for more details. UserDefaults.standard.lastADBCommand = "adb connect \(fullAddress)" if trimmedOutput.contains("connected to") { - // Success! Connection established AppState.shared.adbConnected = true AppState.shared.adbPort = currentPort - AppState.shared.adbConnectedIP = ip // Store the actual connected IP + AppState.shared.adbConnectedIP = ip AppState.shared.adbConnectionResult = trimmedOutput logBinaryDetection("(/^▽^)/ ADB connection successful to \(fullAddress)") AppState.shared.adbConnecting = false clearConnectionFlag() - } - else if trimmedOutput.contains("protocol fault") || trimmedOutput.contains("connection reset by peer") { - // Connection exists elsewhere, show error and try next port - AppState.shared.adbConnected = false - logBinaryDetection("(T_T) Port \(currentPort): ADB connection failed due to existing connection.") - AppState.shared.adbConnectionResult = (AppState.shared.adbConnectionResult ?? "") + """ - -Port \(currentPort) (attempt \(portNumber)/\(totalPorts)): Connection failed - another ADB instance already using the device. -""" - // Try next port after a short delay instead of giving up - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - attemptConnectionToNextPort(adbPath: adbPath, ip: ip, portsToTry: portsToTry, currentIndex: currentIndex + 1, reportedIP: reportedIP) - } - } - else { - // This port didn't work, try the next one - logBinaryDetection("Port \(currentPort) (attempt \(portNumber)/\(totalPorts)): Connection failed, trying next port...") - AppState.shared.adbConnectionResult = (AppState.shared.adbConnectionResult ?? "") + """ - -Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOutput) -""" - // Try next port after a short delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + } else { + logBinaryDetection("Port \(currentPort) failed, trying next...") + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) { attemptConnectionToNextPort(adbPath: adbPath, ip: ip, portsToTry: portsToTry, currentIndex: currentIndex + 1, reportedIP: reportedIP) } } @@ -405,26 +298,35 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu } static func disconnectADB() { - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - AppState.shared.adbConnectionResult = "ADB not found — cannot disconnect." - AppState.shared.adbConnected = false - return - } + DispatchQueue.global(qos: .userInitiated).async { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { + DispatchQueue.main.async { + AppState.shared.adbConnected = false + } + return + } - let adbIP = AppState.shared.adbConnectedIP - let adbPort = AppState.shared.adbPort - let fullAddress = "\(adbIP):\(adbPort)" + var adbIP = "" + var adbPort: UInt16 = 0 + + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { + adbIP = AppState.shared.adbConnectedIP + adbPort = AppState.shared.adbPort + semaphore.signal() + } + semaphore.wait() - if !adbIP.isEmpty { - logBinaryDetection("Disconnecting from adb device: \(adbPath) disconnect \(fullAddress)") - runADBCommand(adbPath: adbPath, arguments: ["disconnect", fullAddress]) - UserDefaults.standard.lastADBCommand = "adb disconnect \(fullAddress)" - } else { - logBinaryDetection("No connected ADB device to disconnect, skipping.") + if !adbIP.isEmpty { + let fullAddress = "\(adbIP):\(adbPort)" + runADBCommand(adbPath: adbPath, arguments: ["disconnect", fullAddress]) + } + + DispatchQueue.main.async { + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + } } - - AppState.shared.adbConnected = false - AppState.shared.adbConnecting = false } private static func runADBCommand( @@ -481,19 +383,28 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu ) { guard let scrcpyPath = findExecutable(named: "scrcpy", fallbackPaths: possibleScrcpyPaths) else { DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "scrcpy not found. Please install via Homebrew: brew install scrcpy" - - presentScrcpyAlert( - title: "scrcpy Not Found", - informative: "AirSync couldn't find the scrcpy binary.\n\nFix suggestions:\n• Install via Homebrew: brew install scrcpy\n.\n\nAfter installing, try mirroring again. Might need the app to be restarted.") + AppState.shared.adbConnectionResult = "scrcpy not found." + presentScrcpyAlert(title: "scrcpy Not Found", informative: "AirSync couldn't find the scrcpy binary.") } return } let fullAddress = "\(ip):\(port)" let deviceNameFormatted = deviceName.removingApostrophesAndPossessives() - let bitrate = AppState.shared.scrcpyBitrate - let resolution = AppState.shared.scrcpyResolution + + var bitrate = 4 + var resolution = 1200 + var wiredAdbEnabled = false + + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { + bitrate = AppState.shared.scrcpyBitrate + resolution = AppState.shared.scrcpyResolution + wiredAdbEnabled = AppState.shared.wiredAdbEnabled + semaphore.signal() + } + semaphore.wait() + let desktopMode = UserDefaults.standard.scrcpyDesktopMode let alwaysOnTop = UserDefaults.standard.scrcpyOnTop let stayAwake = UserDefaults.standard.stayAwake @@ -513,131 +424,77 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu "--no-power-on" ] - // Prioritize wired ADB if enabled - if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { - args.append("--serial=\(serial)") - AppState.shared.adbConnectionMode = AppState.ADBConnectionMode.wired - logBinaryDetection("Wired ADB prioritized: using serial \(serial)") - } else { - args.append("--tcpip=\(fullAddress)") - AppState.shared.adbConnectionMode = AppState.ADBConnectionMode.wireless - } - - if manualPosition { - args.append("--window-x=\(manualPositionCoords[0])") - args.append("--window-y=\(manualPositionCoords[1])") - } - - if alwaysOnTop { - args.append("--always-on-top") - } - - if stayAwake { - args.append("--stay-awake") - } - - if turnScreenOff { - args.append("--turn-screen-off") - } - - if noAudio { - args.append("--no-audio") - } - - if directKeyInput { - args.append("--keyboard=uhid") - } - - if desktop ?? true { - let res = desktopMode ?? "1600x1000" - let dpi = UserDefaults.standard.string(forKey: "scrcpyDesktopDpi") ?? "" - if !dpi.isEmpty { - args.append("--new-display=\(res)/\(dpi)") + getWiredDeviceSerial { serial in + if wiredAdbEnabled, let serial = serial { + args.append("--serial=\(serial)") + DispatchQueue.main.async { AppState.shared.adbConnectionMode = .wired } + logBinaryDetection("Wired ADB prioritized: using serial \(serial)") } else { - args.append("--new-display=\(res)") + args.append("--tcpip=\(fullAddress)") + DispatchQueue.main.async { AppState.shared.adbConnectionMode = .wireless } } - } - if let pkg = package { - args.append(contentsOf: [ - "--new-display=\(appRes ?? "900x2100")", - "--start-app=\(pkg)", - "--no-vd-system-decorations" - ]) - - if continueApp { - args.append("--no-vd-destroy-content") - } - } - - - logBinaryDetection("Launching scrcpy: \(scrcpyPath) \(args.joined(separator: " "))") + DispatchQueue.global(qos: .userInitiated).async { + if manualPosition { + args.append("--window-x=\(manualPositionCoords[0])") + args.append("--window-y=\(manualPositionCoords[1])") + } + if alwaysOnTop { args.append("--always-on-top") } + if stayAwake { args.append("--stay-awake") } + if turnScreenOff { args.append("--turn-screen-off") } + if noAudio { args.append("--no-audio") } + if directKeyInput { args.append("--keyboard=uhid") } + + if desktop ?? true { + let res = desktopMode ?? "1600x1000" + let dpi = UserDefaults.standard.string(forKey: "scrcpyDesktopDpi") ?? "" + args.append("--new-display=\(res)" + (!dpi.isEmpty ? "/\(dpi)" : "")) + } - let task = Process() - task.executableURL = URL(fileURLWithPath: scrcpyPath) - task.arguments = args - - // Inject adb into scrcpy's environment - if let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) { - var env = ProcessInfo.processInfo.environment - let adbDir = URL(fileURLWithPath: adbPath).deletingLastPathComponent().path - env["PATH"] = "\(adbDir):" + (env["PATH"] ?? "") - env["ADB"] = adbPath - task.environment = env - } + if let pkg = package { + args.append(contentsOf: ["--new-display=\(appRes ?? "900x2100")", "--start-app=\(pkg)", "--no-vd-system-decorations"]) + if continueApp { args.append("--no-vd-destroy-content") } + } - UserDefaults.standard.lastADBCommand = "scrcpy \(args.joined(separator: " "))" + logBinaryDetection("Launching scrcpy: \(scrcpyPath) \(args.joined(separator: " "))") + let task = Process() + task.executableURL = URL(fileURLWithPath: scrcpyPath) + task.arguments = args + + if let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) { + var env = ProcessInfo.processInfo.environment + let adbDir = URL(fileURLWithPath: adbPath).deletingLastPathComponent().path + env["PATH"] = "\(adbDir):" + (env["PATH"] ?? "") + env["ADB"] = adbPath + task.environment = env + } + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - - task.terminationHandler = { process in - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "No output" - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "scrcpy exited:\n" + output - - let lowered = output.lowercased() - let errorKeywords = ["error", "failed", "not found", "refused", "denied", "cannot", "unable", "protocol", "disconnected", "permission"] - let hasErrorKeyword = errorKeywords.contains { lowered.contains($0) } - let nonZero = process.terminationStatus != 0 - - if nonZero || hasErrorKeyword { - var hint = "General troubleshooting:\n• Ensure only one mirroring/ADB tool is using the device\n• adb kill-server then retry\n• Re‑enable Wireless debugging\n• If using Desktop/App mode, ensure Android 15+ / vendor support\n• Try a lower resolution or bitrate.\n\nSee ADB Console in Settings for full output." - - if lowered.contains("protocol fault") || lowered.contains("connection reset") { - hint = "Another active ADB/scrcpy session is likely holding the device.\n• Close any existing scrcpy or Android Studio emulator sessions\n• Run: adb kill-server\n• Retry mirroring." - } else if lowered.contains("permission denied") { - hint = "Permission denied starting scrcpy.\n• Verify scrcpy binary has execute permission (chmod +x)\n• Reinstall via Homebrew (brew reinstall scrcpy)." - } else if lowered.contains("could not"), lowered.contains("configure") || lowered.contains("video") { - hint = "Video initialization failed.\n• Lower bitrate or resolution in Settings\n• Toggle Desktop/App mode off\n• Reconnect ADB then retry." + task.terminationHandler = { process in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "scrcpy exited:\n" + output + if process.terminationStatus != 0 { + presentScrcpyAlert(title: "Mirroring Ended With Errors", informative: "See ADB Console for details.") + } } - - presentScrcpyAlert( - title: "Mirroring Ended With Errors", - informative: hint - ) } - } - } - do { - try task.run() - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "(ノ´ヮ´)ノ Started scrcpy on \(fullAddress)" - } - } catch { - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "┐('~`;)┌ Failed to start scrcpy: \(error.localizedDescription)" - presentScrcpyAlert( - title: "Failed to Start Mirroring", - informative: "scrcpy couldn't launch.\nReason: \(error.localizedDescription)\n\nFix suggestions:\n• Ensure the device is still connected via ADB (reconnect if needed)\n• Close other scrcpy/ADB sessions\n• Reinstall scrcpy if the binary is corrupt\n• Lower bitrate/resolution then retry." - ) + do { + try task.run() + } catch { + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "Failed to start scrcpy: \(error.localizedDescription)" + } + } } } } + static func pull(remotePath: String, completion: ((Bool) -> Void)? = nil) { guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { completion?(false) @@ -648,56 +505,42 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.canCreateDirectories = true - panel.message = "Select destination for download" - panel.prompt = "Download" - panel.begin { response in if response == .OK, let destinationURL = panel.url { - let adbIP = AppState.shared.adbConnectedIP - let adbPort = AppState.shared.adbPort - let fullAddress = "\(adbIP):\(adbPort)" - + let fileName = (remotePath as NSString).lastPathComponent + let destiny = destinationURL.appendingPathComponent(fileName).path + + var wiredAdbEnabled = false + var fullAddress = "" + let semaphore = DispatchSemaphore(value: 0) DispatchQueue.main.async { + wiredAdbEnabled = AppState.shared.wiredAdbEnabled + fullAddress = "\(AppState.shared.adbConnectedIP):\(AppState.shared.adbPort)" AppState.shared.isADBTransferring = true AppState.shared.adbTransferringFilePath = remotePath + semaphore.signal() } - - DispatchQueue.global(qos: .userInitiated).async { - var args = ["pull", remotePath, destinationURL.path] - - // Prioritize wired ADB if enabled - if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { - args.insert(contentsOf: ["-s", serial], at: 0) - DispatchQueue.main.async { - AppState.shared.adbConnectionMode = .wired - } - logBinaryDetection("Wired ADB prioritized for pull: using serial \(serial)") - } else { - args.insert(contentsOf: ["-s", fullAddress], at: 0) - DispatchQueue.main.async { - AppState.shared.adbConnectionMode = .wireless + semaphore.wait() + + getWiredDeviceSerial { serial in + DispatchQueue.global(qos: .userInitiated).async { + var args = ["pull", remotePath, destiny] + if wiredAdbEnabled, let serial = serial { + args.insert(contentsOf: ["-s", serial], at: 0) + } else { + args.insert(contentsOf: ["-s", fullAddress], at: 0) } - } - - logBinaryDetection("Pulling: \(adbPath) \(args.joined(separator: " "))") - - runADBCommand(adbPath: adbPath, arguments: args, completion: { output in - logBinaryDetection("ADB Pull Output: \(output)") - let success = !output.lowercased().contains("error") && !output.lowercased().contains("failed") - DispatchQueue.main.async { - AppState.shared.isADBTransferring = false - AppState.shared.adbTransferringFilePath = nil - - if success { - // Open the destination folder in Finder - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: destinationURL.path) + runADBCommand(adbPath: adbPath, arguments: args) { output in + let success = !output.contains("error") && !output.contains("failed") + DispatchQueue.main.async { + AppState.shared.isADBTransferring = false + AppState.shared.adbTransferringFilePath = nil + if success { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: destiny) } + completion?(success) } - completion?(success) } - }) + } } } else { completion?(false) @@ -712,47 +555,38 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu return } - let adbIP = AppState.shared.adbConnectedIP - let adbPort = AppState.shared.adbPort - let fullAddress = "\(adbIP):\(adbPort)" - - DispatchQueue.main.async { - AppState.shared.isADBTransferring = true - - let fileName = URL(fileURLWithPath: localPath).lastPathComponent - let targetRemotePath = remotePath.hasSuffix("/") ? remotePath + fileName : remotePath + "/" + fileName - AppState.shared.adbTransferringFilePath = targetRemotePath - } - DispatchQueue.global(qos: .userInitiated).async { - var args = ["push", localPath, remotePath] - - // Prioritize wired ADB if enabled - if AppState.shared.wiredAdbEnabled, let serial = getWiredDeviceSerial() { - args.insert(contentsOf: ["-s", serial], at: 0) - DispatchQueue.main.async { - AppState.shared.adbConnectionMode = .wired - } - logBinaryDetection("Wired ADB prioritized for push: using serial \(serial)") - } else { - args.insert(contentsOf: ["-s", fullAddress], at: 0) - DispatchQueue.main.async { - AppState.shared.adbConnectionMode = .wireless - } + var wiredAdbEnabled = false + var fullAddress = "" + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { + wiredAdbEnabled = AppState.shared.wiredAdbEnabled + fullAddress = "\(AppState.shared.adbConnectedIP):\(AppState.shared.adbPort)" + AppState.shared.isADBTransferring = true + AppState.shared.adbTransferringFilePath = remotePath + semaphore.signal() } - - logBinaryDetection("Pushing: \(adbPath) \(args.joined(separator: " "))") - - runADBCommand(adbPath: adbPath, arguments: args, completion: { output in - logBinaryDetection("ADB Push Output: \(output)") - let success = !output.lowercased().contains("error") && !output.lowercased().contains("failed") - - DispatchQueue.main.async { - AppState.shared.isADBTransferring = false - AppState.shared.adbTransferringFilePath = nil - completion?(success) + semaphore.wait() + + getWiredDeviceSerial { serial in + DispatchQueue.global(qos: .userInitiated).async { + var args = ["push", localPath, remotePath] + if wiredAdbEnabled, let serial = serial { + args.insert(contentsOf: ["-s", serial], at: 0) + } else { + args.insert(contentsOf: ["-s", fullAddress], at: 0) + } + + runADBCommand(adbPath: adbPath, arguments: args) { output in + let success = !output.contains("error") && !output.contains("failed") + DispatchQueue.main.async { + AppState.shared.isADBTransferring = false + AppState.shared.adbTransferringFilePath = nil + completion?(success) + } + } } - }) + } } } } @@ -774,7 +608,6 @@ private extension ADBConnector { } static func presentScrcpyAlert(title: String, informative: String) { - // Present immediately on main thread (caller ensures main queue) let alert = NSAlert() alert.alertStyle = .warning alert.messageText = title diff --git a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift index 4cd9872a..9cda6e59 100644 --- a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift +++ b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift @@ -80,16 +80,18 @@ class MacInfoSyncManager: ObservableObject { timer = nil // Reset published properties when stopping - title = "Unknown Title" - artist = "Unknown Artist" - album = "Unknown Album" - elapsed = 0 - duration = 0 - isPlaying = false - artworkBase64 = "" - lastSentInfo = nil - lastSentSnapshot = nil - lastSentArtworkHash = nil + DispatchQueue.main.async { + self.title = "Unknown Title" + self.artist = "Unknown Artist" + self.album = "Unknown Album" + self.elapsed = 0 + self.duration = 0 + self.isPlaying = false + self.artworkBase64 = "" + self.lastSentInfo = nil + self.lastSentSnapshot = nil + self.lastSentArtworkHash = nil + } } private func fetch() { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 59ca28b5..7a18b38e 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -142,17 +142,27 @@ extension WebSocketServer { } if (!AppState.shared.adbConnected && (AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending || AppState.shared.wiredAdbEnabled) && AppState.shared.isPlus) { - // If wired ADB is enabled and a wired device is connected, mark as connected - if AppState.shared.wiredAdbEnabled, let serial = ADBConnector.getWiredDeviceSerial() { - AppState.shared.adbConnected = true - AppState.shared.adbConnectionMode = .wired - AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" - AppState.shared.manualAdbConnectionPending = false + if AppState.shared.wiredAdbEnabled { + ADBConnector.getWiredDeviceSerial(completion: { serial in + if let serial = serial { + DispatchQueue.main.async { + AppState.shared.adbConnected = true + AppState.shared.adbConnectionMode = .wired + AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" + AppState.shared.manualAdbConnectionPending = false + } + } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { + // Try wireless connection if wired failed or no device found + ADBConnector.connectToADB(ip: ip) + DispatchQueue.main.async { + AppState.shared.manualAdbConnectionPending = false + } + } + }) } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { - // Try wireless connection + // Try wireless connection directly ADBConnector.connectToADB(ip: ip) AppState.shared.manualAdbConnectionPending = false - // Note: adbConnectionMode will be set to .wireless in attemptDirectConnection on success } } diff --git a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift index fece7b7f..60dcb3a7 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -7,6 +7,7 @@ import SwiftUI import UserNotifications +import Foundation struct SettingsFeaturesView: View { @ObservedObject var appState = AppState.shared From c774c95108e9fbc5e0b49890c27bfd171fb8be79 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 20:33:35 +0530 Subject: [PATCH 11/13] feat: Recent apps --- airsync-mac/Core/AppState.swift | 54 +++ airsync-mac/Core/Util/CLI/ADBConnector.swift | 337 ++++++++---------- .../HomeScreen/AppsView/AppGridView.swift | 1 + .../PhoneView/RecentAppsGridView.swift | 74 ++++ .../HomeScreen/PhoneView/ScreenView.swift | 14 +- 5 files changed, 291 insertions(+), 189 deletions(-) create mode 100644 airsync-mac/Screens/HomeScreen/PhoneView/RecentAppsGridView.swift diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 3fcba1b6..248f47e2 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -125,6 +125,9 @@ class AppState: ObservableObject { QuickConnectManager.shared.saveLastConnectedDevice(newDevice) // Validate pinned apps when connecting to a device validatePinnedApps() + loadRecentApps() + } else { + recentApps = [] } // Automatically switch to the appropriate tab when device connection state changes @@ -178,6 +181,8 @@ class AppState: ObservableObject { @Published var isMenubarWindowOpen: Bool = false @Published var adbConnectionMode: ADBConnectionMode? = nil + @Published var recentApps: [AndroidApp] = [] + var isConnectedOverLocalNetwork: Bool { guard let ip = device?.ipAddress else { return true } // Tailscale IPs usually start with 100. @@ -1084,6 +1089,55 @@ class AppState: ObservableObject { } } + // MARK: - Recent Apps Tracking + + func trackAppUse(_ app: AndroidApp) { + DispatchQueue.main.async { + self.recentApps.removeAll { $0.packageName == app.packageName } + + self.recentApps.insert(app, at: 0) + + if self.recentApps.count > 9 { + self.recentApps = Array(self.recentApps.prefix(9)) + } + + self.saveRecentApps() + } + } + + private func saveRecentApps() { + guard let deviceName = device?.name else { return } + do { + let data = try JSONEncoder().encode(recentApps) + UserDefaults.standard.set(data, forKey: "recentApps_\(deviceName)") + } catch { + print("[state] (recent) Error saving recent apps: \(error)") + } + } + + private func loadRecentApps() { + guard let deviceName = device?.name else { + recentApps = [] + return + } + + guard let data = UserDefaults.standard.data(forKey: "recentApps_\(deviceName)") else { + recentApps = [] + return + } + + do { + recentApps = try JSONDecoder().decode([AndroidApp].self, from: data) + // Filter out apps that are no longer in the androidApps list (in case they were uninstalled) + recentApps.removeAll { app in + androidApps[app.packageName] == nil + } + } catch { + print("[state] (recent) Error loading recent apps: \(error)") + recentApps = [] + } + } + func updateDockIconVisibility() { DispatchQueue.main.async { if self.hideDockIcon { diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index dd59ae9e..5f95f996 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -134,72 +134,65 @@ struct ADBConnector { isConnecting = true connectionLock.unlock() - DispatchQueue.global(qos: .userInitiated).async { - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "ADB not found. Please install via Homebrew: brew install android-platform-tools" - AppState.shared.adbConnected = false - AppState.shared.adbConnecting = false - } - clearConnectionFlag() - return - } + DispatchQueue.main.async { + AppState.shared.adbConnecting = true + let devicePorts = AppState.shared.device?.adbPorts ?? [] + let fallbackToMdns = AppState.shared.fallbackToMdns - DispatchQueue.main.async { AppState.shared.adbConnecting = true } + DispatchQueue.global(qos: .userInitiated).async { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "ADB not found. Please install via Homebrew: brew install android-platform-tools" + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + } + clearConnectionFlag() + return + } - var devicePorts: [String] = [] - var fallbackToMdns = true - - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.main.async { - devicePorts = AppState.shared.device?.adbPorts ?? [] - fallbackToMdns = AppState.shared.fallbackToMdns - semaphore.signal() - } - semaphore.wait() - - if devicePorts.isEmpty { - if fallbackToMdns { - logBinaryDetection("Device reported no ADB ports, attempting mDNS discovery...") - discoverADBPorts(adbPath: adbPath, ip: ip) { ports in - if ports.isEmpty { - logBinaryDetection("mDNS discovery found no ports for \(ip).") - DispatchQueue.main.async { - AppState.shared.adbConnected = false - AppState.shared.adbConnecting = false - AppState.shared.adbConnectionResult = "No ADB ports reported by device and mDNS discovery failed." + if devicePorts.isEmpty { + if fallbackToMdns { + logBinaryDetection("Device reported no ADB ports, attempting mDNS discovery...") + discoverADBPorts(adbPath: adbPath, ip: ip) { ports in + if ports.isEmpty { + logBinaryDetection("mDNS discovery found no ports for \(ip).") + DispatchQueue.main.async { + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + AppState.shared.adbConnectionResult = "No ADB ports reported by device and mDNS discovery failed." + } + clearConnectionFlag() + } else { + logBinaryDetection("mDNS discovery found ports: \(ports.map(String.init).joined(separator: ", "))") + self.proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: ports) } - clearConnectionFlag() - } else { - logBinaryDetection("mDNS discovery found ports: \(ports.map(String.init).joined(separator: ", "))") - self.proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: ports) } + } else { + logBinaryDetection("Device reported no ADB ports and mDNS fallback is disabled.") + DispatchQueue.main.async { + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + } + clearConnectionFlag() } - } else { - logBinaryDetection("Device reported no ADB ports and mDNS fallback is disabled.") + return + } + + logBinaryDetection("Using ADB ports from device: \(devicePorts.joined(separator: ", "))") + let portsToTry = devicePorts.compactMap { UInt16($0) } + + guard !portsToTry.isEmpty else { DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "Device reported ADB ports but none could be parsed as valid port numbers." AppState.shared.adbConnected = false AppState.shared.adbConnecting = false } clearConnectionFlag() + return } - return - } - - logBinaryDetection("Using ADB ports from device: \(devicePorts.joined(separator: ", "))") - let portsToTry = devicePorts.compactMap { UInt16($0) } - - guard !portsToTry.isEmpty else { - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "Device reported ADB ports but none could be parsed as valid port numbers." - AppState.shared.adbConnected = false - AppState.shared.adbConnecting = false - } - clearConnectionFlag() - return + + proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: portsToTry) } - - proceedWithConnection(adbPath: adbPath, ip: ip, portsToTry: portsToTry) } } @@ -298,6 +291,9 @@ struct ADBConnector { } static func disconnectADB() { + let adbIP = AppState.shared.adbConnectedIP + let adbPort = AppState.shared.adbPort + DispatchQueue.global(qos: .userInitiated).async { guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { DispatchQueue.main.async { @@ -306,17 +302,6 @@ struct ADBConnector { return } - var adbIP = "" - var adbPort: UInt16 = 0 - - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.main.async { - adbIP = AppState.shared.adbConnectedIP - adbPort = AppState.shared.adbPort - semaphore.signal() - } - semaphore.wait() - if !adbIP.isEmpty { let fullAddress = "\(adbIP):\(adbPort)" runADBCommand(adbPath: adbPath, arguments: ["disconnect", fullAddress]) @@ -381,30 +366,9 @@ struct ADBConnector { desktop: Bool? = false, package: String? = nil ) { - guard let scrcpyPath = findExecutable(named: "scrcpy", fallbackPaths: possibleScrcpyPaths) else { - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "scrcpy not found." - presentScrcpyAlert(title: "scrcpy Not Found", informative: "AirSync couldn't find the scrcpy binary.") - } - return - } - - let fullAddress = "\(ip):\(port)" - let deviceNameFormatted = deviceName.removingApostrophesAndPossessives() - - var bitrate = 4 - var resolution = 1200 - var wiredAdbEnabled = false - - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.main.async { - bitrate = AppState.shared.scrcpyBitrate - resolution = AppState.shared.scrcpyResolution - wiredAdbEnabled = AppState.shared.wiredAdbEnabled - semaphore.signal() - } - semaphore.wait() - + let bitrate = AppState.shared.scrcpyBitrate + let resolution = AppState.shared.scrcpyResolution + let wiredAdbEnabled = AppState.shared.wiredAdbEnabled let desktopMode = UserDefaults.standard.scrcpyDesktopMode let alwaysOnTop = UserDefaults.standard.scrcpyOnTop let stayAwake = UserDefaults.standard.stayAwake @@ -416,79 +380,92 @@ struct ADBConnector { let continueApp = UserDefaults.standard.continueApp let directKeyInput = UserDefaults.standard.directKeyInput - var args = [ - "--window-title=\(deviceNameFormatted)", - "--video-bit-rate=\(bitrate)M", - "--video-codec=h265", - "--max-size=\(resolution)", - "--no-power-on" - ] - - getWiredDeviceSerial { serial in - if wiredAdbEnabled, let serial = serial { - args.append("--serial=\(serial)") - DispatchQueue.main.async { AppState.shared.adbConnectionMode = .wired } - logBinaryDetection("Wired ADB prioritized: using serial \(serial)") - } else { - args.append("--tcpip=\(fullAddress)") - DispatchQueue.main.async { AppState.shared.adbConnectionMode = .wireless } + DispatchQueue.global(qos: .userInitiated).async { + guard let scrcpyPath = findExecutable(named: "scrcpy", fallbackPaths: possibleScrcpyPaths) else { + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "scrcpy not found." + presentScrcpyAlert(title: "scrcpy Not Found", informative: "AirSync couldn't find the scrcpy binary.") + } + return } - DispatchQueue.global(qos: .userInitiated).async { - if manualPosition { - args.append("--window-x=\(manualPositionCoords[0])") - args.append("--window-y=\(manualPositionCoords[1])") - } - if alwaysOnTop { args.append("--always-on-top") } - if stayAwake { args.append("--stay-awake") } - if turnScreenOff { args.append("--turn-screen-off") } - if noAudio { args.append("--no-audio") } - if directKeyInput { args.append("--keyboard=uhid") } - - if desktop ?? true { - let res = desktopMode ?? "1600x1000" - let dpi = UserDefaults.standard.string(forKey: "scrcpyDesktopDpi") ?? "" - args.append("--new-display=\(res)" + (!dpi.isEmpty ? "/\(dpi)" : "")) - } + let fullAddress = "\(ip):\(port)" + let deviceNameFormatted = deviceName.removingApostrophesAndPossessives() + + var args = [ + "--window-title=\(deviceNameFormatted)", + "--video-bit-rate=\(bitrate)M", + "--video-codec=h265", + "--max-size=\(resolution)", + "--no-power-on" + ] - if let pkg = package { - args.append(contentsOf: ["--new-display=\(appRes ?? "900x2100")", "--start-app=\(pkg)", "--no-vd-system-decorations"]) - if continueApp { args.append("--no-vd-destroy-content") } - } + getWiredDeviceSerial { serial in + DispatchQueue.global(qos: .userInitiated).async { + if wiredAdbEnabled, let serial = serial { + args.append("--serial=\(serial)") + DispatchQueue.main.async { AppState.shared.adbConnectionMode = .wired } + logBinaryDetection("Wired ADB prioritized: using serial \(serial)") + } else { + args.append("--tcpip=\(fullAddress)") + DispatchQueue.main.async { AppState.shared.adbConnectionMode = .wireless } + } - logBinaryDetection("Launching scrcpy: \(scrcpyPath) \(args.joined(separator: " "))") - let task = Process() - task.executableURL = URL(fileURLWithPath: scrcpyPath) - task.arguments = args - - if let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) { - var env = ProcessInfo.processInfo.environment - let adbDir = URL(fileURLWithPath: adbPath).deletingLastPathComponent().path - env["PATH"] = "\(adbDir):" + (env["PATH"] ?? "") - env["ADB"] = adbPath - task.environment = env - } + if manualPosition { + args.append("--window-x=\(manualPositionCoords[0])") + args.append("--window-y=\(manualPositionCoords[1])") + } + if alwaysOnTop { args.append("--always-on-top") } + if stayAwake { args.append("--stay-awake") } + if turnScreenOff { args.append("--turn-screen-off") } + if noAudio { args.append("--no-audio") } + if directKeyInput { args.append("--keyboard=uhid") } + + if desktop ?? true { + let res = desktopMode ?? "1600x1000" + let dpi = UserDefaults.standard.string(forKey: "scrcpyDesktopDpi") ?? "" + args.append("--new-display=\(res)" + (!dpi.isEmpty ? "/\(dpi)" : "")) + } - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe + if let pkg = package { + args.append(contentsOf: ["--new-display=\(appRes ?? "900x2100")", "--start-app=\(pkg)", "--no-vd-system-decorations"]) + if continueApp { args.append("--no-vd-destroy-content") } + } - task.terminationHandler = { process in - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "scrcpy exited:\n" + output - if process.terminationStatus != 0 { - presentScrcpyAlert(title: "Mirroring Ended With Errors", informative: "See ADB Console for details.") + logBinaryDetection("Launching scrcpy: \(scrcpyPath) \(args.joined(separator: " "))") + let task = Process() + task.executableURL = URL(fileURLWithPath: scrcpyPath) + task.arguments = args + + if let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) { + var env = ProcessInfo.processInfo.environment + let adbDir = URL(fileURLWithPath: adbPath).deletingLastPathComponent().path + env["PATH"] = "\(adbDir):" + (env["PATH"] ?? "") + env["ADB"] = adbPath + task.environment = env + } + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + task.terminationHandler = { process in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "scrcpy exited:\n" + output + if process.terminationStatus != 0 { + presentScrcpyAlert(title: "Mirroring Ended With Errors", informative: "See ADB Console for details.") + } } } - } - do { - try task.run() - } catch { - DispatchQueue.main.async { - AppState.shared.adbConnectionResult = "Failed to start scrcpy: \(error.localizedDescription)" + do { + try task.run() + } catch { + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "Failed to start scrcpy: \(error.localizedDescription)" + } } } } @@ -496,11 +473,6 @@ struct ADBConnector { } static func pull(remotePath: String, completion: ((Bool) -> Void)? = nil) { - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - completion?(false) - return - } - DispatchQueue.main.async { let panel = NSOpenPanel() panel.canChooseFiles = false @@ -510,20 +482,19 @@ struct ADBConnector { let fileName = (remotePath as NSString).lastPathComponent let destiny = destinationURL.appendingPathComponent(fileName).path - var wiredAdbEnabled = false - var fullAddress = "" - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.main.async { - wiredAdbEnabled = AppState.shared.wiredAdbEnabled - fullAddress = "\(AppState.shared.adbConnectedIP):\(AppState.shared.adbPort)" - AppState.shared.isADBTransferring = true - AppState.shared.adbTransferringFilePath = remotePath - semaphore.signal() - } - semaphore.wait() + let wiredAdbEnabled = AppState.shared.wiredAdbEnabled + let fullAddress = "\(AppState.shared.adbConnectedIP):\(AppState.shared.adbPort)" + AppState.shared.isADBTransferring = true + AppState.shared.adbTransferringFilePath = remotePath getWiredDeviceSerial { serial in DispatchQueue.global(qos: .userInitiated).async { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { + DispatchQueue.main.async { AppState.shared.isADBTransferring = false } + completion?(false) + return + } + var args = ["pull", remotePath, destiny] if wiredAdbEnabled, let serial = serial { args.insert(contentsOf: ["-s", serial], at: 0) @@ -550,26 +521,20 @@ struct ADBConnector { } static func push(localPath: String, remotePath: String, completion: ((Bool) -> Void)? = nil) { - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - completion?(false) - return - } - - DispatchQueue.global(qos: .userInitiated).async { - var wiredAdbEnabled = false - var fullAddress = "" - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.main.async { - wiredAdbEnabled = AppState.shared.wiredAdbEnabled - fullAddress = "\(AppState.shared.adbConnectedIP):\(AppState.shared.adbPort)" - AppState.shared.isADBTransferring = true - AppState.shared.adbTransferringFilePath = remotePath - semaphore.signal() - } - semaphore.wait() + DispatchQueue.main.async { + let wiredAdbEnabled = AppState.shared.wiredAdbEnabled + let fullAddress = "\(AppState.shared.adbConnectedIP):\(AppState.shared.adbPort)" + AppState.shared.isADBTransferring = true + AppState.shared.adbTransferringFilePath = remotePath getWiredDeviceSerial { serial in DispatchQueue.global(qos: .userInitiated).async { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { + DispatchQueue.main.async { AppState.shared.isADBTransferring = false } + completion?(false) + return + } + var args = ["push", localPath, remotePath] if wiredAdbEnabled, let serial = serial { args.insert(contentsOf: ["-s", serial], at: 0) diff --git a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift index 94bbed36..9f26bd1d 100644 --- a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift +++ b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift @@ -72,6 +72,7 @@ private struct AppGridItemView: View { private func handleTap() { if let device = appState.device, appState.adbConnected { + appState.trackAppUse(app) ADBConnector.startScrcpy( ip: device.ipAddress, port: appState.adbPort, diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/RecentAppsGridView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/RecentAppsGridView.swift new file mode 100644 index 00000000..c0b00825 --- /dev/null +++ b/airsync-mac/Screens/HomeScreen/PhoneView/RecentAppsGridView.swift @@ -0,0 +1,74 @@ +// +// RecentAppsGridView.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-03-11. +// + +import SwiftUI + +struct RecentAppsGridView: View { + @ObservedObject var appState = AppState.shared + + var body: some View { + HStack(spacing: 2) { + + Spacer() + + ForEach(0.. Date: Wed, 11 Mar 2026 20:37:15 +0530 Subject: [PATCH 12/13] feat: Recent apps in menubar --- airsync-mac/Screens/MenubarView/MenubarView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/airsync-mac/Screens/MenubarView/MenubarView.swift b/airsync-mac/Screens/MenubarView/MenubarView.swift index f8b3e9d4..520f130d 100644 --- a/airsync-mac/Screens/MenubarView/MenubarView.swift +++ b/airsync-mac/Screens/MenubarView/MenubarView.swift @@ -186,6 +186,11 @@ struct MenubarView: View { } .padding(8) + if appState.adbConnected && !appState.recentApps.isEmpty { + RecentAppsGridView() + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } + if (appState.status != nil){ DeviceStatusView(showMediaToggle: false) .transition(.opacity.combined(with: .scale)) From bacf80956bb87379fb234564088e36ccf9b86c97 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 11 Mar 2026 20:40:04 +0530 Subject: [PATCH 13/13] version: Updated to v2.6.1 --- AirSync.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index 69d353f6..9271129b 100644 --- a/AirSync.xcodeproj/project.pbxproj +++ b/AirSync.xcodeproj/project.pbxproj @@ -256,7 +256,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -274,7 +274,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 2.6.0; + MARKETING_VERSION = 2.6.1; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -432,7 +432,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -450,7 +450,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 2.6.0; + MARKETING_VERSION = 2.6.1; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -480,7 +480,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 22; + CURRENT_PROJECT_VERSION = 23; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -498,7 +498,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 2.6.0; + MARKETING_VERSION = 2.6.1; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES;