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.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; diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 792def7b..248f47e2 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 @@ -29,6 +34,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") @@ -119,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 @@ -159,11 +168,26 @@ 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 + + @Published var recentApps: [AndroidApp] = [] + + 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? @@ -234,6 +258,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 { @@ -1060,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 20cb5bd0..5f95f996 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -78,78 +78,122 @@ struct ADBConnector { print("[adb-connector] (Binary Detection) \(message)") } + 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 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 { + 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)") + } + completion(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.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 + } - // 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).") + 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) + } + } + } else { + logBinaryDetection("Device reported no ADB ports and mDNS fallback is disabled.") DispatchQueue.main.async { AppState.shared.adbConnected = false AppState.shared.adbConnecting = false - AppState.shared.adbConnectionResult = "No ADB ports reported by device and mDNS discovery failed." } - 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) } + return } - } else { - logBinaryDetection("Device reported no ADB ports and mDNS fallback is disabled.") - AppState.shared.adbConnected = false - DispatchQueue.main.async { AppState.shared.adbConnecting = false } - clearConnectionFlag() + + 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) } - 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) } private static func discoverADBPorts(adbPath: String, ip: String, completion: @escaping ([UInt16]) -> Void) { @@ -158,12 +202,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) { @@ -181,89 +223,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.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. -""" - let response = alert.runModal() - if response == .alertFirstButtonReturn { - DispatchQueue.main.async { - 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 @@ -272,41 +240,19 @@ 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. -""" - let response = alert.runModal() - if response == .alertFirstButtonReturn { - DispatchQueue.main.async { + 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 } } @@ -318,10 +264,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() @@ -330,37 +273,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) } } @@ -369,26 +291,27 @@ 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 - } - let adbIP = AppState.shared.adbConnectedIP let adbPort = AppState.shared.adbPort - let fullAddress = "\(adbIP):\(adbPort)" - - 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.") - } - AppState.shared.adbConnected = false - AppState.shared.adbConnecting = false + DispatchQueue.global(qos: .userInitiated).async { + guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { + DispatchQueue.main.async { + AppState.shared.adbConnected = false + } + return + } + + if !adbIP.isEmpty { + let fullAddress = "\(adbIP):\(adbPort)" + runADBCommand(adbPath: adbPath, arguments: ["disconnect", fullAddress]) + } + + DispatchQueue.main.async { + AppState.shared.adbConnected = false + AppState.shared.adbConnecting = false + } + } } private static func runADBCommand( @@ -443,21 +366,9 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu desktop: Bool? = false, package: String? = nil ) { - 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.") - } - return - } - - let fullAddress = "\(ip):\(port)" - let deviceNameFormatted = deviceName.removingApostrophesAndPossessives() 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 @@ -469,175 +380,138 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu let continueApp = UserDefaults.standard.continueApp let directKeyInput = UserDefaults.standard.directKeyInput - var args = [ - "--window-title=\(deviceNameFormatted)", - "--tcpip=\(fullAddress)", - "--video-bit-rate=\(bitrate)M", - "--video-codec=h265", - "--max-size=\(resolution)", - "--no-power-on" - ] - - 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)") - } else { - args.append("--new-display=\(res)") - } - } - - 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") + 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 } - } + 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" + ] + + 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 - - // 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 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)" : "")) + } - UserDefaults.standard.lastADBCommand = "scrcpy \(args.joined(separator: " "))" + 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: " "))") + 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() + } catch { + DispatchQueue.main.async { + AppState.shared.adbConnectionResult = "Failed to start scrcpy: \(error.localizedDescription)" + } + } } } } - - 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." - ) - } - } } - static func pull(remotePath: String, completion: ((Bool) -> Void)? = nil) { - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - completion?(false) - return - } + static func pull(remotePath: String, completion: ((Bool) -> Void)? = nil) { DispatchQueue.main.async { 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)" - - DispatchQueue.main.async { - AppState.shared.isADBTransferring = true - AppState.shared.adbTransferringFilePath = remotePath - } - - DispatchQueue.global(qos: .userInitiated).async { - let args = ["-s", fullAddress, "pull", remotePath, destinationURL.path] - 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") + let fileName = (remotePath as NSString).lastPathComponent + let destiny = destinationURL.appendingPathComponent(fileName).path + + 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) + } else { + args.insert(contentsOf: ["-s", fullAddress], at: 0) + } - 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) @@ -647,50 +521,63 @@ Attempt \(portNumber)/\(totalPorts) on port \(currentPort): Failed - \(trimmedOu } static func push(localPath: String, remotePath: String, completion: ((Bool) -> Void)? = nil) { - guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - completion?(false) - return - } - - let adbIP = AppState.shared.adbConnectedIP - let adbPort = AppState.shared.adbPort - let fullAddress = "\(adbIP):\(adbPort)" - DispatchQueue.main.async { + let wiredAdbEnabled = AppState.shared.wiredAdbEnabled + let fullAddress = "\(AppState.shared.adbConnectedIP):\(AppState.shared.adbPort)" AppState.shared.isADBTransferring = true - - let fileName = URL(fileURLWithPath: localPath).lastPathComponent - let targetRemotePath = remotePath.hasSuffix("/") ? remotePath + fileName : remotePath + "/" + fileName - AppState.shared.adbTransferringFilePath = targetRemotePath - } + 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 + } - DispatchQueue.global(qos: .userInitiated).async { - let args = ["-s", fullAddress, "push", localPath, remotePath] - 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) + 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) + } + } } - }) + } } } } // 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() alert.alertStyle = .warning 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() + } } } 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 3b75d2fb..7a18b38e 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -141,9 +141,29 @@ 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 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 directly + 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/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/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 new file mode 100644 index 00000000..d8610772 --- /dev/null +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -0,0 +1,195 @@ +// +// ConnectionStatusPill.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-03-11. +// + +import SwiftUI + +struct ConnectionStatusPill: View { + @ObservedObject var appState = AppState.shared + @State private var showingPopover = false + @State private var isHovered = false + + var body: some View { + 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.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 + )) + } + } + } + .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() + } + } + + 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" + } + } +} + +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 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.isPlus && 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.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) + } + } + } + + GlassButtonView( + label: "Disconnect Device", + systemImage: "iphone.slash", + iconOnly: false, + primary: true, + action: { + appState.disconnectDevice() + if appState.isPlus { + ADBConnector.disconnectADB() + } + appState.adbConnected = false + } + ) + .focusable(false) + } + } else { + Text("No device connected") + .foregroundColor(.secondary) + } + } + .padding() + .onAppear { + currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" + } + } +} + + +#Preview { + ConnectionStatusPill() + .padding() + .background(Color.black.opacity(0.1)) +} 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..