diff --git a/Control.xcodeproj/project.pbxproj b/Control.xcodeproj/project.pbxproj index 403a991..c784066 100644 --- a/Control.xcodeproj/project.pbxproj +++ b/Control.xcodeproj/project.pbxproj @@ -328,7 +328,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10; + MARKETING_VERSION = 1.11; PRODUCT_BUNDLE_IDENTIFIER = ryanwhitney.MacControl; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -365,7 +365,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.10; + MARKETING_VERSION = 1.11; PRODUCT_BUNDLE_IDENTIFIER = ryanwhitney.MacControl; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Control/AppIcon.icon/Assets/pause.svg b/Control/AppIcon.icon/Assets/pause.svg new file mode 100644 index 0000000..ef5a1e7 --- /dev/null +++ b/Control/AppIcon.icon/Assets/pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/Control/AppIcon.icon/Assets/play.svg b/Control/AppIcon.icon/Assets/play.svg new file mode 100644 index 0000000..97c1a34 --- /dev/null +++ b/Control/AppIcon.icon/Assets/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/Control/AppIcon.icon/icon.json b/Control/AppIcon.icon/icon.json new file mode 100644 index 0000000..dd2ffbb --- /dev/null +++ b/Control/AppIcon.icon/icon.json @@ -0,0 +1,92 @@ +{ + "color-space-for-untagged-svg-colors" : "display-p3", + "fill" : { + "linear-gradient" : [ + "display-p3:0.13592,0.14828,0.15995,1.00000", + "display-p3:0.03829,0.04706,0.07131,1.00000" + ] + }, + "groups" : [ + { + "layers" : [ + { + "blend-mode" : "normal", + "fill" : { + "automatic-gradient" : "extended-srgb:0.15686,0.80392,0.25490,1.00000" + }, + "glass" : true, + "hidden" : false, + "image-name" : "play.svg", + "name" : "play", + "opacity" : 0.7, + "position" : { + "scale" : 1.25, + "translation-in-points" : [ + -190.5546875, + 2.578125 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + }, + { + "layers" : [ + { + "blend-mode" : "normal", + "fill" : { + "solid" : "extended-srgb:0.15686,0.80392,0.25490,1.00000" + }, + "glass" : true, + "hidden" : false, + "image-name" : "play.svg", + "name" : "play", + "opacity" : 1, + "position" : { + "scale" : 1.25, + "translation-in-points" : [ + -190.5546875, + 2.578125 + ] + } + }, + { + "fill" : { + "solid" : "display-p3:0.23220,0.25000,0.28526,1.00000" + }, + "image-name" : "pause.svg", + "name" : "pause", + "opacity" : 1, + "position" : { + "scale" : 1.2, + "translation-in-points" : [ + 212.7578125, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Control/Components/PlatformControlPanel.swift b/Control/Components/PlatformControlPanel.swift index 89d4821..beff524 100644 --- a/Control/Components/PlatformControlPanel.swift +++ b/Control/Components/PlatformControlPanel.swift @@ -92,6 +92,8 @@ struct PlatformControl: View { } .onAppear { Task { + // Wait until the initial batch update has completed + guard controller.hasCompletedInitialUpdate else { return } await controller.updateState(for: platform) } } diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 42d7b57..6ead098 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -6,18 +6,26 @@ class AppController: ObservableObject { private var platformRegistry: PlatformRegistry private var isUpdating = false @Published var isActive = true - static var debugMode = true // Add debug flag for troubleshooting + + // Track initial comprehensive update completion + @Published var hasCompletedInitialUpdate = false @Published var states: [String: AppState] = [:] @Published var lastKnownStates: [String: AppState] = [:] @Published var currentVolume: Float? + // Track last per-platform state refresh to avoid redundant work/log noise + private var lastStateRefresh: [String: Date] = [:] + + // Track last action per platform to prevent rapid-fire commands + private var lastActionTime: [String: Date] = [:] + var platforms: [any AppPlatform] { platformRegistry.activePlatforms } init(sshClient: SSHClientProtocol, platformRegistry: PlatformRegistry) { - appControllerLog("AppController: Initializing") + appControllerLog("Initializing with \(platformRegistry.activePlatforms.count) active platforms") self.sshClient = sshClient self.platformRegistry = platformRegistry @@ -30,27 +38,25 @@ class AppController: ObservableObject { } func reset() { - appControllerLog("AppController: Resetting state") + appControllerLog("Resetting state") isActive = true isUpdating = false - // Don't reset states - they'll update naturally when we get new data + hasCompletedInitialUpdate = false } func cleanup() { - appControllerLog("AppController: Cleaning up") + appControllerLog("Cleaning up") isActive = false } func updateClient(_ client: SSHClientProtocol) { - appControllerLog("AppController: Updating SSH Client") + appControllerLog("Updating SSH Client") self.sshClient = client isActive = true // Ensure we're active for upcoming state updates } func updatePlatformRegistry(_ newRegistry: PlatformRegistry) { - appControllerLog("AppController: Updating platform registry") - appControllerLog("Previous platform count: \(platformRegistry.platforms.count)") - appControllerLog("New platform count: \(newRegistry.platforms.count)") + appControllerLog("Updating platform registry to \(newRegistry.activePlatforms.map { $0.name })") self.platformRegistry = newRegistry @@ -67,79 +73,79 @@ class AppController: ObservableObject { // Ensure controller is active for the new platforms isActive = true - appControllerLog("✓ AppController reactivated for new platform registry") - - appControllerLog("✓ Platform registry updated with platforms: \(platformRegistry.platforms.map { $0.name })") } func updateAllStates() async { - appControllerLog("AppController: Starting comprehensive state update") - appControllerLog("Controller active: \(isActive)") - appControllerLog("Number of platforms: \(platforms.count)") + appControllerLog("❇︎ Starting update for \(platforms.count) platforms") guard isActive else { appControllerLog("⚠️ Controller not active, skipping state update") return } - // Update system volume first - await updateSystemVolume() + // If this is the initial update, give channels a moment to fully initialize + if !hasCompletedInitialUpdate { + // A shorter 0.5-second pause is typically enough for the dedicated + // AppleScript channels to finish their interactive shell handshake. + // Reducing this delay brings the system-volume fetch forward and + // makes the UI feel snappier without sacrificing reliability. + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + } - // Then check which apps are running - for platform in platforms { - guard isActive else { - appControllerLog("⚠️ Controller became inactive during updates, stopping") - break + // Update system volume first (sequential – very fast) + await updateSystemVolume() + // Slow-start strategy: the very first comprehensive refresh runs + if hasCompletedInitialUpdate { + // Parallel path – after the initial warm-up everything is fast again. + await withTaskGroup(of: Void.self) { group in + for platform in platforms { + group.addTask { [weak self] in + guard let self else { return } + await self.updateState(for: platform) + } + } } - - appControllerLog("Checking platform: \(platform.name)") - let isRunning = await checkIfRunning(platform) - if isRunning { - appControllerLog("✓ \(platform.name) is running, fetching state") + } else { + // Initial sweep: do one platform at a time. + for platform in platforms { await updateState(for: platform) - } else { - appControllerLog("\(platform.name) is not running") - let newState = AppState( - title: "Not running", - subtitle: "", - isPlaying: nil, - error: nil - ) - updateStateIfChanged(platform.id, newState) } } + hasCompletedInitialUpdate = true appControllerLog("✓ State update complete") } func updateState(for platform: any AppPlatform) async { guard isActive else { return } - let isRunning = await checkIfRunning(platform) - guard isRunning else { - let newState = AppState( - title: "Not running", - subtitle: "", - isPlaying: nil, - error: nil - ) - // Only update if we don't have a previous state or if the state has changed - let currentState = states[platform.id] - let shouldUpdate = currentState == nil || - currentState?.title != newState.title || - currentState?.isPlaying != newState.isPlaying - - if shouldUpdate { - states[platform.id] = newState - lastKnownStates[platform.id] = newState - } + // Prevent duplicate refreshes within 2 s + if let last = lastStateRefresh[platform.id], Date().timeIntervalSince(last) < 2 { + appControllerLog("⏭️ \(platform.name): skipping refresh (< 2s since last)") return } + lastStateRefresh[platform.id] = Date() - let result = await executeCommand(platform.fetchState(), description: "\(platform.name): fetch status") + appControllerLog("⚐ \(platform.name): checking status") + + let result = await executeCommand(platform.combinedStatusScript(), channelKey: platform.id, description: "\(platform.id): combined status") switch result { case .success(let output): + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + + // Detect sentinel for not-running state + if trimmed == "NOT_RUNNING" { + let newState = AppState( + title: "Not running", + subtitle: "", + isPlaying: nil, + error: nil + ) + updateStateIfChanged(platform.id, newState) + return + } + if output.contains("Not authorized to send Apple events") { let newState = AppState( title: "Permissions Required", @@ -147,58 +153,35 @@ class AppController: ObservableObject { isPlaying: nil, error: nil ) - // Only update if we don't have a previous state or if the state has changed - let currentState = states[platform.id] - let shouldUpdate = currentState == nil || - currentState?.title != newState.title || - currentState?.isPlaying != newState.isPlaying - - if shouldUpdate { - states[platform.id] = newState - lastKnownStates[platform.id] = newState - } + updateStateIfChanged(platform.id, newState) } else { let newState = platform.parseState(output) - // Only update if we don't have a previous state or if the state has changed - let currentState = states[platform.id] - let shouldUpdate = currentState == nil || - currentState?.title != newState.title || - currentState?.isPlaying != newState.isPlaying - - if shouldUpdate { - states[platform.id] = newState - lastKnownStates[platform.id] = newState - } + let playString = newState.isPlaying.map { $0 ? "playing" : "paused" } ?? "n/a" + let subtitlePart = newState.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitleSegment = subtitlePart.isEmpty ? "" : " · \(subtitlePart.redacted())" + appControllerLog("⚑ \(platform.name) state: \(newState.title.redacted())\(subtitleSegment) · \(playString)") + updateStateIfChanged(platform.id, newState) } case .failure(let error): - // For errors, we might want to keep the previous state and just add an error - var currentState = states[platform.id] ?? AppState(title: "", subtitle: "error") - currentState.error = error.localizedDescription - states[platform.id] = currentState - lastKnownStates[platform.id] = currentState - } - } - - private func checkIfRunning(_ platform: any AppPlatform) async -> Bool { - appControllerLog("AppController: Checking if \(platform.name) is running") - guard isActive else { - appControllerLog("⚠️ Controller not active, returning false") - return false - } - - let result = await executeCommand( - platform.isRunningScript(), - description: "\(platform.name): check if running" - ) - - switch result { - case .success(let output): - let isRunning = output.trimmingCharacters(in: .whitespacesAndNewlines) == "true" - appControllerLog(isRunning ? "✓ \(platform.name) is running" : "⚠️ \(platform.name) is not running") - return isRunning - case .failure(let error): - appControllerLog("❌ Failed to check if \(platform.name) is running: \(error)") - return false + appControllerLog("❌ \(platform.name) status fetch failed: \(error)") + + // For AppleScript errors, show a more user-friendly message + if error.localizedDescription.contains("AppleScript error") { + let newState = AppState( + title: "Script Error", + subtitle: "Unable to get status", + isPlaying: nil, + error: error.localizedDescription + ) + states[platform.id] = newState + lastKnownStates[platform.id] = newState + } else { + // For other errors, we might want to keep the previous state and just add an error + var currentState = states[platform.id] ?? AppState(title: "", subtitle: "error") + currentState.error = error.localizedDescription + states[platform.id] = currentState + lastKnownStates[platform.id] = currentState + } } } @@ -208,28 +191,26 @@ class AppController: ObservableObject { return } - appControllerLog("AppController: Executing action \(action) on \(platform.name)") + // Rate limit TV actions to prevent channel overload + if platform.id == "tv" { + if let lastAction = lastActionTime[platform.id], + Date().timeIntervalSince(lastAction) < 0.3 { + appControllerLog("⏭️ \(platform.name): rate limiting action (< 0.3s since last)") + return + } + lastActionTime[platform.id] = Date() + } - // Combine action and status fetch into single script - let actionScript = platform.executeAction(action) - let statusScript = platform.fetchState() + appControllerLog("⚡︎ \(platform.name): \(action.label)") - let combinedScript = """ - try - \(actionScript) - delay 0.1 - \(statusScript) - on error errMsg - delay 0.1 - \(statusScript) - end try - """ + // Leverage the shared helper on the platform to combine the action and + // status script into a single AppleScript round-trip. + let combinedScript = platform.actionWithStatus(action) - let result = await executeCommand(combinedScript, description: "\(platform.name): executeAction(.\(action))") + let result = await executeCommand(combinedScript, channelKey: platform.id, description: "\(platform.id): \(action)") switch result { case .success(let output): - appControllerLog("✓ Action executed successfully") let lines = output.components(separatedBy: .newlines) if let firstLine = lines.first, firstLine.contains("Not authorized to send Apple events") { @@ -243,7 +224,10 @@ class AppController: ObservableObject { } else if let lastLine = lines.last?.trimmingCharacters(in: .whitespacesAndNewlines), !lastLine.isEmpty { let newState = platform.parseState(lastLine) - appControllerLog("State updated for \(platform.name)") + let playString = newState.isPlaying.map { $0 ? "playing" : "paused" } ?? "n/a" + let subtitlePart = newState.subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitleSegment = subtitlePart.isEmpty ? "" : " · \(subtitlePart.redacted())" + appControllerLog("❖ \(platform.name) after action: \(newState.title.redacted())\(subtitleSegment) · \(playString)") states[platform.id] = newState } case .failure(let error): @@ -253,27 +237,23 @@ class AppController: ObservableObject { sshClient.isConnectionLossError(error) { appControllerLog("🚨 Connection lost during action execution - marking controller inactive") self.isActive = false - sshClient.handleConnectionLost() + sshClient.handleConnectionLost(because: error) } } } func setVolume(_ volume: Float) async { - appControllerLog("AppController: Setting system volume to \(Int(volume * 100))%") - guard isActive else { - appControllerLog("⚠️ Controller not active, skipping volume change") - return - } + guard isActive else { return } - let script = "set volume output volume \(Int(volume * 100))" - let result = await executeCommand(script, description: "System: set volume(\(Int(volume * 100)))") + let target = Int(volume * 100) + let script = "set volume output volume \(target)" + appControllerLog("🔊 Set volume request · \(target)%") + let result = await executeCommand(script, channelKey: "system", description: "system: set volume to \(target)%") switch result { - case .success(let output): - appControllerLog("✓ Volume set successfully") - if !output.isEmpty { - appControllerLog("Volume command output: \(output)") - } + case .success(_): + // Success is implied, no need to log + break case .failure(let error): appControllerLog("❌ Failed to set volume: \(error)") // Check if this is a connection loss @@ -281,38 +261,33 @@ class AppController: ObservableObject { sshClient.isConnectionLossError(error) { appControllerLog("🚨 Connection lost during volume change - marking controller inactive") self.isActive = false - sshClient.handleConnectionLost() + sshClient.handleConnectionLost(because: error) } } } private func updateSystemVolume() async { - appControllerLog("AppController: Updating system volume") guard isActive else { - appControllerLog("⚠️ Controller not active, skipping volume update") return } - let script = """ - output volume of (get volume settings) - """ + appControllerLog("⚐ System: checking volume") - let result = await executeCommand(script, description: "System: get volume") + let script = "output volume of (get volume settings)" + + let result = await executeCommand(script, channelKey: "system", description: "system: get volume") switch result { case .success(let output): if let volume = Float(output.trimmingCharacters(in: .whitespacesAndNewlines)) { currentVolume = volume / 100.0 - appControllerLog("✓ Current volume: \(Int(volume))%") - appControllerLog("Volume controls should now be enabled") + appControllerLog("⚑ System volume · \(Int(volume))%") } else { appControllerLog("⚠️ Could not parse volume from output: '\(output)'") - appControllerLog("🚨 Volume controls will remain disabled due to parse failure") currentVolume = nil } case .failure(let error): appControllerLog("❌ Failed to get current volume: \(error)") - appControllerLog("🚨 Volume controls will remain disabled due to command failure") currentVolume = nil // Check if this is a connection loss @@ -320,25 +295,18 @@ class AppController: ObservableObject { sshClient.isConnectionLossError(error) { appControllerLog("🚨 Connection lost during volume update - marking controller inactive") self.isActive = false - sshClient.handleConnectionLost() + sshClient.handleConnectionLost(because: error) } } } - // Keep this simpler version for single commands (permissions checks, etc) - private func executeCommand(_ command: String, description: String? = nil) async -> Result { - if let description = description { - appControllerLog("Executing command: \(description)") - } else { - appControllerLog("Executing command") - } - + private func executeCommand(_ command: String, channelKey: String, description: String? = nil) async -> Result { guard isActive else { appControllerLog("⚠️ Controller not active, skipping command") return .failure(SSHError.channelError("Controller not active")) } - let wrappedCommand = ShellCommandUtilities.wrapAppleScriptForBash(command) + let wrappedCommand = ShellCommandUtilities.appleScriptForStreaming(command) return await withCheckedContinuation { [weak self] continuation in guard let self = self else { @@ -346,28 +314,20 @@ class AppController: ObservableObject { return } - // Always use new channel for reliability - revert the session reuse optimization - self.sshClient.executeCommandWithNewChannel(wrappedCommand, description: description) { result in - switch result { - case .success(let output): - appControllerLog("✓ Command executed successfully") - if !output.isEmpty { - appControllerLog("Command output: \(output)") - } - continuation.resume(returning: result) - case .failure(let error): - appControllerLog("❌ Command failed: \(error)") + self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in + if case .failure(let error) = result { + let commandDesc = description ?? "command" + appControllerLog("❌ SSH: \(commandDesc) failed - \(error)") // Check if this is a connection loss if let connectionManager = self.sshClient as? SSHConnectionManager, connectionManager.isConnectionLossError(error) { - appControllerLog("🚨 Connection appears to be lost - marking controller inactive") - self.isActive = false // Prevent further commands - connectionManager.handleConnectionLost() + appControllerLog("🚨 Connection lost - marking controller inactive") + self.isActive = false + connectionManager.handleConnectionLost(because: error) } - - continuation.resume(returning: result) } + continuation.resume(returning: result) } } } diff --git a/Control/Platforms/Implementations/Chrome.swift b/Control/Platforms/Implementations/Chrome.swift index 50d4b91..994d8dd 100644 --- a/Control/Platforms/Implementations/Chrome.swift +++ b/Control/Platforms/Implementations/Chrome.swift @@ -20,37 +20,47 @@ struct ChromeApp: AppPlatform { // Checks if Chrome is running func isRunningScript() -> String { """ - tell application "System Events" to set isAppOpen to exists (processes where name is "Google Chrome") - return isAppOpen as text + tell application "System Events" + if exists (processes where name is "Google Chrome") then + return "true" + else + return "false" + end if + end tell """ } - // Retrieves the current media status - private let statusScript = """ - tell application "Google Chrome" - set windowCount to number of windows - if windowCount is 0 then - return "No windows open|||No media playing|||false" - end if - - repeat with w in windows - set tabCount to number of tabs in w - repeat with t in tabs of w - set theURL to URL of t - if theURL starts with "https://www.youtube.com/watch" then - set videoTitle to title of t - set isPlaying to execute t javascript "document.querySelector('video').paused ? 'false' : 'true'" - return videoTitle & "|||YouTube|||" & isPlaying - end if + // Template status script that can optionally inject action AppleScript + private func statusScript(actionLines: String = "") -> String { + """ + tell application "Google Chrome" + \(actionLines) + set windowCount to number of windows + if windowCount is 0 then + return "No windows open|||No media playing|||false" + end if + + repeat with w in windows + set tabCount to number of tabs in w + repeat with t in tabs of w + set theURL to URL of t + if theURL starts with \"https://www.youtube.com/watch\" then + set videoTitle to title of t + set isPlaying to execute t javascript \"document.querySelector('video').paused ? 'false' : 'true'\" + return videoTitle & \"|||YouTube|||\" & isPlaying + end if + end repeat end repeat - end repeat - - return "No media playing|||No media found|||false" - end tell - """ + + return \"No media playing|||No media found|||false\" + end tell + """ + } - func fetchState() -> String { - return statusScript + func fetchState() -> String { statusScript() } + + func actionWithStatus(_ action: AppAction) -> String { + statusScript(actionLines: executeAction(action)) } // Parses the output into a friendly AppState @@ -77,53 +87,47 @@ struct ChromeApp: AppPlatform { switch action { case .playPauseToggle: return """ - tell application "Google Chrome" - set windowCount to number of windows - if windowCount is 0 then return - - repeat with w in windows - set tabCount to number of tabs in w - repeat with t in tabs of w - set theURL to URL of t - if theURL starts with "https://www.youtube.com/watch" then - execute t javascript "document.querySelector('video').click()" - return - end if - end repeat + set windowCount to number of windows + if windowCount is 0 then return + + repeat with w in windows + set tabCount to number of tabs in w + repeat with t in tabs of w + set theURL to URL of t + if theURL starts with \"https://www.youtube.com/watch\" then + execute t javascript \"document.querySelector('video').click()\" + return + end if end repeat - end tell + end repeat """ case .skipForward(let seconds): return """ - tell application "Google Chrome" - if (count of windows) > 0 then - tell active tab of front window - execute javascript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime += \(seconds); - })(); - " - end tell - end if - end tell + if (count of windows) > 0 then + tell active tab of front window + execute javascript " + (function() { + const media = document.querySelector('video, audio'); + if (media) media.currentTime += \(seconds); + })(); + " + end tell + end if """ case .skipBackward(let seconds): - return """ - tell application "Google Chrome" - if (count of windows) > 0 then - tell active tab of front window - execute javascript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime -= \(seconds); - })(); - " - end tell - end if - end tell + return """ + if (count of windows) > 0 then + tell active tab of front window + execute javascript " + (function() { + const media = document.querySelector('video, audio'); + if (media) media.currentTime -= \(seconds); + })(); + " + end tell + end if """ default: diff --git a/Control/Platforms/Implementations/IINAApp.swift b/Control/Platforms/Implementations/IINAApp.swift index 9dbc742..93e4f5a 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -18,51 +18,27 @@ struct IINAApp: AppPlatform { } func isRunningScript() -> String { + // Match Spotify's pattern for System Events """ - tell application "System Events" to set isAppOpen to exists (processes where name is "IINA") - return isAppOpen as text + tell application "System Events" + if exists (processes where name is "IINA") then + return "true" + else + return "false" + end if + end tell """ } - private let statusScript = """ + // Everything must be done through System Events since IINA has no AppleScript support + private func statusScript(actionLines: String = "") -> String { + """ tell application "System Events" - set isRunning to exists (processes where name is "IINA") - if not isRunning then - return "Not running ||| ||| stopped |||false" - end if - - -- Try to get window title via System Events only - set windowTitle to "" - try - tell process "IINA" - if (count of windows) > 0 then - set windowTitle to name of front window - end if - end tell - end try - - if windowTitle is "" then - return "Nothing playing ||| ||| false ||| false" + \(actionLines) + if not (exists (processes where name is "IINA")) then + return "Not running||| |||false" end if - - -- Check if window title indicates non-media windows - set nonMediaWindows to {"Window", "Preferences", "Log Viewer", "Choose Media Files", "Playback History"} - repeat with nonMediaWindow in nonMediaWindows - if windowTitle is nonMediaWindow then - return "Nothing playing ||| ||| false ||| false" - end if - end repeat - - -- Try to parse title, fall back to full title if parsing fails - set cleanTitle to windowTitle - try - set AppleScript's text item delimiters to " — /" - set cleanTitle to first text item of windowTitle - set AppleScript's text item delimiters to "" - end try - - -- Now that we know media is loaded, check play/pause state - set isPlaying to false + set isPlaying to false try tell application "IINA" to activate tell process "IINA" @@ -70,20 +46,38 @@ struct IINAApp: AppPlatform { set isPlaying to (name of playPauseMenu contains "Pause") end tell end try - - return cleanTitle & "||| ||| " & isPlaying & " ||| " & isPlaying + tell process "IINA" + if (count of windows) > 0 then + set windowTitle to name of front window + return windowTitle & "||| |||" & isPlaying + else + return "No window||| |||" & isPlaying + end if + end tell end tell """ - - func fetchState() -> String { - return statusScript + } + + func fetchState() -> String { statusScript() } + + func actionWithStatus(_ action: AppAction) -> String { + // executeAction already brings the app frontmost and inserts a small + // delay *only when needed*. We therefore no longer add an unconditional + // delay here. + return statusScript(actionLines: executeAction(action)) } func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") if components.count >= 3 { + var title = components[0].trimmingCharacters(in: .whitespacesAndNewlines) + // IINA shows "filename — /full/path" (two spaces + em dash). + if let range = title.range(of: " — ") { + title = String(title[.. String { + // Only bring IINA to front (with small delay) if it's not already frontmost. + let keyLine: String switch action { case .playPauseToggle: - return """ - tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 49 -- spacebar - end tell - end tell - """ + keyLine = "keystroke space" case .skipBackward: - return """ - tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 123 -- left arrow - end tell - end tell - """ + keyLine = "key code 123" case .skipForward: - return """ - tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 124 -- right arrow - end tell - end tell - """ + keyLine = "key code 124" case .previousTrack: - return """ - tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 123 using {command down} -- cmd+left - end tell - end tell - """ + keyLine = "key code 123 using {command down}" case .nextTrack: - return """ - tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 124 using {command down} -- cmd+right - end tell + keyLine = "key code 124 using {command down}" + } + + // AppleScript template: conditionally frontmost + optional delay, then keystroke + return """ + tell application \"System Events\" + if not (frontmost of process \"IINA\") then + set frontmost of process \"IINA\" to true + delay 0.1 + end if + tell process \"IINA\" + \(keyLine) end tell - """ - } + end tell + """ } } + diff --git a/Control/Platforms/Implementations/MusicApp.swift b/Control/Platforms/Implementations/MusicApp.swift index 92386fc..346ccc8 100644 --- a/Control/Platforms/Implementations/MusicApp.swift +++ b/Control/Platforms/Implementations/MusicApp.swift @@ -16,36 +16,41 @@ struct MusicApp: AppPlatform { } func isRunningScript() -> String { + "tell application \"System Events\" to exists (processes where name is \"Music\")" + } + + // Template status script that can optionally inject action AppleScript + private func statusScript(actionLines: String = "") -> String { """ - tell application "System Events" to set isAppOpen to exists (processes where name is "Music") - return isAppOpen as text + tell application "Music" + \(actionLines) + if player state is stopped then + return "Nothing playing ||| ||| false" + end if + set trackName to name of current track + set artistName to artist of current track + set playerState to player state as text + set isPlaying to player state is playing + return trackName & "|||" & artistName & "|||" & isPlaying + end tell """ } - - private let statusScript = """ - tell application "Music" - if player state is stopped then - return "Nothing playing ||| ||| stopped ||| false" - end if - set trackName to name of current track - set artistName to artist of current track - set playerState to player state as text - set isPlaying to player state is playing - return trackName & "|||" & artistName & "|||" & playerState & "|||" & isPlaying - end tell - """ - - func fetchState() -> String { - return statusScript + + func fetchState() -> String { statusScript() } + + // Override the default helper to make use of the shared template so the + // action and status execute inside the same `tell application` block. + func actionWithStatus(_ action: AppAction) -> String { + statusScript(actionLines: executeAction(action)) } func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") - if components.count >= 4 { + if components.count >= 3 { return AppState( title: components[0].trimmingCharacters(in: .whitespacesAndNewlines), subtitle: components[1].trimmingCharacters(in: .whitespacesAndNewlines), - isPlaying: components[3].trimmingCharacters(in: .whitespacesAndNewlines) == "true", + isPlaying: components[2].trimmingCharacters(in: .whitespacesAndNewlines) == "true", error: nil ) } @@ -60,23 +65,11 @@ struct MusicApp: AppPlatform { func executeAction(_ action: AppAction) -> String { switch action { case .playPauseToggle: - return """ - tell application "Music" - playpause - end tell - """ + return "playpause" case .previousTrack: - return """ - tell application "Music" - previous track - end tell - """ + return "previous track" case .nextTrack: - return """ - tell application "Music" - next track - end tell - """ + return "next track" default: return "" } diff --git a/Control/Platforms/Implementations/QuickTimeApp.swift b/Control/Platforms/Implementations/QuickTimeApp.swift index 87ccd84..1c2e1bb 100644 --- a/Control/Platforms/Implementations/QuickTimeApp.swift +++ b/Control/Platforms/Implementations/QuickTimeApp.swift @@ -17,30 +17,36 @@ struct QuickTimeApp: AppPlatform { func isRunningScript() -> String { """ - tell application "System Events" to set isAppOpen to exists (processes where name is "QuickTime Player") - return isAppOpen as text + tell application "System Events" + if exists (processes where name is "QuickTime Player") then + return "true" + else + return "false" + end if + end tell """ } - private let statusScript = """ - tell application "QuickTime Player" - if not (exists document 1) then - return "Nothing playing ||| |||false" - end if - set docName to name of document 1 - if playing of document 1 then - set playState to "playing" - else - set playState to "paused" - end if - return docName & "||| |||" & (playing of document 1 as text) - end tell - """ - - func fetchState() -> String { - return statusScript + private func statusScript(actionLines: String = "") -> String { + """ + tell application "QuickTime Player" + \(actionLines) + if not (exists document 1) then + return "Nothing playing ||| |||false" + end if + set docName to name of document 1 + if playing of document 1 then + set playState to "playing" + else + set playState to "paused" + end if + return docName & "||| |||" & (playing of document 1 as text) + end tell + """ } + func fetchState() -> String { statusScript() } + func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") if components.count >= 3 { @@ -63,43 +69,41 @@ struct QuickTimeApp: AppPlatform { switch action { case .skipBackward: return """ - tell application "QuickTime Player" - if not (exists document 1) then return - set theDocument to document 1 - set currentTime to current time of theDocument - set newTime to currentTime - 5 - if newTime < 0 then set newTime to 0 - set current time of theDocument to newTime - end tell + if not (exists document 1) then return + set theDocument to document 1 + set currentTime to current time of theDocument + set newTime to currentTime - 5 + if newTime < 0 then set newTime to 0 + set current time of theDocument to newTime """ case .skipForward: return """ - tell application "QuickTime Player" - if not (exists document 1) then return - set theDocument to document 1 - set currentTime to current time of theDocument - set videoDuration to duration of theDocument - set newTime to currentTime + 5 - if newTime > videoDuration then set newTime to videoDuration - 0.01 - set current time of theDocument to newTime - end tell + if not (exists document 1) then return + set theDocument to document 1 + set currentTime to current time of theDocument + set videoDuration to duration of theDocument + set newTime to currentTime + 5 + if newTime > videoDuration then set newTime to videoDuration - 0.01 + set current time of theDocument to newTime """ case .playPauseToggle: return """ - tell application "QuickTime Player" - if exists document 1 then - tell document 1 - if playing then - pause - else - play - end if - end tell - end if - end tell + if exists document 1 then + tell document 1 + if playing then + pause + else + play + end if + end tell + end if """ default: return "" } } + + func actionWithStatus(_ action: AppAction) -> String { + statusScript(actionLines: executeAction(action)) + } } diff --git a/Control/Platforms/Implementations/SafariApp.swift b/Control/Platforms/Implementations/SafariApp.swift index 208d3bb..d49b7d1 100644 --- a/Control/Platforms/Implementations/SafariApp.swift +++ b/Control/Platforms/Implementations/SafariApp.swift @@ -18,139 +18,87 @@ struct SafariApp: AppPlatform { } func isRunningScript() -> String { - """ - tell application "System Events" to set isAppOpen to exists (processes where name is "Safari") - return isAppOpen as text + // A simple, direct check. If this fails, the issue is fundamental. + "tell application \"System Events\" to return (exists (processes where name is \"Safari\"))" + } + + private func jsForStatus() -> String { + return "(function() { const v = document.querySelector('video'); if (!v) return 'No video found||| |||false'; const title = document.title.replace(' - YouTube', '') || 'Unknown Video'; const site = window.location.hostname.replace('www.', ''); const playing = !v.paused && !v.ended; return title + '|||' + site + '|||' + playing; })();" + } + + private func jsForAction(_ action: AppAction) -> String { + let innerJs: String + switch action { + case .playPauseToggle: + innerJs = "const v = document.querySelector('video'); if (v) { v.paused ? v.play() : v.pause(); }" + case .skipForward(let seconds): + innerJs = "const v = document.querySelector('video'); if (v) v.currentTime += \(seconds);" + case .skipBackward(let seconds): + innerJs = "const v = document.querySelector('video'); if (v) v.currentTime -= \(seconds);" + default: + return "" + } + // Wrap in an IIFE for robust execution, which was missing before. + return "(function() { \(innerJs) })();" + } + + func fetchState() -> String { + let js = jsForStatus() + // Re-add window check for robustness. + return """ + tell application "Safari" + if (count of windows) is 0 then + return "No windows open||| |||false" + end if + return do JavaScript "\(js)" in current tab of front window + end tell """ } - private let statusScript = """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then - return "Nothing playing ||| ||| false ||| false" - end if + func actionWithStatus(_ action: AppAction) -> String { + let actionJs = jsForAction(action) + let statusJs = jsForStatus() - -- Check the current tab in the frontmost window only - try - set currentTab to current tab of window 1 - set hasVideo to do JavaScript "document.querySelector('video') !== null" in currentTab - if hasVideo then - set videoScript to " - var video = document.querySelector('video'); - var title = document.title || 'Unknown Video'; - var siteName = window.location.hostname.replace('www.', ''); - var isPlaying = !video.paused && !video.ended; - title + ' ||| ' + siteName + ' ||| ' + (isPlaying ? 'true' : 'false') + ' ||| ' + (isPlaying ? 'true' : 'false'); - " - set videoInfo to do JavaScript videoScript in currentTab - return videoInfo + // Build a single, direct script with the window check. + return """ + tell application "Safari" + if (count of windows) is 0 then + return "No windows open||| |||false" end if - end try - - return "Nothing playing ||| ||| false ||| false" - end tell - """ - - func fetchState() -> String { - return statusScript + do JavaScript "\(actionJs)" in current tab of front window + delay 0.15 + return do JavaScript "\(statusJs)" in current tab of front window + end tell + """ } func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") if components.count >= 3 { - let title = components[0].trimmingCharacters(in: .whitespacesAndNewlines) - let subtitle = components[1].trimmingCharacters(in: .whitespacesAndNewlines) - let isPlayingStr = components[2].trimmingCharacters(in: .whitespacesAndNewlines) - let isPlaying = isPlayingStr == "true" - return AppState( - title: title, - subtitle: subtitle, - isPlaying: isPlaying, + title: components[0].trimmingCharacters(in: .whitespacesAndNewlines), + subtitle: components[1].trimmingCharacters(in: .whitespacesAndNewlines), + isPlaying: components[2].trimmingCharacters(in: .whitespacesAndNewlines) == "true", error: nil ) } + + // Handle cases where the script might return fewer components + if !output.isEmpty && !output.contains("|||") { + return AppState(title: output, subtitle: "", isPlaying: nil) + } + return AppState( - title: "", - subtitle: "", + title: "Error", + subtitle: "Could not parse Safari state", isPlaying: nil, - error: "Failed to parse Safari state" + error: output ) } + // This function is not used when actionWithStatus is implemented, but is required by the protocol. func executeAction(_ action: AppAction) -> String { - switch action { - case .playPauseToggle: - return """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then return - - -- Check the current tab in the frontmost window only - try - set currentTab to current tab of window 1 - set hasVideo to do JavaScript "document.querySelector('video') !== null" in currentTab - if hasVideo then - do JavaScript " - var video = document.querySelector('video'); - if (video) { - if (video.paused || video.ended) { - video.play(); - } else { - video.pause(); - } - } - " in currentTab - end if - end try - end tell - """ - case .skipForward(let seconds): - return """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then return - - -- Check the current tab in the frontmost window only - try - set currentTab to current tab of window 1 - set hasVideo to do JavaScript "document.querySelector('video, audio') !== null" in currentTab - if hasVideo then - do JavaScript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime += \(seconds); - })(); - " in currentTab - end if - end try - end tell - """ - case .skipBackward(let seconds): - return """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then return - - -- Check the current tab in the frontmost window only - try - set currentTab to current tab of window 1 - set hasVideo to do JavaScript "document.querySelector('video, audio') !== null" in currentTab - if hasVideo then - do JavaScript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime -= \(seconds); - })(); - " in currentTab - end if - end try - end tell - """ - default: - return "" - } + return "" } } diff --git a/Control/Platforms/Implementations/SpotifyApp.swift b/Control/Platforms/Implementations/SpotifyApp.swift index cc1a85b..4e678a9 100644 --- a/Control/Platforms/Implementations/SpotifyApp.swift +++ b/Control/Platforms/Implementations/SpotifyApp.swift @@ -17,38 +17,45 @@ struct SpotifyApp: AppPlatform { func isRunningScript() -> String { """ - tell application "System Events" to set isAppOpen to exists (processes where name is "Spotify") - return isAppOpen as text + tell application "System Events" + if exists (processes where name is "Spotify") then + return "true" + else + return "false" + end if + end tell """ } - private let statusScript = """ - tell application "Spotify" - if not running then - return "Not running ||| |||stopped|||false" - end if - try - set trackName to name of current track - set artistName to artist of current track - set playerState to player state as text - set isPlaying to player state is playing - return trackName & "|||" & artistName & "|||" & playerState & "|||" & isPlaying - end try - return "Nothing playing ||| |||" & false & "|||" & false - end tell - """ - - func fetchState() -> String { - return statusScript + // Template status script that can optionally inject action AppleScript + private func statusScript(actionLines: String = "") -> String { + """ + tell application "Spotify" + \(actionLines) + if not running then + return "Not running ||| |||false" + end if + try + set trackName to name of current track + set artistName to artist of current track + set playerState to player state as text + set isPlaying to player state is playing + return trackName & "|||" & artistName & "|||" & isPlaying + end try + return "Nothing playing ||| |||" & false + end tell + """ } + func fetchState() -> String { statusScript() } + func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") - if components.count >= 4 { + if components.count >= 3 { return AppState( title: components[0].trimmingCharacters(in: .whitespacesAndNewlines), subtitle: components[1].trimmingCharacters(in: .whitespacesAndNewlines), - isPlaying: components[3].trimmingCharacters(in: .whitespacesAndNewlines) == "true", + isPlaying: components[2].trimmingCharacters(in: .whitespacesAndNewlines) == "true", error: nil ) } @@ -63,25 +70,20 @@ struct SpotifyApp: AppPlatform { func executeAction(_ action: AppAction) -> String { switch action { case .playPauseToggle: - return """ - tell application "Spotify" - playpause - end tell - """ + return "playpause" case .previousTrack: - return """ - tell application "Spotify" - previous track - end tell - """ + return "previous track" case .nextTrack: - return """ - tell application "Spotify" - next track - end tell - """ + return "next track" default: return "" } } + + func actionWithStatus(_ action: AppAction) -> String { + // Add delay for all actions to let Spotify update its state + let delayScript = "delay 0.3\n" + + return statusScript(actionLines: executeAction(action) + "\n" + delayScript) + } } diff --git a/Control/Platforms/Implementations/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index 923e2d8..37e1671 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -16,67 +16,64 @@ struct TVApp: AppPlatform { } func isRunningScript() -> String { - """ - tell application "System Events" to set isAppOpen to exists (processes where name is "TV") - return isAppOpen as text - """ + "tell application \"System Events\" to exists (processes where name is \"TV\")" } - private let statusScript = """ - tell application "TV" - -- Grab the raw player state: can be "playing", "paused", or "stopped". - set rawState to player state as text - - -- Try to get the current track, which might fail if truly no track is loaded. - set currentTrack to missing value - try - set currentTrack to name of current track - set currentProperties to properties of current track - end try - try - set frontWindow to name of front window - end try - - if currentTrack is not missing value then - set trackName to currentTrack - - if media kind of currentProperties is TV show then - set showName to show of current track - else - set showName to "" + private func statusScript(actionLines: String = "") -> String { + """ + tell application "TV" + \(actionLines) + set rawState to player state as text + if rawState is "stopped" then + return "Nothing playing||| |||false" end if - - if rawState is "playing" then - -- Standard playing scenario - return trackName & "|||" & showName & "|||" & "playing" & "|||" & "true" - else if rawState is "paused" or rawState is "stopped" then - -- If there's a valid track but the state is "stopped" or "paused," treat it as paused - return trackName & "|||" & showName & "|||" & "paused" & "|||" & "false" + + set trackName to "" + try + set trackName to name of current track + end try + + -- If no track name, try window for streaming content + if trackName is "" then + try + set windowName to name of front window + if windowName is not "TV" then + set trackName to windowName + end if + end try end if - else if frontWindow is not "TV" then - if rawState is "playing" then - return frontWindow & "||| |||" & "playing" & "|||" & "true" - else - return frontWindow & "||| |||" & "paused" & "|||" & "false" + + if trackName is "" then + return "Nothing playing||| |||false" end if - else - -- If we can't retrieve a track, there's truly no video playing. - return "Nothing playing ||| ||| stopped ||| false" - end if - end tell - """ + + set isPlaying to (rawState is "playing") + return trackName & "||| |||" & isPlaying + end tell + """ + } - func fetchState() -> String { - return statusScript + func fetchState() -> String { statusScript() } + + func actionWithStatus(_ action: AppAction) -> String { + switch action { + case .skipBackward, .skipForward: + // For complex actions, chain the self-contained script with the status script. + // The final return value will be from fetchState(). + return executeAction(action) + "\n" + fetchState() + default: + // For simple actions, inject them into the status script as before. + return statusScript(actionLines: executeAction(action)) + } } func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") - if components.count >= 4 { + if components.count >= 3 { return AppState( title: components[0].trimmingCharacters(in: .whitespacesAndNewlines), subtitle: components[1].trimmingCharacters(in: .whitespacesAndNewlines), - isPlaying: components[3].trimmingCharacters(in: .whitespacesAndNewlines) == "true", + isPlaying: components[2].trimmingCharacters(in: .whitespacesAndNewlines) == "true", error: nil ) } @@ -91,54 +88,25 @@ struct TVApp: AppPlatform { func executeAction(_ action: AppAction) -> String { switch action { case .playPauseToggle: - return """ - tell application "TV" - playpause - end tell - """ + return "playpause" case .skipBackward: return """ - tell application "TV" - activate - try - set currentPosition to player position - if currentPosition is not missing value then - set player position to currentPosition - 10 - else - -- Fallback for streaming content tell application "System Events" - -- Target the TV app directly - tell process "TV" - key code 123 -- Right Arrow - end tell + if frontmost of application "TV" is false then + tell application "TV" to activate + delay 0.1 + end if + tell process "TV" to key code 123 end tell - end if - on error errMsg - return "Error: " & errMsg - end try - end tell - """ case .skipForward: return """ - tell application "TV" - activate - try - set currentPosition to player position - if currentPosition is not missing value then - set player position to currentPosition + 10 - else - -- Fallback for streaming content tell application "System Events" - -- Target the TV app directly - tell process "TV" - key code 124 -- Right Arrow - end tell - end tell - end if - on error errMsg - return "Error: " & errMsg - end try + if frontmost of application "TV" is false then + tell application "TV" to activate + delay 0.1 + end if + tell process "TV" to key code 124 end tell """ default: diff --git a/Control/Platforms/Implementations/VLCApp.swift b/Control/Platforms/Implementations/VLCApp.swift index c41a18d..292b8f3 100644 --- a/Control/Platforms/Implementations/VLCApp.swift +++ b/Control/Platforms/Implementations/VLCApp.swift @@ -18,60 +18,57 @@ struct VLCApp: AppPlatform { } func isRunningScript() -> String { - """ - tell application "System Events" to set isAppOpen to exists (processes where name is "VLC") - return isAppOpen as text - """ + "tell application \"System Events\" to exists (processes where name is \"VLC\")" } - private let statusScript = """ - tell application "VLC" - try - -- Check if VLC is currently running + private func statusScript(actionLines: String = "") -> String { + """ + tell application "VLC" + \(actionLines) if not running then return "Not running ||| ||| stopped |||false" end if - - -- Check playback status - if playing then - -- Attempt to get the name of the current media item - try - set mediaName to name of current item - on error - set mediaName to "Unknown media" - end try - return mediaName & "||| ||| true ||| true" - else - try - set mediaName to name of current item - return mediaName & "||| ||| false ||| false " - on error - return "Nothing playing ||| ||| false ||| false" - end try - end if - - on error errMsg - -- Handle errors gracefully - if errMsg contains "Not authorized to send Apple events" then - error errMsg - else - return "Error: " & errMsg & "||| false |||false" - end if - end try - end tell - """ + try + set mediaName to name of current item + if playing then + return mediaName & "||| ||| playing ||| true" + else + return mediaName & "||| ||| paused ||| false" + end if + on error + if playing then + return "Unknown media ||| ||| playing ||| true" + else + return "Nothing playing ||| ||| paused ||| false" + end if + end try + end tell + """ + } + + func fetchState() -> String { statusScript() } - func fetchState() -> String { - return statusScript + func actionWithStatus(_ action: AppAction) -> String { + let delayScript: String + switch action { + case .previousTrack, .nextTrack: + // Track changes need more time to load new media + delayScript = "delay 0.5" + default: + // Other actions need less time + delayScript = "delay 0.2" + } + + return statusScript(actionLines: executeAction(action) + "\n" + delayScript) } func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") - if components.count >= 3 { + if components.count >= 4 { return AppState( title: components[0].trimmingCharacters(in: .whitespacesAndNewlines), subtitle: components[1].trimmingCharacters(in: .whitespacesAndNewlines), - isPlaying: components[2].trimmingCharacters(in: .whitespacesAndNewlines) == "true", + isPlaying: components[3].trimmingCharacters(in: .whitespacesAndNewlines) == "true", error: nil ) } @@ -86,35 +83,15 @@ struct VLCApp: AppPlatform { func executeAction(_ action: AppAction) -> String { switch action { case .playPauseToggle: - return """ - tell application "VLC" - play - end tell - """ + return "play" case .skipBackward: - return """ - tell application "VLC" - step backward - end tell - """ + return "step backward" case .skipForward: - return """ - tell application "VLC" - step forward - end tell - """ + return "step forward" case .previousTrack: - return """ - tell application "VLC" - previous - end tell - """ + return "previous" case .nextTrack: - return """ - tell application "VLC" - next - end tell - """ + return "next" } } } diff --git a/Control/Platforms/Types.swift b/Control/Platforms/Types.swift index 7afb004..cc75c76 100644 --- a/Control/Platforms/Types.swift +++ b/Control/Platforms/Types.swift @@ -85,6 +85,7 @@ protocol AppPlatform: Identifiable { func isRunningScript() -> String func isInstalledScript() -> String func activateScript() -> String + func actionWithStatus(_ action: AppAction) -> String } extension AppPlatform { @@ -109,4 +110,23 @@ extension AppPlatform { tell application "\(name)" to activate """ } + + /// Combined status script: checks if the application process exists and, if so, + /// executes the platformʼs `fetchState()` AppleScript. If not running we + /// simply return the sentinel string "NOT_RUNNING". Wrapping everything in + /// a single `tell application \"System Events\"` block keeps the entire + /// script within one top-level tell as required by the remote interactive + /// shell. + func combinedStatusScript() -> String { + return """ + tell application \"System Events\" + if (count of (processes where name is \"\(name)\")) > 0 then + -- App is running; delegate to the platform-specific status fetch + \(fetchState()) + else + return \"NOT_RUNNING\" + end if + end tell + """ + } } diff --git a/Control/SSH/ChannelExecutor+ShellSetup.swift b/Control/SSH/ChannelExecutor+ShellSetup.swift new file mode 100644 index 0000000..54d793b --- /dev/null +++ b/Control/SSH/ChannelExecutor+ShellSetup.swift @@ -0,0 +1,38 @@ +import NIOSSH +import NIOCore + +// MARK: - ChannelExecutor shell helpers & error handler + +/// Interactive AppleScript shell initialisation. +/// Allocates a PTY and launches `/usr/bin/osascript -i` so subsequent payloads can be streamed. +func setupInteractiveShell(channel: Channel, command: String) -> EventLoopFuture { + // Allocate a PTY for proper terminal behaviour + let ptyRequest = SSHChannelRequestEvent.PseudoTerminalRequest( + wantReply: true, + term: "xterm-256color", + terminalCharacterWidth: 80, + terminalRowHeight: 24, + terminalPixelWidth: 0, + terminalPixelHeight: 0, + terminalModes: SSHTerminalModes([:]) + ) + + return channel.triggerUserOutboundEvent(ptyRequest) + .flatMap { _ -> EventLoopFuture in + // Start an interactive shell session + let shellRequest = SSHChannelRequestEvent.ShellRequest(wantReply: true) + return channel.triggerUserOutboundEvent(shellRequest) + } + .flatMap { _ -> EventLoopFuture in + // Inject the AppleScript interpreter command + let initialPayload = "\(command)\n" + let buffer = channel.allocator.buffer(string: initialPayload) + let writePromise = channel.eventLoop.makePromise(of: Void.self) + channel.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: writePromise) + return writePromise.futureResult + } + .flatMapError { error in + sshLog("☄︎ setupInteractiveShell: ❌ Setup failed: \(error)") + return channel.eventLoop.makeFailedFuture(error) + } +} diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift new file mode 100644 index 0000000..76fa7c6 --- /dev/null +++ b/Control/SSH/ChannelExecutor.swift @@ -0,0 +1,227 @@ +import Foundation +import NIOSSH +import NIOCore + +/// Actor that serialises AppleScript commands on a dedicated SSH *child-channel*. +/// For each physical key (`system`, `heartbeat`, `app-0`, …) we open an interactive +/// session – a PTY running `/usr/bin/osascript -s s -l AppleScript -i` and keep it +/// alive for the lifetime of the executor. Every command is streamed into that +/// interpreter, demarcated with a unique sentinel so responses can be matched back +/// to their promises. If a fatal timeout or channel error occurs the shell is +/// closed and the executor will be recreated on next use. +@available(iOS 15.0, *) +actor ChannelExecutor { + private static let idQueue = DispatchQueue(label: "com.volumecontrol.executorIdQueue") + private static var nextExecutorID = 1 + + private let executorId: Int + private unowned let connection: Channel + private var shellChannel: Channel? + private let shellHandler: StreamingShellHandler + private let channelKey: String + + // MARK: - Single-flight queue support + private struct WorkItem { + let payload: String + let sentinel: String + let description: String? + let continuation: CheckedContinuation, Never> + } + + private var workQueue: [WorkItem] = [] + private var isBusy = false + private var commandCounter: UInt32 = 0 + + // Warm-up flag – we send a no-op AppleScript (`return 0`) once per channel + // before the very first real command to make sure the interpreter has + // finished its prompt/initialisation handshake. This greatly reduces the + // likelihood of the first user command timing out. + private var isWarmedUp = false + + // MARK: - Reliability tuning + /// Maximum number of commands that can be queued when the executor is busy. + /// This still keeps latency low (worst-case 2 in-flight before the one just issued) + /// but avoids the "Executor busy" error when the user taps e.g. ▶︎ six times quickly. + private let maxQueuedCommands = 20 + + /// Number of consecutive timeouts observed. We only tear the channel down after + /// a small burst of timeouts to avoid over-aggressive reconnects on a momentary stall. + private var consecutiveTimeouts = 0 + private let maxConsecutiveTimeouts = 1 + + /// Command watchdog duration. AppleScript can legitimately take >2 s under load. + private let commandTimeoutSeconds: TimeAmount = .seconds(3) + + init(connection: Channel, channelKey: String) { + var id = 0 + ChannelExecutor.idQueue.sync { + id = ChannelExecutor.nextExecutorID + ChannelExecutor.nextExecutorID += 1 + } + self.executorId = id + + // Initialization logging handled by SSHClient + self.connection = connection + self.channelKey = channelKey + + // Create a single interactive shell session + let promise = connection.eventLoop.makePromise(of: Channel.self) + let handler = StreamingShellHandler() + self.shellHandler = handler + connection.pipeline.handler(type: NIOSSHHandler.self) + .flatMap { sshHandler -> EventLoopFuture in + sshHandler.createChannel(promise) { child, type in + guard type == .session else { + return child.eventLoop.makeFailedFuture(SSHError.invalidChannelType) + } + return child.pipeline.addHandlers([handler]) + } + return promise.futureResult + } + .flatMap { (chan: Channel) -> EventLoopFuture in + // Persist the channel reference as soon as it's available + Task { [weak self] in + await self?.setShellChannel(chan) + } + // Always use the interactive AppleScript shell + return setupInteractiveShell(channel: chan, command: "/usr/bin/osascript -s s -l AppleScript -i") + } + .whenComplete { [weak self] result in + guard let self = self else { return } + switch result { + case .success: break + case .failure(let error): + sshLog("☄︎ [E\(self.executorId)] ChannelExecutor: ❌ Failed to start interactive shell: \(error)") + } + } + } + + /// Executes `command` by queueing it. Exactly one command is inflight on the interactive shell. + func run(command: String, description: String?) async -> Result { + // Perform a one-time warm-up if this is the first command on the channel. + if !isWarmedUp && channelKey != "system" { + // Mark as warmed-up immediately to avoid recursion. + isWarmedUp = true + // Fire a synchronous warm-up round-trip and ignore the result. + _ = await run(command: "return 0", description: "warm-up") + // After the warm-up completes, proceed with the actual command below. + } + + // Respect the bounded queue: accept up to `maxQueuedCommands` items. + if isBusy || !workQueue.isEmpty { + if workQueue.count >= maxQueuedCommands { + let dropPreview = description ?? String(command.prefix(30)) + sshLog("☄︎ [E\(executorId):\(channelKey)] ⚠️ Queue full – rejecting cmd \(dropPreview)") + return .failure(SSHError.channelError("Executor queue full")) + } + } + + // Build unique command id & sentinel + let cmdId = commandCounter + commandCounter &+= 1 + let cmdIdHex = String(format: "%04X", cmdId & 0xFFFF) + let sentinel = ">>>CTRL_\(cmdIdHex)<<<" + + // AppleScript payload (command already wrapped upstream) + let escapedSentinel = sentinel.replacingOccurrences(of: "\"", with: "\\\"") + let payload = "-- \(cmdIdHex) \(description ?? "")\n\(command)\n\n\"\(escapedSentinel)\"\n\n" + + // Only log command attempts on failure; success logged by AppController + + return await withCheckedContinuation { [weak self] continuation in + guard let self = self else { + continuation.resume(returning: .failure(SSHError.channelError("Executor deallocated"))) + return + } + Task { await self.enqueueWorkItem(payload: payload, sentinel: sentinel, description: description, continuation: continuation) } + } + } + + private func enqueueWorkItem(payload: String, sentinel: String, description: String?, continuation: CheckedContinuation, Never>) async { + let item = WorkItem(payload: payload, sentinel: sentinel, description: description, continuation: continuation) + workQueue.append(item) + await processNext() + } + + // MARK: - Internal queue processor + private func processNext() async { + guard !isBusy, !workQueue.isEmpty else { return } + guard let chan = await ensureShellChannelReady() else { return } + + let item = workQueue.removeFirst() + isBusy = true + + // Prepare promise & sentinel mapping + let promise = chan.eventLoop.makePromise(of: String.self) + shellHandler.addCommand(sentinel: item.sentinel, promise: promise) + + // Send payload + let buffer = chan.allocator.buffer(string: item.payload) + chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: nil) + + // Timeout watchdog – more generous and adaptive. We don't immediately kill the + // channel on the first timeout to avoid expensive reconnects during short stalls. + let timeoutTask = chan.eventLoop.scheduleTask(in: commandTimeoutSeconds) { [weak self] in + guard let self else { return } + sshLog("☄︎ [E\(executorId)] ChannelExecutor: ⏰ Cmd \(item.sentinel.prefix(8)) timed out") + promise.fail(SSHError.timeout) + + self.consecutiveTimeouts += 1 + + if self.consecutiveTimeouts >= self.maxConsecutiveTimeouts { + sshLog("☄︎ [E\(executorId)] ChannelExecutor: ⚠️ Too many consecutive timeouts – closing shell channel") + Task { await self.close() } + self.consecutiveTimeouts = 0 + } else { + // Allow queue to proceed; mark not busy to process next command + Task { await self.finishCurrentAndContinue() } + } + } + + promise.futureResult.whenComplete { [weak self] result in + timeoutTask.cancel() + guard let self = self else { return } + // Success or failure resets the timeout counter if we did get a response + self.consecutiveTimeouts = 0 + let cont = item.continuation + Task { @MainActor in + cont.resume(returning: result) + } + Task { await self.finishCurrentAndContinue() } + } + } + + private func finishCurrentAndContinue() async { + isBusy = false + await processNext() + } + + private func ensureShellChannelReady() async -> Channel? { + var retries = 0 + while shellChannel == nil && retries < 150 { + retries += 1 + try? await Task.sleep(nanoseconds: 10_000_000) + } + if shellChannel == nil { + sshLog("☄︎ [E\(executorId):\(channelKey)] ❌ No shell channel available") + } + return shellChannel + } + + /// Close shell channel + func close() { + sshLog("☄︎ [E\(executorId)] ChannelExecutor: Closing shell channel") + if let chan = self.shellChannel { + chan.close(mode: .all, promise: nil) + } + // Reset internal state so this executor cannot be reused accidentally + self.shellChannel = nil + self.workQueue.removeAll() + self.isBusy = false + } + + /// Set shell channel from async context + private func setShellChannel(_ channel: Channel) { + self.shellChannel = channel + } +} diff --git a/Control/SSH/README.md b/Control/SSH/README.md new file mode 100644 index 0000000..f448aae --- /dev/null +++ b/Control/SSH/README.md @@ -0,0 +1,42 @@ +# SSH stack overview + +``` ++-----------------------+ ping / UI state +-------------------+ +| SSHConnectionManager| <-------------------------------- | SwiftUI Views | +| (heartbeats & recon) | +-------------------+ +| | delegates cmd/heartbeat ++-----------+-----------+-------------------------------------------+ + | | + v v ++-----------+-----------+ +----------+-----------+ +| SSHClient | maps logical -> physical | PlatformRegistry | +| (single TCP socket) | "system" -> system +----------------------+ +| | "heartbeat" -> heartbeat | (app metadata) | ++-----------+-----------+ otherAppId -> app-0..app-N +----------------------+ + | + v ++-----------+-----------+ +| ChannelExecutor(s) | serial queue -> 1 interactive AppleScript shell +| one per physical key | ++-----------+-----------+ + | + v ++-----------------------+ +| SSH Channel (PTY) | `/usr/bin/osascript -i` keeps interpreter alive ++-----------------------+ +``` + +Highlights +----------- +1. **TCP socket** is opened once by `SSHClient` and reused. +2. **ChannelExecutors** guarantee only *one* command at a time on their interactive shell. +3. **Heartbeat** channel is totally isolated from app/system channels so transient script errors don't look like connection loss. +4. `SSHConnectionManager` owns the reconnection logic and dimming/UI state. + +File map +-------- +- `SSHConnectionManager.swift` – high-level lifecycle, recovery, UI binding. +- `SSHConnectionManager+Heartbeat.swift` – focused heartbeat implementation. +- `SSHClient.swift` – connection bootstrap and executor pool. +- `ChannelExecutor.swift` – queue, timeouts, warm-up. +- `ChannelExecutor+ShellSetup.swift` – PTY + interactive shell boilerplate. \ No newline at end of file diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 5af7c7c..1e19515 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -13,14 +13,71 @@ enum SSHError: Error { case noSession } - -class SSHClient: SSHClientProtocol { +class SSHClient: SSHClientProtocol, @unchecked Sendable { private var group: EventLoopGroup private var connection: Channel? - private var session: Channel? - private var authDelegate: PasswordAuthDelegate? private var hasCompletedConnection = false + // MARK: - Dedicated Channel Support + // Using a single app channel improves stability by serialising all app commands + private let appChannelPoolSize = 1 + + /// Executors keyed by physical channel name (e.g. "system", "app-0", "app-1") + private var dedicatedExecutors: [String: ChannelExecutor] = [:] + + /// Retrieve an existing executor or create a new one based on the logical key. + /// This function maps a logical key (like "spotify") to a physical executor (like "app-2"). + private func executor(for key: String) async throws -> ChannelExecutor { + let executorKey = physicalKey(for: key) + + // Create a new executor if still missing (non-blocking; possible race creates extra but harmless) + if let existing = dedicatedExecutors[executorKey] { + return existing + } + + // Ensure we have an active SSH TCP connection. + guard let connection = self.connection else { + sshLog("📡 SSHClient: ❌ No active connection for executor creation") + throw SSHError.channelNotConnected + } + + // Create a new ChannelExecutor for this physical key ("app-N" or "system") + let executor = ChannelExecutor(connection: connection, channelKey: executorKey) + dedicatedExecutors[executorKey] = executor + sshLog("☕︎ Channel '\(executorKey)' ready") + return executor + } + + /// Async helper that runs a command on a dedicated channel and returns the Result. + private func performOnDedicatedChannel(_ channelKey: String, command: String, description: String?) async -> Result { + do { + let exec = try await executor(for: channelKey) + let result = await exec.run(command: command, description: description) + if case .failure(let error) = result { + var shouldReset = false + if case SSHError.timeout = error { shouldReset = true } + if case SSHError.channelError = error { shouldReset = true } + if shouldReset { + let physicalKey = self.physicalKey(for: channelKey) + dedicatedExecutors.removeValue(forKey: physicalKey) + sshLog("📡 SSHClient: Removed executor for key '\(physicalKey)' due to error – will recreate on next use") + } + } + return result + } catch { + sshLog("❌ Failed to get executor or run command: \(error)") + return .failure(error) + } + } + + // Protocol-facing entry point (completion-handler style) + func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { + Task { + let result = await performOnDedicatedChannel(channelKey, command: command, description: description) + completion(result) + } + } + init() { self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) } @@ -34,10 +91,8 @@ class SSHClient: SSHClientProtocol { // Reset connection state hasCompletedConnection = false - let connectionId = UUID().uuidString.prefix(8) - sshLog("🆔 [\(connectionId)] SSHClient: Starting connection process") - sshLog("Host: \(host.prefix(10))***") - sshLog("Username: \(username.prefix(3))***") + let connectionId = String(UUID().uuidString.prefix(8)) + sshLog("⚯ [\(connectionId)] SSHClient: Connecting to \(host) as \(username)") // Only clean up if we have an active connection if connection != nil { @@ -65,25 +120,30 @@ class SSHClient: SSHClientProtocol { self.disconnect() completion(.failure(SSHError.authenticationFailed)) } - self.authDelegate = authDelegate // Create and configure bootstrap with explicit timeout let bootstrap = ClientBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .channelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1) .channelOption(ChannelOptions.connectTimeout, value: .seconds(4)) // Shorter than our 5-second timeout - .channelInitializer { [weak self] channel in - self?.setupChannel(channel, authDelegate: authDelegate) ?? channel.eventLoop.makeFailedFuture(SSHError.channelError("Failed to setup channel")) + .channelInitializer { channel in + channel.pipeline.addHandler(NIOSSHHandler( + role: .client(.init( + userAuthDelegate: authDelegate, + serverAuthDelegate: AcceptAllHostKeysDelegate() + )), + allocator: channel.allocator, + inboundChildChannelInitializer: { childChannel, channelType in + // This initializer is for channels opened by the SERVER. + // We are opening channels from the client side, so this can be minimal. + guard channelType == .session else { + return childChannel.eventLoop.makeFailedFuture(SSHError.invalidChannelType) + } + return childChannel.pipeline.addHandler(ErrorHandler()) + } + )) } - // Attempt connection - let isLocal = host.contains(".local") - let connectionType = isLocal ? "SSH over Bonjour (.local)" : "SSH over TCP/IP" - let hostRedacted = String(host.prefix(3)) + "***" - - sshLog("Attempting TCP connection: \(connectionType)") - sshLog("Target: \(hostRedacted):22") - bootstrap.connect(host: host, port: 22).whenComplete { [weak self] result in guard let self = self, !self.hasCompletedConnection else { sshLog("⚠️ [\(connectionId)] Connection attempt completed but already handled, ignoring result") @@ -93,91 +153,36 @@ class SSHClient: SSHClientProtocol { switch result { case .success(let channel): - sshLog("✓ [\(connectionId)] TCP connection established") + sshLog("⚭ [\(connectionId)] TCP connection established") self.connection = channel - self.createSession { [weak self] sessionResult in - guard let self = self, !self.hasCompletedConnection else { - sshLog("⚠️ [\(connectionId)] Session creation completed but already handled, ignoring result") - return - } - - switch sessionResult { - case .success: - if authDelegate.authFailed { - sshLog("❌ [\(connectionId)] Authentication failed during session creation") - self.hasCompletedConnection = true - completion(.failure(SSHError.authenticationFailed)) - } else { - sshLog("✓ [\(connectionId)] SSH connection fully established") - self.hasCompletedConnection = true - completion(.success(())) - } - case .failure(let error): - sshLog("❌ [\(connectionId)] Session creation failed: \(error)") - self.hasCompletedConnection = true - completion(.failure(error)) - } + // With client-side channels, we don't need to pre-create a main session. + // The connection is ready to be used by ChannelExecutors. + if authDelegate.authFailed { + sshLog("❌ [\(connectionId)] Authentication failed post-connection") + self.hasCompletedConnection = true + completion(.failure(SSHError.authenticationFailed)) + } else { + sshLog("☕︎ [\(connectionId)] SSH connection ready for channels") + self.hasCompletedConnection = true + completion(.success(())) } case .failure(let error): sshLog("❌ [\(connectionId)] TCP connection failed: \(error.localizedDescription)") self.hasCompletedConnection = true - - // Use centralized error processing (handles NIOConnectionError and timeouts) completion(.failure(self.processError(error))) } } } - private func setupChannel(_ channel: Channel, authDelegate: PasswordAuthDelegate) -> EventLoopFuture { - let sshHandler = NIOSSHHandler( - role: .client(.init( - userAuthDelegate: authDelegate, - serverAuthDelegate: AcceptAllHostKeysDelegate() - )), - allocator: channel.allocator, - inboundChildChannelInitializer: { childChannel, channelType in - guard channelType == .session else { - return channel.eventLoop.makeFailedFuture(SSHError.invalidChannelType) - } - return childChannel.pipeline.addHandlers([ - SSHCommandHandler(), - ErrorHandler() - ]) - } - ) - return channel.pipeline.addHandler(sshHandler) - } - private func processError(_ error: Error) -> Error { let errorString = error.localizedDescription.lowercased() - let errorTypeName = String(describing: type(of: error)) - sshLog("Processing SSH error: \(errorString)") - sshLog("Error type: \(errorTypeName)") - - // Handle NIOConnectionError specifically - if errorString.contains("nioconnectionerror") || errorTypeName.contains("NIOConnectionError") { - sshLog("Error classified as: NIO connection error") - - // Check for timeout specifically in NIOConnectionError - if errorString.contains("connecttimeout") || errorString.contains("timeout") { - sshLog("NIOConnectionError contains timeout - converting to SSHError.timeout") - return SSHError.timeout - } else if errorString.contains("dnsaerror") || errorString.contains("dnsaaaerror") { - sshLog("DNS resolution failed") - return SSHError.connectionFailed("Could not find the device on your network") - } else { - sshLog("Generic NIOConnectionError - treating as network connection failed") - return SSHError.connectionFailed("Network connection failed") - } - } // Network connectivity issues if errorString.contains("network is unreachable") || errorString.contains("host is unreachable") || errorString.contains("no route to host") || errorString.contains("connection timed out") { - sshLog("Error classified as: Network connectivity issue") return SSHError.connectionFailed("Network connectivity lost") } @@ -185,341 +190,48 @@ class SSHClient: SSHClientProtocol { if errorString.contains("dns") || errorString.contains("unknown host") || errorString.contains("nodename nor servname provided") { - sshLog("Error classified as: DNS resolution failure") return SSHError.connectionFailed("Could not find the device on your network") } - // Authentication failures - if errorString.contains("auth failed") || - errorString.contains("permission denied") { - sshLog("Error classified as: Authentication failure") - return SSHError.authenticationFailed - } - - // Connection failures - if let posixError = error as? POSIXError { - sshLog("POSIX error detected: \(posixError.code)") - switch posixError.code { - case .ECONNREFUSED: - sshLog("Error classified as: Connection refused (Remote Login disabled)") - return SSHError.connectionFailed("Remote Login is not enabled") - case .EHOSTUNREACH: - sshLog("Error classified as: Host unreachable") - return SSHError.connectionFailed("Computer is not reachable") - case .ETIMEDOUT: - sshLog("Error classified as: Timeout") - return SSHError.timeout - case .ENETUNREACH: - sshLog("Error classified as: Network unreachable") - return SSHError.connectionFailed("Network connectivity lost") - case .ENOTCONN: - sshLog("Error classified as: Not connected") - return SSHError.connectionFailed("Connection was lost") - default: - sshLog("Error classified as: Network error (\(posixError.code))") - return SSHError.connectionFailed("Network error: \(posixError.localizedDescription)") - } + // Connection refused (e.g., Remote Login disabled) + if let posixError = error as? POSIXError, posixError.code == .ECONNREFUSED { + return SSHError.connectionFailed("Remote Login is not enabled") } - // Connection reset and EOF are connection failures - if errorString.contains("connection reset") || - errorString.contains("eof") || - errorString.contains("broken pipe") { - sshLog("Error classified as: Connection interrupted") - return SSHError.connectionFailed("Connection was interrupted") - } - - // If we get here, it's likely a connection issue - sshLog("Error classified as: Generic connection failure") + // If we get here, it's likely a generic connection issue + sshLog("Error classified as: Generic connection failure: \(errorString)") return SSHError.connectionFailed("Could not establish connection") } - private func createSession(completion: @escaping (Result) -> Void) { - guard let connection = connection else { - sshLog("❌ No active connection for session creation") - completion(.failure(SSHError.channelNotConnected)) - return - } - - let promise = connection.eventLoop.makePromise(of: Channel.self) - - sshLog("Creating SSH session...") - connection.pipeline.handler(type: NIOSSHHandler.self).flatMap { handler -> EventLoopFuture in - handler.createChannel(promise) { channel, channelType in - guard channelType == .session else { - return channel.eventLoop.makeFailedFuture(SSHError.invalidChannelType) - } - return channel.pipeline.addHandlers([ - SSHCommandHandler(), - ErrorHandler() - ]) - } - return promise.futureResult - }.whenComplete { [weak self] result in - switch result { - case .success(let channel): - sshLog("✓ SSH session created successfully") - self?.session = channel - completion(.success(())) - case .failure(let error): - sshLog("❌ SSH session creation failed: \(error)") - completion(.failure(self?.processError(error) ?? error)) - } - } - } - - func executeCommand(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - guard let session = session else { - sshLog("❌ No active session for command execution") - completion(.failure(SSHError.channelNotConnected)) - return - } - - let commandDesc = description ?? "Running AppleScript command" - sshLog("Executing: \(commandDesc)") - - session.pipeline.handler(type: SSHCommandHandler.self).flatMap { handler -> EventLoopFuture in - let promise = session.eventLoop.makePromise(of: String.self) - handler.pendingCommandPromise = promise - - let execRequest = SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true) - return session.triggerUserOutboundEvent(execRequest).flatMap { _ in - return promise.futureResult - } - }.whenComplete { result in - switch result { - case .success(let output): - sshLog("✓ Command completed successfully") - if !output.isEmpty { - sshLog("Command output: \(output)") - } - completion(.success(output)) - case .failure(let error): - let errorString = error.localizedDescription.lowercased() - if errorString.contains("channel setup rejected") || errorString.contains("open failed") { - sshLog("❌ Command failed: Server rejected channel setup") - completion(.failure(SSHError.channelError("Server rejected channel setup"))) - } else { - sshLog("❌ Command failed: \(error)") - completion(.failure(error)) - } - } - } - } - - func executeCommandWithNewChannel(_ command: String, description: String?, completion: @escaping (Result) -> Void) { - executeCommandDirectly(command, description: description, completion: completion) - } - - /// Execute command directly without heartbeat checks - used by the heartbeat mechanism itself - func executeCommandBypassingHeartbeat(_ command: String, description: String?, completion: @escaping (Result) -> Void) { - executeCommandDirectly(command, description: description, completion: completion) - } - - /// Direct execution method that bypasses heartbeat checks (used by heartbeat itself) - private func executeCommandDirectly(_ command: String, description: String?, completion: @escaping (Result) -> Void) { - guard let connection = connection else { - sshLog("❌ No active connection for new channel command") - completion(.failure(SSHError.channelNotConnected)) - return - } - - let commandDesc = description ?? "Running command with new channel" - sshLog("Executing with new channel: \(commandDesc)") - - let childPromise = connection.eventLoop.makePromise(of: Channel.self) - var commandChannel: Channel? - - connection.pipeline.handler(type: NIOSSHHandler.self).flatMap { sshHandler -> EventLoopFuture in - sshHandler.createChannel(childPromise) { (childChannel: Channel, channelType: SSHChannelType) -> EventLoopFuture in - guard channelType == .session else { - return childChannel.eventLoop.makeFailedFuture(SSHError.invalidChannelType) - } - - let commandHandler = SSHCommandHandler() - commandHandler.pendingCommandPromise = childChannel.eventLoop.makePromise(of: String.self) - - return childChannel.pipeline.addHandlers([ - commandHandler, - ErrorHandler() - ]) - } - - return childPromise.futureResult.map { channel in - commandChannel = channel - return channel - }.flatMapError { error in - sshLog("❌ Channel creation failed: \(error)") - // Check for TCP shutdown and other fatal errors - let errorString = error.localizedDescription.lowercased() - if errorString.contains("tcp shutdown") || - errorString.contains("connection reset") || - errorString.contains("broken pipe") || - errorString.contains("connection closed") || - errorString.contains("eof") { - sshLog("🚨 Fatal connection error detected - connection lost") - self.disconnect() - return connection.eventLoop.makeFailedFuture(SSHError.channelError("Connection lost")) - } - return connection.eventLoop.makeFailedFuture(error) - } - }.flatMap { channel -> EventLoopFuture in - channel.pipeline.handler(type: SSHCommandHandler.self).flatMap { handler in - guard let promise = handler.pendingCommandPromise else { - return channel.eventLoop.makeFailedFuture(SSHError.channelError("Command promise not set")) - } - - let execRequest = SSHChannelRequestEvent.ExecRequest(command: command, wantReply: true) - return channel.triggerUserOutboundEvent(execRequest).flatMap { _ in - // Add timeout for command execution - shorter for heartbeat commands - let timeoutSeconds = (command.contains("heartbeat-")) ? 3 : 5 - channel.eventLoop.scheduleTask(in: .seconds(Int64(timeoutSeconds))) { - if let pendingPromise = handler.pendingCommandPromise { - sshLog("⏰ Command execution timed out after \(timeoutSeconds) seconds") - pendingPromise.fail(SSHError.timeout) - channel.close(promise: nil) - } - } - return promise.futureResult - } - } - }.whenComplete { result in - // Clean up the channel - if let channel = commandChannel { - channel.close(promise: nil) - } - - switch result { - case .success(let output): - sshLog("✓ New channel command completed successfully") - if !output.isEmpty { - sshLog("Command output: \(output)") - } - completion(.success(output)) - case .failure(let error): - sshLog("❌ New channel command failed: \(error)") - // Check if the error indicates a disconnection - let errorString = error.localizedDescription.lowercased() - if errorString.contains("eof") || - errorString.contains("connection reset") || - errorString.contains("broken pipe") || - errorString.contains("connection closed") || - errorString.contains("tcp shutdown") { - sshLog("🚨 Connection appears to be closed - cleaning up") - self.disconnect() - completion(.failure(SSHError.channelError("Connection lost"))) - } else { - completion(.failure(error)) - } - } - } - } - func disconnect() { - sshLog("SSHClient: Starting disconnect process") + sshLog("⚯ Starting disconnect process") // Reset connection completion state hasCompletedConnection = false - // Send exit command to gracefully close remote session if possible - if let session = session { - sshLog("Sending exit command to gracefully close remote session") - let exitPromise = session.eventLoop.makePromise(of: Void.self) - let exitRequest = SSHChannelRequestEvent.ExecRequest(command: "exit", wantReply: false) - session.triggerUserOutboundEvent(exitRequest).whenComplete { _ in - exitPromise.succeed(()) - } - - // Don't wait too long for exit command - session.eventLoop.scheduleTask(in: .milliseconds(500)) { - exitPromise.succeed(()) - } - - // Cancel any pending promises after exit attempt - session.pipeline.handler(type: SSHCommandHandler.self).whenSuccess { handler in - if let promise = handler.pendingCommandPromise { - sshLog("Cancelling pending command promise") - promise.fail(SSHError.channelError("Connection closed")) - } - } + // Close and clear any dedicated channels + for (key, executor) in dedicatedExecutors { + sshLog("Closing dedicated channel for key: \(key)") + Task { await executor.close() } } + dedicatedExecutors.removeAll() - // Clean up auth delegate - authDelegate = nil - - // Close session and connection - session?.close(promise: nil) - session = nil connection?.close(promise: nil) connection = nil - sshLog("✓ SSHClient disconnected and cleaned up") - } -} - -class SSHCommandHandler: ChannelInboundHandler { - typealias InboundIn = SSHChannelData - typealias OutboundOut = SSHChannelData - - var pendingCommandPromise: EventLoopPromise? - private var buffer = "" - private var hasReceivedOutput = false - - func channelRead(context: ChannelHandlerContext, data: NIOAny) { - let channelData = unwrapInboundIn(data) - - switch channelData.type { - case .channel: - if case .byteBuffer(let buffer) = channelData.data, - let output = buffer.getString(at: 0, length: buffer.readableBytes) { - self.buffer += output - hasReceivedOutput = true - if !output.isEmpty { completeCommand() } - } - - case .stdErr: - if case .byteBuffer(let buffer) = channelData.data, - let error = buffer.getString(at: 0, length: buffer.readableBytes) { - self.buffer += "[Error] " + error - hasReceivedOutput = true - completeCommand() - } - - default: - break - } + sshLog("⚰︎ SSHClient disconnected and cleaned up") } - private func completeCommand() { - if hasReceivedOutput { - let result = buffer.trimmingCharacters(in: .whitespacesAndNewlines) - pendingCommandPromise?.succeed(result) - pendingCommandPromise = nil - buffer = "" - hasReceivedOutput = false - } - } - - func channelInactive(context: ChannelHandlerContext) { - if let promise = pendingCommandPromise { - promise.fail(SSHError.channelError("Connection closed")) - pendingCommandPromise = nil - } - context.fireChannelInactive() - } - - func errorCaught(context: ChannelHandlerContext, error: Error) { - if let promise = pendingCommandPromise { - promise.fail(error) - pendingCommandPromise = nil - } - context.close(promise: nil) + /// Maps a logical channel key ("system", "heartbeat", app id) to its underlying executor key. + private func physicalKey(for logicalKey: String) -> String { + if logicalKey == "system" { return "system" } + if logicalKey == "heartbeat" { return "heartbeat" } + return "app-\(abs(logicalKey.hashValue) % appChannelPoolSize)" } } class PasswordAuthDelegate: NIOSSHClientUserAuthenticationDelegate { private let username: String private let password: String - private var authAttempts = 0 private(set) var authFailed = false var onAuthFailure: (() -> Void)? @@ -532,27 +244,16 @@ class PasswordAuthDelegate: NIOSSHClientUserAuthenticationDelegate { availableMethods: NIOSSHAvailableUserAuthenticationMethods, nextChallengePromise: EventLoopPromise ) { - authAttempts += 1 - // Only attempt password auth if it's available if !availableMethods.contains(.password) { - print("Password authentication not available") - authFailed = true - onAuthFailure?() - nextChallengePromise.succeed(nil) - return - } - - // Allow only one authentication attempt - if authAttempts > 1 { - print("Authentication failed after attempt") + sshLog("Password authentication not available on server") authFailed = true onAuthFailure?() nextChallengePromise.succeed(nil) return } - print("Attempting password authentication") + sshLog("Attempting password authentication") nextChallengePromise.succeed( NIOSSHUserAuthenticationOffer( username: username, @@ -573,6 +274,9 @@ private class ErrorHandler: ChannelInboundHandler { typealias InboundIn = Any func errorCaught(context: ChannelHandlerContext, error: Error) { + // This handler is for server-initiated channels, which we don't expect. + // Logging the error is sufficient. + sshLog("SSH Error on server-initiated channel: \(error)") context.close(promise: nil) } } diff --git a/Control/SSH/SSHClientProtocol.swift b/Control/SSH/SSHClientProtocol.swift index 3d82f93..4458813 100644 --- a/Control/SSH/SSHClientProtocol.swift +++ b/Control/SSH/SSHClientProtocol.swift @@ -3,12 +3,14 @@ import Foundation protocol SSHClientProtocol { func connect(host: String, username: String, password: String, completion: @escaping (Result) -> Void) func disconnect() - func executeCommandWithNewChannel(_ command: String, description: String?, completion: @escaping (Result) -> Void) + /// Execute a command on a long-lived, dedicated SSH channel identified by `channelKey`. + /// The same channel is reused for subsequent calls with the same key. + func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, description: String?, completion: @escaping (Result) -> Void) } // Default implementation for optional description parameter extension SSHClientProtocol { - func executeCommandWithNewChannel(_ command: String, completion: @escaping (Result) -> Void) { - executeCommandWithNewChannel(command, description: nil, completion: completion) + func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, completion: @escaping (Result) -> Void) { + executeCommandOnDedicatedChannel(channelKey, command, description: nil, completion: completion) } } diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 33be8b8..6121a65 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -1,14 +1,30 @@ import Foundation import SwiftUI +import Network @MainActor class SSHConnectionManager: ObservableObject, SSHClientProtocol { @Published private(set) var connectionState: ConnectionState = .disconnected private nonisolated let sshClient: SSHClient private var currentCredentials: Credentials? - private var connectionLostHandler: (@MainActor () -> Void)? + private var connectionLostHandler: (@MainActor (Error?) -> Void)? + private var pathMonitor: NWPathMonitor? + private let monitorQueue = DispatchQueue(label: "SSHPathMonitor") private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + // MARK: - Heartbeat Management + private var heartbeatTask: Task? + private var consecutiveHeartbeatFailures = 0 + private let maxHeartbeatFailures = 1 + private let minHeartbeatInterval: TimeInterval = 0.5 + private let maxHeartbeatInterval: TimeInterval = 12 + private var currentHeartbeatInterval: TimeInterval = 3 + private var lastHeartbeatSuccess: Date? + private var recoveryDeadline: Date? + private var heartbeatCounter: UInt32 = 0 + private let heartbeatReplyTimeout: TimeInterval = 1.0 + private var heartbeatReadyContinuations: [CheckedContinuation] = [] + static let shared = SSHConnectionManager() struct Credentials: Equatable { @@ -21,6 +37,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { case disconnected case connecting case connected + case recovering case failed(String) var description: String { @@ -28,6 +45,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { case .disconnected: return "disconnected" case .connecting: return "connecting" case .connected: return "connected" + case .recovering: return "recovering" case .failed(let error): return "failed(\(error))" } } @@ -36,26 +54,49 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { init() { connectionLog("SSHConnectionManager: Initializing") self.sshClient = SSHClient() + + // Start monitoring network path changes to detect sudden Wi-Fi drops + let monitor = NWPathMonitor() + self.pathMonitor = monitor + monitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + if path.status != .satisfied { + connectionLog("🚨 Network path no longer satisfied – assuming connection lost") + Task { @MainActor in + self.handleConnectionLost(because: path.status == .unsatisfied ? SSHError.connectionFailed("Network path unavailable") : nil) + } + } + } + monitor.start(queue: monitorQueue) } - func setConnectionLostHandler(_ handler: @escaping @MainActor () -> Void) { + func setConnectionLostHandler(_ handler: @escaping @MainActor (Error?) -> Void) { self.connectionLostHandler = handler } nonisolated var client: SSHClient { - sshLog("SSHConnectionManager: Accessing SSH client") + // Accessor no longer logs every call to reduce console noise. return sshClient } - func handleConnectionLost() { - sshLog("🚨 SSHConnectionManager: Connection Lost Detected") + func handleConnectionLost(because error: Error? = nil) { Task { @MainActor in - let previousState = connectionState.description - connectionState = .disconnected - sshLog("Connection state changed: \(previousState) -> \(connectionState.description)") - sshLog("🚨 Triggering connection lost handler to update UI") - disconnect() - connectionLostHandler?() + // Prevent multiple simultaneous reconnect attempts + guard connectionState == .connected || connectionState == .recovering else { return } + + connectionLog("🚨 Connection lost...") + + // Immediately transition to disconnected to stop further commands + self.connectionState = .disconnected + + // Clean up old connection artifacts + self.sshClient.disconnect() + + // Trigger UI handler to show alert + self.connectionLostHandler?(error) + + // Stop heartbeat monitoring + self.stopHeartbeat() } } @@ -87,12 +128,14 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { return } hasResumed = true - connectionLog("🔄 [\(connectionId)] Processing connection result: \(result)") switch result { case .success: connectionLog("✓ [\(connectionId)] Connection successful") self.connectionState = .connected + self.consecutiveHeartbeatFailures = 0 + self.lastHeartbeatSuccess = Date() + self.recoveryDeadline = nil continuation.resume() case .failure(let error): connectionLog("❌ [\(connectionId)] Connection failed: \(error)") @@ -101,6 +144,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { // Ensure client is disconnected on failure to prevent stale state client.disconnect() + self.handleConnectionLost(because: error) continuation.resume(throwing: error) } } @@ -125,12 +169,15 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { self.currentCredentials = nil self.cancelBackgroundDisconnect() self.endBackgroundTask() + // Stop heartbeat monitoring + self.stopHeartbeat() } } deinit { sshClient.disconnect() backgroundDisconnectTimer?.cancel() + pathMonitor?.cancel() } func shouldReconnect(host: String, username: String, password: String) -> Bool { @@ -190,20 +237,20 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { // Set up 30-second timer to disconnect SSH let disconnectTimer = DispatchWorkItem { [weak self] in - connectionLog("📱 App backgrounded for 30 seconds - disconnecting SSH") + connectionLog("⚰︎ App backgrounded for 30 seconds - disconnecting SSH") self?.disconnect() self?.endBackgroundTask() } backgroundDisconnectTimer = disconnectTimer DispatchQueue.main.asyncAfter(deadline: .now() + 30.0, execute: disconnectTimer) - connectionLog("📱 Started 30-second background disconnect timer") + connectionLog("⚰︎ Started 30-second background disconnect timer") } private func cancelBackgroundDisconnect() { backgroundDisconnectTimer?.cancel() backgroundDisconnectTimer = nil - connectionLog("📱 Cancelled background disconnect timer") + connectionLog("⚰︎ Cancelled background disconnect timer") } private func startBackgroundTask() { @@ -212,7 +259,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "SSH Connection Cleanup") { [weak self] in // Background task is about to expire (system limit ~30-180 seconds) - connectionLog("📱 Background task expiring - disconnecting SSH") + connectionLog("⚰︎ Background task expiring - disconnecting SSH") self?.client.disconnect() self?.disconnect() self?.endBackgroundTask() @@ -221,13 +268,13 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { if backgroundTask == .invalid { connectionLog("⚠️ Failed to start background task") } else { - connectionLog("📱 Started background task: \(backgroundTask)") + connectionLog("⚰︎ Started background task: \(backgroundTask)") } } private func endBackgroundTask() { if backgroundTask != .invalid { - connectionLog("📱 Ending background task: \(backgroundTask)") + connectionLog("⚰︎ Ending background task: \(backgroundTask)") UIApplication.shared.endBackgroundTask(backgroundTask) backgroundTask = .invalid } @@ -277,19 +324,18 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { return isConnectionLoss } - /// Execute a command with proactive timeout-based connection monitoring - /// - /// This method includes a heartbeat mechanism that: - /// - Starts a timeout timer when the command is sent - /// - Triggers reconnection if no response (success OR failure) within 6 seconds - /// - Prevents silent hangs by detecting dead connections proactively - /// - Sends heartbeat verification after successful commands - nonisolated func executeCommand(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - sshLog("SSHConnectionManager: Executing command with proactive timeout monitoring") + /// Execute a command with proactive timeout-based connection monitoring, allowing selection of a dedicated channel. + nonisolated func executeCommand(onChannel channelKey: String = "system", _ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { + sshLog("SSHConnectionManager: Executing command on channel \(channelKey)") if let description = description { sshLog("Command: \(description)") } + // Boost heartbeat frequency after user/system activity + Task { @MainActor in + self.resetHeartbeatInterval() + } + let commandId = UUID().uuidString.prefix(8) var hasCompleted = false let startTime = Date() @@ -301,7 +347,6 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { let elapsed = Date().timeIntervalSince(startTime) sshLog("⏰ [\(commandId)] Command timed out after \(String(format: "%.1f", elapsed))s with no response") - sshLog("💓 [\(commandId)] Proactive heartbeat timeout - connection appears dead") // Connection appears dead - trigger reconnection Task { @MainActor [weak self] in @@ -310,11 +355,13 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { completion(.failure(SSHError.timeout)) } - sshLog("⏰ [\(commandId)] Starting 6-second proactive timeout monitor") - DispatchQueue.global().asyncAfter(deadline: .now() + 6.0, execute: timeoutTask) + let timeoutSeconds: Double = 15.0 + sshLog("⏰ [\(commandId)] Starting \(Int(timeoutSeconds))-second timeout monitor") + DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutTask) - // Execute the command - client.executeCommandWithNewChannel(command, description: description) { [weak self] result in + // Execute the command on dedicated channel WITHOUT heartbeat verification + // Heartbeats were causing channel exhaustion and connection failures + client.executeCommandOnDedicatedChannel(channelKey, command, description: description) { [weak self] result in guard !hasCompleted else { sshLog("⚠️ [\(commandId)] Command completed but timeout already triggered, ignoring result") return @@ -327,25 +374,10 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { switch result { case .success(let output): - sshLog("✓ [\(commandId)] Command succeeded, sending verification heartbeat") - // Command succeeded, send verification heartbeat - self?.sendPostCommandHeartbeat { heartbeatResult in - switch heartbeatResult { - case .success: - sshLog("💓 [\(commandId)] Post-command heartbeat successful") - completion(.success(output)) - case .failure(let heartbeatError): - sshLog("💓 [\(commandId)] Post-command heartbeat failed: \(heartbeatError)") - // Heartbeat failed - connection is likely dead - Task { @MainActor [weak self] in - self?.handleConnectionLost() - } - completion(.failure(heartbeatError)) - } - } + sshLog("✓ [\(commandId)] Command succeeded") + completion(.success(output)) case .failure(let error): sshLog("❌ [\(commandId)] Command failed: \(error)") - // Command failed, check if it's a connection issue if self?.isConnectionLossError(error) == true { sshLog("🚨 [\(commandId)] Connection loss detected during command execution") Task { @MainActor [weak self] in @@ -357,58 +389,26 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } } - /// Send a heartbeat command to verify connection health after normal commands - private nonisolated func sendPostCommandHeartbeat(completion: @escaping (Result) -> Void) { - let heartbeatId = UUID().uuidString.prefix(8) - let heartbeatCommand = "echo \"heartbeat-\(heartbeatId)-$(date +%s)\"" - sshLog("💓 Sending post-command heartbeat: \(heartbeatId)") - - client.executeCommandBypassingHeartbeat(heartbeatCommand, description: "Post-command heartbeat") { result in - switch result { - case .success(let output): - if output.contains("heartbeat-\(heartbeatId)") { - sshLog("💓 Heartbeat verified: connection is alive") - completion(.success(())) - } else { - sshLog("💓 Heartbeat invalid response: \(output)") - completion(.failure(SSHError.channelError("Invalid heartbeat response"))) - } - case .failure(let error): - sshLog("💓 Heartbeat failed: \(error)") - completion(.failure(error)) - } - } - } - /// Verifies that the connection is alive and responsive func verifyConnectionHealth() async throws { return try await withCheckedThrowingContinuation { continuation in let healthCommand = "echo \"health-check-$(date +%s)\"" - sshLog("💓 Executing connection health check") - client.executeCommandWithNewChannel(healthCommand, description: "Connection health check") { result in + client.executeCommandOnDedicatedChannel("system", healthCommand, description: "Connection health check") { result in switch result { case .success(let output): if output.contains("health-check-") { - sshLog("💓 Connection health check successful") continuation.resume() } else { - sshLog("💓 Connection health check invalid response") continuation.resume(throwing: SSHError.channelError("Invalid health check response")) } case .failure(let error): - sshLog("💓 Connection health check failed: \(error)") continuation.resume(throwing: error) } } } } - /// Compatibility alias for existing code - nonisolated func executeCommandWithNewChannel(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - executeCommand(command, description: description, completion: completion) - } - // MARK: - SSHClientProtocol Conformance /// Protocol-required connect method with completion handler @@ -423,7 +423,130 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } } + /// Protocol conformance – executes on a dedicated channel (default heartbeat behaviour) + nonisolated func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, description: String?, completion: @escaping (Result) -> Void) { + // User/system activity detected – reset heartbeat interval to minimum + Task { @MainActor in + self.resetHeartbeatInterval() + } + client.executeCommandOnDedicatedChannel(channelKey, command, description: description, completion: completion) + } + + // MARK: - Heartbeat Helpers + func startHeartbeat() { + stopHeartbeat() + consecutiveHeartbeatFailures = 0 + currentHeartbeatInterval = minHeartbeatInterval + heartbeatTask = Task { [weak self] in + guard let self else { return } + await self.performHeartbeat() // immediate first ping + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(self.currentHeartbeatInterval * 1_000_000_000)) + if Task.isCancelled { break } + await self.performHeartbeat() + // Gradually back off interval until max + self.currentHeartbeatInterval = min(self.currentHeartbeatInterval + 1, self.maxHeartbeatInterval) + } + } + connectionLog("♡ Heartbeat started (interval \(minHeartbeatInterval)s -> \(maxHeartbeatInterval)s)") + + lastHeartbeatSuccess = Date() + recoveryDeadline = nil + } + + private func stopHeartbeat() { + heartbeatTask?.cancel() + heartbeatTask = nil + connectionLog("⛔︎ Heartbeat stopped") + recoveryDeadline = nil + } + + @MainActor + private func performHeartbeat() async { + // Build unique identifier & script + let hbId = heartbeatCounter + heartbeatCounter &+= 1 + let idString = String(format: "HB%05u", hbId) + let script = "return \"\(idString)\"" + let sendTime = Date() + var completed = false + + // Timeout watchdog + let timeoutTask = DispatchWorkItem { [weak self] in + guard let self, !completed else { return } + completed = true + self.handleHeartbeatFailure(reason: "timeout waiting > \(heartbeatReplyTimeout)s for \(idString)") + } + DispatchQueue.main.asyncAfter(deadline: .now() + heartbeatReplyTimeout, execute: timeoutTask) + + self.client.executeCommandOnDedicatedChannel("heartbeat", script, description: "heartbeat-\(idString)") { [weak self] result in + guard let self, !completed else { return } + completed = true + timeoutTask.cancel() + switch result { + case .success(let output): + if output.contains(idString) { + self.handleHeartbeatSuccess(rtt: Date().timeIntervalSince(sendTime), id: idString) + } else { + self.handleHeartbeatFailure(reason: "mismatched reply for \(idString)") + } + case .failure(let error): + self.handleHeartbeatFailure(reason: error.localizedDescription) + } + } + } + + @MainActor + private func handleHeartbeatSuccess(rtt: TimeInterval, id: String) { + consecutiveHeartbeatFailures = 0 + lastHeartbeatSuccess = Date() + // Fulfil any waiters exactly once on the first heartbeat. + if !heartbeatReadyContinuations.isEmpty { + heartbeatReadyContinuations.forEach { $0.resume() } + heartbeatReadyContinuations.removeAll() + } + if connectionState == .recovering { + connectionState = .connected + connectionLog("✅ Recovery complete – connection restored (\(String(format: "%.0f", rtt*1000)) ms)") + } else { + connectionLog("♡ Heartbeat OK (\(id), \(String(format: "%.0f", rtt*1000)) ms)") + } + recoveryDeadline = nil + } + + @MainActor + private func handleHeartbeatFailure(reason: String) { + consecutiveHeartbeatFailures += 1 + connectionLog("⚠️ Heartbeat failure (#\(consecutiveHeartbeatFailures)): \(reason)") + if consecutiveHeartbeatFailures == 1 { + connectionState = .recovering + recoveryDeadline = Date().addingTimeInterval(2) + currentHeartbeatInterval = 0.5 + connectionLog("🛠️ Entering recovering state – monitoring for 2s") + } else { + let shouldDrop = consecutiveHeartbeatFailures >= maxHeartbeatFailures && (recoveryDeadline.map { Date() >= $0 } ?? false) + if shouldDrop { + connectionLog("🚨 Recovery failed – treating as connection loss") + handleConnectionLost() + stopHeartbeat() + } + } + } + + @MainActor + private func resetHeartbeatInterval() { + currentHeartbeatInterval = minHeartbeatInterval + } - // executeCommandWithNewChannel is already implemented above with heartbeat protection + // Public helper: returns when the very first heartbeat has succeeded after a connect/reconnect. + @MainActor + func waitForHeartbeatReady() async { + if lastHeartbeatSuccess != nil { + return + } + await withCheckedContinuation { (continuation: CheckedContinuation) in + heartbeatReadyContinuations.append(continuation) + } + } } diff --git a/Control/SSH/SSHViewSupport.swift b/Control/SSH/SSHViewSupport.swift index 3046f00..6b51b65 100644 --- a/Control/SSH/SSHViewSupport.swift +++ b/Control/SSH/SSHViewSupport.swift @@ -31,16 +31,18 @@ extension SSHConnectedView { /// Handle scene phase changes with health check logic @MainActor func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) { - viewLog("\(Self.self): Scene phase changed from \(oldPhase) to \(newPhase)", view: String(describing: Self.self)) + let viewName = String(describing: Self.self) + viewLog("\(viewName): Scene phase changed from \(oldPhase) to \(newPhase)", view: viewName) if newPhase == .active { + let currentViewName = viewName Task { @MainActor in if connectionManager.connectionState == .connected { do { try await connectionManager.verifyConnectionHealth() - viewLog("✓ \(Self.self): Connection health verified", view: String(describing: Self.self)) + viewLog("✓ \(currentViewName): Connection health verified", view: currentViewName) } catch { - viewLog("❌ \(Self.self): Connection health check failed: \(error)", view: String(describing: Self.self)) + viewLog("❌ \(currentViewName): Connection health check failed: \(error)", view: currentViewName) connectToSSH() } } else { @@ -78,34 +80,48 @@ extension SSHConnectedView { let connectionType = isLocal ? "SSH over Bonjour (.local)" : "SSH over TCP/IP" let hostRedacted = String(host.prefix(3)) + "***" - viewLog("Target: \(hostRedacted)", view: String(describing: Self.self)) - viewLog("Protocol: \(connectionType)", view: String(describing: Self.self)) - viewLog("Display name: \(String(displayName.prefix(3)))***", view: String(describing: Self.self)) + let viewNameMeta = String(describing: Self.self) + viewLog("Target: \(hostRedacted)", view: viewNameMeta) + viewLog("Protocol: \(connectionType)", view: viewNameMeta) + viewLog("Display name: \(String(displayName.prefix(3)))***", view: viewNameMeta) } @MainActor private func setConnectionLostHandler() { - connectionManager.setConnectionLostHandler { @MainActor in - viewLog("⚠️ \(Self.self): Connection lost handler triggered", view: String(describing: Self.self)) + let viewNameMeta = String(describing: Self.self) + connectionManager.setConnectionLostHandler { @MainActor error in + viewLog("⚠️ \(viewNameMeta): Connection lost handler triggered by error: \(String(describing: error))", view: viewNameMeta) + + // Use the generic error alert mechanism to show the issue + if let sshError = error as? SSHError { + connectionError.wrappedValue = sshError.formatError(displayName: displayName) + } else if let error = error { + connectionError.wrappedValue = ("Connection Lost", error.localizedDescription) + } else { + connectionError.wrappedValue = ("Connection Lost", "The connection was unexpectedly dropped.") + } + + // Show a generic alert, which on dismiss will pop the view showingConnectionLostAlert.wrappedValue = true } } @MainActor private func connectToSSH() { - viewLog("\(Self.self): Starting SSH connection", view: String(describing: Self.self)) - viewLog("Connection manager state: \(connectionManager.connectionState)", view: String(describing: Self.self)) + let viewName = String(describing: Self.self) + viewLog("\(viewName): Starting SSH connection", view: viewName) + viewLog("Connection manager state: \(connectionManager.connectionState)", view: viewName) connectionManager.handleConnection( host: host, username: username, password: password, onSuccess: { - viewLog("✓ \(Self.self): SSH connection successful", view: String(describing: Self.self)) + viewLog("✓ \(viewName): SSH connection successful", view: viewName) onSSHConnected() }, onError: { error in - viewLog("❌ \(Self.self): SSH connection failed: \(error)", view: String(describing: Self.self)) + viewLog("❌ \(viewName): SSH connection failed: \(error)", view: viewName) if let sshError = error as? SSHError { connectionError.wrappedValue = sshError.formatError(displayName: displayName) diff --git a/Control/SSH/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift new file mode 100644 index 0000000..d456dec --- /dev/null +++ b/Control/SSH/StreamingShellHandler.swift @@ -0,0 +1,307 @@ +import Foundation +import NIOCore +import NIOSSH + +/// Handles an interactive shell channel and fulfils promises when sentinels are encountered. +final class StreamingShellHandler: ChannelInboundHandler { + typealias InboundIn = SSHChannelData + + struct Pending { + let sentinel: String + let promise: EventLoopPromise + var buffer: String = "" + } + + private var queue: [Pending] = [] + private var hasReceivedAnyData = false + private var totalDataReceived = 0 + private var greetingStripped = false + + /// Called by ChannelExecutor when a new command is queued. + func addCommand(sentinel: String, promise: EventLoopPromise) { + queue.append(Pending(sentinel: sentinel, promise: promise)) + } + + func handlerAdded(context: ChannelHandlerContext) { + // Handler is ready + } + + func channelActive(context: ChannelHandlerContext) { + // Channel setup logged by SSHClient + context.fireChannelActive() + } + + func channelInactive(context: ChannelHandlerContext) { + if !queue.isEmpty { + sshLog("🔍 StreamingShellHandler: ❌ Channel closed with \(queue.count) pending commands – failing them") + for pending in queue { + pending.promise.fail(SSHError.channelError("Channel closed unexpectedly")) + } + queue.removeAll() + } + context.fireChannelInactive() + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + if !hasReceivedAnyData { + hasReceivedAnyData = true + // Data flow is implied by successful commands; no need to log + } + + let payload = unwrapInboundIn(data) + + guard case .byteBuffer(let buf) = payload.data, + let string = buf.getString(at: 0, length: buf.readableBytes) else { + return + } + + totalDataReceived += string.count + + // Handle stderr separately + if payload.type == .stdErr { + sshLog("🔍 StreamingShellHandler: ❌ Stderr: '\(string.trimmingCharacters(in: .whitespacesAndNewlines).prefix(120))'") + // If we have a pending command and receive stderr, it's likely an error + if !queue.isEmpty { + let pending = queue.removeFirst() + pending.promise.fail(SSHError.channelError("AppleScript stderr: \(string.trimmingCharacters(in: .whitespacesAndNewlines))")) + } + return + } + + guard !queue.isEmpty else { + return + } + + var incoming = string + + // One-time removal of typical shell / osascript greetings to avoid corrupting the first command's buffer. + if !greetingStripped { + greetingStripped = true + let greetingPatterns = [ + "Welcome to fish", + "Type 'help' for instructions", + "Last login", + "osascript -e" + ] + // Remove any lines containing the greeting patterns + let filteredLines = incoming + .components(separatedBy: .newlines) + .filter { line in !greetingPatterns.contains(where: { pattern in line.contains(pattern) }) } + incoming = filteredLines.joined(separator: "\n") + } + + queue[0].buffer += incoming + + // Process the buffer to look for the sentinel + let currentBuffer = queue[0].buffer + let expectedSentinel = queue[0].sentinel + + // Check if buffer is getting too large (possible stuck command) + if currentBuffer.count > 100000 { + sshLog("🔍 StreamingShellHandler: ⚠️ Buffer overflow - command may be stuck") + let pending = queue.removeFirst() + pending.promise.fail(SSHError.channelError("Buffer overflow - response too large")) + context.close(promise: nil) + return + } + + // Try both formats: + // 1. Interactive osascript format: => "sentinel" + // 2. Direct AppleScript result format with our sentinel + let osascriptSentinelPattern = "=> \"\(expectedSentinel)\"" + let directSentinelPattern = "=> \"\(expectedSentinel)\"" // Same as osascript pattern + + var sentinelRange: Range? + var isOsascriptFormat = false + + // Check for osascript format first + if let range = currentBuffer.range(of: osascriptSentinelPattern) { + sentinelRange = range + isOsascriptFormat = true + } else if let range = currentBuffer.range(of: directSentinelPattern, options: .backwards) { + // This is also an AppleScript result format + sentinelRange = range + isOsascriptFormat = true + } + + if let sentinelRange = sentinelRange { + // Found the sentinel - extract the output before it + let outputPart = String(currentBuffer[.. "result" lines + let (result, isError) = extractAppleScriptResult(from: outputPart) + if isError { + // Complete with error + let pending = queue.removeFirst() + pending.promise.fail(SSHError.channelError("AppleScript error: \(result)")) + context.close(promise: nil) + return + } + scriptOutput = result + } else { + // Parse shell/mixed output - look for AppleScript results or clean shell output + scriptOutput = extractCleanOutput(from: outputPart) + } + + // Complete the promise with the parsed output + let pending = queue.removeFirst() + pending.promise.succeed(scriptOutput) + } + } + + /// Extract clean AppleScript result from => "result" format + /// Returns (result, isError) tuple + private func extractAppleScriptResult(from output: String) -> (String, Bool) { + let lines = output.components(separatedBy: .newlines) + + // Look for the last meaningful result line before the sentinel + for line in lines.reversed() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check for AppleScript error indicators + if trimmed.hasPrefix("!!") || + trimmed.contains("error \"") || + trimmed.contains("can't go here") || + trimmed.contains("is not defined") || + trimmed.contains("doesn't understand") || + trimmed.contains("Can't get") || + trimmed.contains("Can't make") { + // This is an error message + return (trimmed, true) + } + + if trimmed.hasPrefix("=> ") { + let content = String(trimmed.dropFirst(3)) + // Check if the result itself is an error + if content.hasPrefix("!!") || + content.contains("error \"") || + content.contains("can't go here") || + content.contains("is not defined") { + return (content, true) + } + + // Remove surrounding quotes if present + var unquoted = content + if unquoted.hasPrefix("\"") && unquoted.hasSuffix("\"") && unquoted.count > 1 { + unquoted = String(unquoted.dropFirst().dropLast()) + } + + // Prefer lines containing our status separator or booleans / numbers + if unquoted.contains("|||") || unquoted == "true" || unquoted == "false" || Int(unquoted) != nil { + return (unquoted, false) + } + + // Skip noisy "set ..." echoes from the interpreter + if unquoted.hasPrefix("set ") { + continue + } + + return (unquoted, false) + } + } + + // If no => format found, look for any meaningful result + for line in lines.reversed() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + + // Skip noise and check for errors + if !trimmed.isEmpty && + !trimmed.hasPrefix("?>") && + !trimmed.hasPrefix(">>") && + !trimmed.hasPrefix(">") && + !trimmed.contains("ryan@") && + !trimmed.hasPrefix("[") && + !trimmed.hasPrefix("]") && + !trimmed.contains("Welcome to fish") && + !trimmed.hasPrefix("tell ") && + !trimmed.hasPrefix("end tell") && + trimmed.count < 200 { // Avoid very long output + + // Check for error indicators + if trimmed.hasPrefix("!!") || + trimmed.contains("error") || + trimmed.contains("can't") || + trimmed.contains("is not defined") { + return (trimmed, true) + } + + // Strip leading quote if present (common in AppleScript results) + if trimmed.hasPrefix("\"") { + return (String(trimmed.dropFirst()), false) + } + return (trimmed, false) + } + } + return ("", false) + } + + /// Extract clean output from mixed shell/AppleScript output + private func extractCleanOutput(from output: String) -> String { + let lines = output.components(separatedBy: .newlines) + + // First, look for AppleScript result lines (checking in reverse order for last result) + for line in lines.reversed() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("=> \"") && trimmed.hasSuffix("\"") { + // Extract content between quotes + return String(trimmed.dropFirst(3).dropLast()) + } else if trimmed.hasPrefix("=> ") { + // Extract content after => + return String(trimmed.dropFirst(3)) + } + } + + // If no AppleScript result, find any meaningful output + for line in lines.reversed() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + + // Skip all the noise - but be more precise about what we filter + if trimmed.isEmpty || + trimmed.hasPrefix("?>") || + trimmed.hasPrefix(">>") || + trimmed.hasPrefix(">") || + trimmed.contains("ryan@") || + trimmed.hasPrefix("]") || + trimmed.hasPrefix("[") || + trimmed.contains("Welcome to fish") || + trimmed.contains("Type help") || + trimmed.contains("Last login") || + trimmed.hasPrefix("/bin/sh") || + trimmed.hasPrefix("bash -c") || + trimmed.hasPrefix("tell ") || + trimmed.hasPrefix("end tell") || + trimmed.contains("APPLESCRIPT") { + continue + } + + // Take the first (latest) clean line we find + if trimmed.count < 200 { // Avoid very long output + // Strip leading quote if present (common in AppleScript results) + if trimmed.hasPrefix("\"") { + return String(trimmed.dropFirst()) + } + return trimmed + } + } + + return "" + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + // Suppress noisy tcpShutdown messages – they occur during orderly disconnects. + if String(describing: error).contains("tcpShutdown") { + sshLog("🔍 StreamingShellHandler: ℹ️ Channel closed (tcpShutdown)") + } else { + sshLog("🔍 StreamingShellHandler: ❌ Error caught: \(error)") + } + if let pending = queue.first { + print("🔍 StreamingShellHandler: Failing pending promise due to error") + pending.promise.fail(error) + queue.removeAll() + } + context.close(promise: nil) + } +} diff --git a/Control/Utilities/ShellCommandUtilities.swift b/Control/Utilities/ShellCommandUtilities.swift index 060abda..783dbcd 100644 --- a/Control/Utilities/ShellCommandUtilities.swift +++ b/Control/Utilities/ShellCommandUtilities.swift @@ -13,20 +13,9 @@ struct ShellCommandUtilities { .replacingOccurrences(of: "`", with: "\\`") // Escape command substitution } - /// Wraps an AppleScript command in a bash command that will work regardless of the user's default shell - /// - Parameter appleScript: The AppleScript code to execute - /// - Returns: A bash command string that safely executes the AppleScript - static func wrapAppleScriptForBash(_ appleScript: String) -> String { - let escapedScript = escapeBashString(appleScript) - - return """ - bash -c "osascript << 'APPLESCRIPT' - try - \(escapedScript) - on error errMsg - return errMsg - end try - APPLESCRIPT" - """ + /// Returns raw AppleScript intended to be streamed into a long-lived `osascript -` process. + /// Nothing is escaped or wrapped — the caller is responsible for adding any sentinel afterwards. + static func appleScriptForStreaming(_ appleScript: String) -> String { + return appleScript } } diff --git a/Control/Utilities/String+Extensions.swift b/Control/Utilities/String+Extensions.swift new file mode 100644 index 0000000..cefb836 --- /dev/null +++ b/Control/Utilities/String+Extensions.swift @@ -0,0 +1,11 @@ +import Foundation + +extension String { + /// Returns a redacted version of the string for logging purposes. + /// Example: "My Private Video" becomes "My P***" + func redacted() -> String { + guard !self.isEmpty else { return "" } + let prefixLength = min(2, self.count) + return String(self.prefix(prefixLength)) + "***" + } +} diff --git a/Control/ViewModels/ConnectionsViewModel.swift b/Control/ViewModels/ConnectionsViewModel.swift index 6c0c86e..8b8c19a 100644 --- a/Control/ViewModels/ConnectionsViewModel.swift +++ b/Control/ViewModels/ConnectionsViewModel.swift @@ -276,15 +276,15 @@ class ConnectionsViewModel: ObservableObject { } private func navigateToApp(computer: Connection) { - viewLog("ConnectionsViewModel: Navigating to app", view: "ConnectionsViewModel") + viewLog("⛵︎ Navigating to app", view: "ConnectionsViewModel") selectedConnection = computer if !savedConnections.hasConnectedBefore(computer.host) { - viewLog("First time setup needed - navigating to SetupFlowView", view: "ConnectionsViewModel") + viewLog("⎈ First time setup needed - navigating to SetupFlowView", view: "ConnectionsViewModel") showingSetupFlow = true } else { - viewLog("Regular connection - navigating to ControlView", view: "ConnectionsViewModel") + viewLog("⛵︎ navigating to ControlView", view: "ConnectionsViewModel") navigateToControl = true } diff --git a/Control/Views/ConnectionsView.swift b/Control/Views/ConnectionsView.swift index 26e0ee2..3506e92 100644 --- a/Control/Views/ConnectionsView.swift +++ b/Control/Views/ConnectionsView.swift @@ -121,7 +121,11 @@ struct ConnectionsView: View { .tint(UserPreferences.shared.tintColorValue) } .environmentObject(viewModel) - .onAppear(perform: viewModel.onAppear) + .onAppear { + // Root views don't disappear, so these only run on app open or foreground + SSHConnectionManager.shared.disconnect() + viewModel.onAppear() + } .onDisappear(perform: viewModel.onDisappear) .onChange(of: scenePhase) { oldPhase, newPhase in viewModel.handleScenePhaseChange(from: oldPhase, to: newPhase) @@ -132,8 +136,14 @@ struct ConnectionsView: View { viewModel.checkForRescanOnForeground() } } - .onChange(of: viewModel.navigateToControl) { _, newValue in - if !newValue { + .onChange(of: viewModel.navigateToControl) { oldVal, newVal in + // When we return from ControlView to the connections list, tear down any live SSH session + if oldVal == true && newVal == false { + SSHConnectionManager.shared.disconnect() + viewLog("ConnectionsView: navigateToControl -> false, disconnected active SSH session", view: "ConnectionsView") + } + + if !newVal { viewModel.connectingComputer = nil viewModel.selectedConnection = nil } @@ -250,7 +260,7 @@ private struct SetupFlowDestination: View { password: viewModel.password, isReconfiguration: false, onComplete: { - viewLog("ConnectionsView: First-time setup completed, navigating to ControlView", view: "ConnectionsView") + viewLog("⛵︎ First-time setup completed, navigating to ControlView", view: "ConnectionsView") viewModel.showingSetupFlow = false viewModel.navigateToControl = true } diff --git a/Control/Views/ControlView.swift b/Control/Views/ControlView.swift index 114704e..e80b6e9 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -22,7 +22,8 @@ struct ControlView: View, SSHConnectedView { @State private var volume: Float = 0.5 @State private var volumeInitialized: Bool = false @State private var errorMessage: String? - @State private var volumeChangeWorkItem: DispatchWorkItem? + @State private var volumeChangeWorkItem: DispatchWorkItem? // unused but keep for other logic + @State private var lastVolumeCommandDate: Date = .distantPast @State private var isReady: Bool = false @State private var shouldShowLoadingOverlay: Bool = false @State private var _showingConnectionLostAlert = false @@ -40,10 +41,14 @@ struct ControlView: View, SSHConnectedView { // MARK: - SSH Connection Callbacks func onSSHConnected() { - Task { await appController.updateAllStates() } + Task { + await appController.reset() + await appController.updateAllStates() + connectionManager.startHeartbeat() + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.025) { isReady = true - viewLog("ControlView: Ready state activated", view: "ControlView") + viewLog("Ready state activated", view: "ControlView") } } @@ -74,14 +79,14 @@ struct ControlView: View, SSHConnectedView { if currentPlatforms.isEmpty { let defaultRegistry = PlatformRegistry() currentPlatforms = defaultRegistry.enabledPlatforms - viewLog("ControlView: No saved platforms for host, using defaults: \(currentPlatforms)", view: "ControlView") + viewLog("No saved platforms for host, using defaults: \(currentPlatforms)", view: "ControlView") } // Create new registry with all platforms, but update enabled platforms let newRegistry = PlatformRegistry() newRegistry.enabledPlatforms = currentPlatforms - viewLog("ControlView: Updating AppController with \(newRegistry.activePlatforms.count) active platforms: \(newRegistry.activePlatforms.map { $0.name })", view: "ControlView") + viewLog("Updating AppController with \(newRegistry.activePlatforms.count) active platforms: \(newRegistry.activePlatforms.map { $0.name })", view: "ControlView") // Update the AppController's platform registry appController.updatePlatformRegistry(newRegistry) @@ -116,6 +121,12 @@ struct ControlView: View, SSHConnectedView { .onChange(of: selectedPlatformIndex) { _, newValue in if let platform = appController.platforms[safe: newValue] { savedConnections.updateLastViewedPlatform(host, platform: platform.id) + + if appController.hasCompletedInitialUpdate { + Task { + await appController.updateState(for: platform) + } + } } } Spacer() @@ -140,13 +151,22 @@ struct ControlView: View, SSHConnectedView { set: { newValue in if volumeInitialized { volume = Float(newValue) - debounceVolumeChange() + let now = Date() + if now.timeIntervalSince(lastVolumeCommandDate) > 0.4 { + lastVolumeCommandDate = now + Task { await appController.setVolume(volume) } + } } } ), in: 0...1, step: 0.01, - onEditingChanged: { _ in } + onEditingChanged: { isEditing in + if !isEditing && volumeInitialized { + // Send the final value immediately + Task { await appController.setVolume(volume) } + } + } ) .accessibilityLabel("Volume Slider") .accessibilityValue("system volume \(Int(volume * 100))%") @@ -164,6 +184,7 @@ struct ControlView: View, SSHConnectedView { .disabled(!volumeInitialized) } } + .padding() .frame(maxWidth: 500, maxHeight: isPhoneLandscape ? 10 : nil) if !isPhoneLandscape { Spacer(minLength: 40) @@ -180,7 +201,7 @@ struct ControlView: View, SSHConnectedView { .animation(.spring(), value: connectionManager.connectionState) .allowsHitTesting(connectionManager.connectionState == .connected) } - .padding() + .padding(.vertical) .navigationTitle("") .toolbarTitleDisplayMode(.inline) .toolbarRole(.editor) @@ -236,7 +257,7 @@ struct ControlView: View, SSHConnectedView { } } .onAppear { - viewLog("ControlView: View appeared", view: "ControlView") + viewLog("View appeared", view: "ControlView") viewLog("Enabled platforms: \(enabledPlatforms)", view: "ControlView") viewLog("Connection manager state: \(connectionManager.connectionState)", view: "ControlView") @@ -255,54 +276,43 @@ struct ControlView: View, SSHConnectedView { viewLog("No previous platform preference, using default index 0", view: "ControlView") } } - .navigationDestination(isPresented: $showingSetupFlow) { - SetupFlowView( - host: host, - displayName: displayName, - username: username, - password: password, - isReconfiguration: true, - onComplete: { - showingSetupFlow = false - } - ) - .environmentObject(savedConnections) - } .onChange(of: scenePhase) { oldPhase, newPhase in handleScenePhaseChange(from: oldPhase, to: newPhase) } .onDisappear { - viewLog("ControlView: View disappeared", view: "ControlView") + viewLog("View disappeared", view: "ControlView") Task { @MainActor in appController.cleanup() } } .onReceive(appController.$currentVolume) { newVolume in if let newVolume = newVolume { - viewLog("ControlView: Volume updated to \(Int(newVolume * 100))%", view: "ControlView") + viewLog("Volume updated to \(Int(newVolume * 100))%", view: "ControlView") volumeInitialized = true volume = newVolume } else { - viewLog("ControlView: Volume became nil - controls will be disabled", view: "ControlView") + viewLog("Volume became nil - controls will be disabled", view: "ControlView") } } .onReceive(appController.$isActive) { isActive in - viewLog("ControlView: AppController active state changed to \(isActive)", view: "ControlView") + viewLog("AppController active state changed to \(isActive)", view: "ControlView") if !isActive { - viewLog("🚨 ControlView: AppController became inactive - connection likely lost", view: "ControlView") + viewLog("🚨 AppController became inactive - connection likely lost", view: "ControlView") } } .onReceive(connectionManager.$connectionState) { connectionState in - viewLog("ControlView: Connection state changed to \(connectionState)", view: "ControlView") + viewLog("Connection state changed to \(connectionState)", view: "ControlView") switch connectionState { case .disconnected: - viewLog("🚨 ControlView: Connection is disconnected", view: "ControlView") + viewLog("🚨Connection is disconnected", view: "ControlView") case .connecting: - viewLog("ControlView: Currently connecting...", view: "ControlView") + viewLog("⚯ Currently connecting...", view: "ControlView") + case .recovering: + viewLog("⚯ Recovering connection...", view: "ControlView") case .connected: - viewLog("✓ ControlView: Connection established", view: "ControlView") + viewLog("⚭ Connection established", view: "ControlView") case .failed(let error): - viewLog("❌ ControlView: Connection failed: \(error)", view: "ControlView") + viewLog("❌ Connection failed: \(error)", view: "ControlView") } } .alert("Connection Lost", isPresented: showingConnectionLostAlert) { @@ -319,6 +329,19 @@ struct ControlView: View, SSHConnectedView { .sheet(isPresented: $showingDebugLogs) { DebugLogsView(isReadOnly: true) } + .navigationDestination(isPresented: $showingSetupFlow) { + SetupFlowView( + host: host, + displayName: displayName, + username: username, + password: password, + isReconfiguration: true, + onComplete: { + showingSetupFlow = false + } + ) + .environmentObject(savedConnections) + } } @@ -332,28 +355,16 @@ struct ControlView: View, SSHConnectedView { let oldVolume = Int(volume * 100) let newVolume = min(max(Int(volume * 100) + amount, 0), 100) - viewLog("ControlView: Adjusting volume by \(amount)% (\(oldVolume)% -> \(newVolume)%)", view: "ControlView") + viewLog("Adjusting volume by \(amount)% (\(oldVolume)% -> \(newVolume)%)", view: "ControlView") volume = Float(newVolume) / 100.0 - Task { - await appController.setVolume(volume) - } - } - - private func debounceVolumeChange() { - guard volumeInitialized else { - viewLog("⚠️ ControlView: Volume change attempted before initialization", view: "ControlView") - return - } - - volumeChangeWorkItem?.cancel() - let workItem = DispatchWorkItem { + let now = Date() + if now.timeIntervalSince(lastVolumeCommandDate) > 0.05 { + lastVolumeCommandDate = now Task { await appController.setVolume(volume) } } - volumeChangeWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) } } diff --git a/Control/Views/PermissionsView.swift b/Control/Views/PermissionsView.swift index afd72a5..7271df7 100644 --- a/Control/Views/PermissionsView.swift +++ b/Control/Views/PermissionsView.swift @@ -319,16 +319,6 @@ struct PermissionsView: View, SSHConnectedView { } } - func executeCommand(_ command: String, description: String? = nil) async -> Result { - let wrappedCommand = ShellCommandUtilities.wrapAppleScriptForBash(command) - - return await withCheckedContinuation { continuation in - connectionManager.executeCommand(wrappedCommand, description: description) { result in - continuation.resume(returning: result) - } - } - } - private func checkPermission(for platformId: String) async { guard let platform = PlatformRegistry.allPlatforms.first(where: { $0.id == platformId }) else { viewLog("❌ Platform not found: \(platformId)", view: "PermissionsView") @@ -344,11 +334,10 @@ struct PermissionsView: View, SSHConnectedView { activate end tell """ - let activateCommand = ShellCommandUtilities.wrapAppleScriptForBash(activateScript) viewLog("Activating \(platform.name)...", view: "PermissionsView") let activateResult = await withCheckedContinuation { continuation in - connectionManager.executeCommandWithNewChannel(activateCommand, description: "\(platform.name): activate") { result in + connectionManager.executeCommandOnDedicatedChannel(platformId, activateScript, description: "\(platform.name): activate") { result in continuation.resume(returning: result) } } @@ -364,11 +353,11 @@ struct PermissionsView: View, SSHConnectedView { try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds // Then check permissions by fetching state - let stateCommand = ShellCommandUtilities.wrapAppleScriptForBash(platform.fetchState()) + let stateScript = platform.fetchState() viewLog("Checking permissions for \(platform.name) by fetching state...", view: "PermissionsView") let stateResult = await withCheckedContinuation { continuation in - connectionManager.executeCommandWithNewChannel(stateCommand, description: "\(platform.name): fetch status") { result in + connectionManager.executeCommandOnDedicatedChannel(platformId, stateScript, description: "\(platform.name): fetch status") { result in continuation.resume(returning: result) } } @@ -396,7 +385,7 @@ struct PermissionsView: View, SSHConnectedView { viewLog("Retry attempt \(attempts + 1) for \(platform.name)", view: "PermissionsView") let retryResult = await withCheckedContinuation { continuation in - connectionManager.executeCommandWithNewChannel(stateCommand, description: "\(platform.name): fetch status (retry \(attempts + 1))") { result in + connectionManager.executeCommandOnDedicatedChannel(platformId, stateScript, description: "\(platform.name): fetch status (retry \(attempts + 1))") { result in continuation.resume(returning: result) } }