From e0f5811e45cf722ca7bce34816a60b911b04f2f2 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Wed, 2 Jul 2025 16:12:19 -0700 Subject: [PATCH 01/25] reduce safari commands to use only one js block each --- .../Platforms/Implementations/SafariApp.swift | 83 ++++++++----------- 1 file changed, 35 insertions(+), 48 deletions(-) diff --git a/Control/Platforms/Implementations/SafariApp.swift b/Control/Platforms/Implementations/SafariApp.swift index 208d3bb..02276f4 100644 --- a/Control/Platforms/Implementations/SafariApp.swift +++ b/Control/Platforms/Implementations/SafariApp.swift @@ -26,29 +26,28 @@ struct SafariApp: AppPlatform { private let statusScript = """ tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then - return "Nothing playing ||| ||| false ||| false" - end if - - -- 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 " + set windowCount to count of windows + if windowCount is 0 then + return "Nothing playing ||| ||| false ||| false" + end if + try + set currentTab to current tab of window 1 + set videoScript to " + (function() { var video = document.querySelector('video'); - var title = document.title || 'Unknown Video'; + if (!video) return 'Nothing playing ||| ||| false ||| false'; + var title = document.title.replace(' - YouTube', '') || '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 - end if - end try - - return "Nothing playing ||| ||| false ||| false" + + return title + ' ||| ' + siteName + ' ||| ' + (isPlaying ? 'true' : 'false') + ' ||| ' + (isPlaying ? 'true' : 'false'); + })(); + " + set videoInfo to do JavaScript videoScript in currentTab + return videoInfo + end try + + return "Nothing playing ||| ||| false ||| false" end tell """ @@ -87,13 +86,10 @@ struct SafariApp: AppPlatform { 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 " + do JavaScript " + (function() { var video = document.querySelector('video'); if (video) { if (video.paused || video.ended) { @@ -102,8 +98,8 @@ struct SafariApp: AppPlatform { video.pause(); } } - " in currentTab - end if + })(); + " in currentTab end try end tell """ @@ -112,19 +108,14 @@ struct SafariApp: AppPlatform { 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 + do JavaScript " + (function() { + const media = document.querySelector('video, audio'); + if (media) media.currentTime += \(seconds); + })(); + " in currentTab end try end tell """ @@ -134,18 +125,14 @@ struct SafariApp: AppPlatform { 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 + do JavaScript " + (function() { + const media = document.querySelector('video, audio'); + if (media) media.currentTime -= \(seconds); + })(); + " in currentTab end try end tell """ From b056fe4a8f93fcd0a15344e1e1486fef56c7c5b6 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Wed, 2 Jul 2025 21:13:17 -0700 Subject: [PATCH 02/25] avoid duplicate batch status updates --- Control.xcodeproj/project.pbxproj | 4 +- Control/Platforms/AppController.swift | 114 ++++++++++++++++++++----- Control/SSH/SSHClientProtocol.swift | 5 ++ Control/SSH/SSHConnectionManager.swift | 9 +- Control/Views/ControlView.swift | 32 ++++--- 5 files changed, 122 insertions(+), 42 deletions(-) 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/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 42d7b57..57db2c4 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -8,6 +8,12 @@ class AppController: ObservableObject { @Published var isActive = true static var debugMode = true // Add debug flag for troubleshooting + // Add batch operation flag to reduce heartbeats + private var isBatchOperation = false + + // Track initial comprehensive update completion + @Published var hasCompletedInitialUpdate = false + @Published var states: [String: AppState] = [:] @Published var lastKnownStates: [String: AppState] = [:] @Published var currentVolume: Float? @@ -33,6 +39,7 @@ class AppController: ObservableObject { appControllerLog("AppController: Resetting state") isActive = true isUpdating = false + hasCompletedInitialUpdate = false // Don't reset states - they'll update naturally when we get new data } @@ -82,6 +89,13 @@ class AppController: ObservableObject { return } + // Mark as batch operation to reduce heartbeats + isBatchOperation = true + defer { + isBatchOperation = false + appControllerLog("βœ“ State update complete") + } + // Update system volume first await updateSystemVolume() @@ -96,7 +110,29 @@ class AppController: ObservableObject { let isRunning = await checkIfRunning(platform) if isRunning { appControllerLog("βœ“ \(platform.name) is running, fetching state") - await updateState(for: platform) + // Directly fetch state since we already know it's running + let result = await executeCommand(platform.fetchState(), description: "\(platform.name): fetch status") + + switch result { + case .success(let output): + if output.contains("Not authorized to send Apple events") { + let newState = AppState( + title: "Permissions Required", + subtitle: "Grant permission in System Settings > Privacy > Automation", + isPlaying: nil, + error: nil + ) + updateStateIfChanged(platform.id, newState) + } else { + let newState = platform.parseState(output) + updateStateIfChanged(platform.id, newState) + } + case .failure(let error): + var currentState = states[platform.id] ?? AppState(title: "", subtitle: "error") + currentState.error = error.localizedDescription + states[platform.id] = currentState + lastKnownStates[platform.id] = currentState + } } else { appControllerLog("\(platform.name) is not running") let newState = AppState( @@ -109,7 +145,12 @@ class AppController: ObservableObject { } } - appControllerLog("βœ“ State update complete") + // Send single verification heartbeat at end of batch operation + if isActive { + appControllerLog("πŸ“¦ Batch operation complete, sending single verification heartbeat") + _ = await executeCommand("true", description: "Batch operation verification") + hasCompletedInitialUpdate = true + } } func updateState(for platform: any AppPlatform) async { @@ -217,10 +258,10 @@ class AppController: ObservableObject { let combinedScript = """ try \(actionScript) - delay 0.1 + delay 0.03 \(statusScript) on error errMsg - delay 0.1 + delay 0.03 \(statusScript) end try """ @@ -346,27 +387,54 @@ 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)") + // Use heartbeat-optimized execution during batch operations + if self.isBatchOperation && !(description?.contains("verification") ?? false) { + // During batch operations, skip individual heartbeats except for verification commands + self.sshClient.executeCommandBypassingHeartbeat(wrappedCommand, description: description) { result in + switch result { + case .success(let output): + appControllerLog("βœ“ Command executed successfully (batch mode)") + if !output.isEmpty { + appControllerLog("Command output: \(output)") + } + continuation.resume(returning: result) + case .failure(let error): + appControllerLog("❌ Command 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 + connectionManager.handleConnectionLost() + } + + continuation.resume(returning: result) } - continuation.resume(returning: result) - case .failure(let error): - appControllerLog("❌ Command 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() + } + } else { + // 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)") + + // 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() + } + + continuation.resume(returning: result) } - - continuation.resume(returning: result) } } } diff --git a/Control/SSH/SSHClientProtocol.swift b/Control/SSH/SSHClientProtocol.swift index 3d82f93..56ad88c 100644 --- a/Control/SSH/SSHClientProtocol.swift +++ b/Control/SSH/SSHClientProtocol.swift @@ -4,6 +4,7 @@ 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) + func executeCommandBypassingHeartbeat(_ command: String, description: String?, completion: @escaping (Result) -> Void) } // Default implementation for optional description parameter @@ -11,4 +12,8 @@ extension SSHClientProtocol { func executeCommandWithNewChannel(_ command: String, completion: @escaping (Result) -> Void) { executeCommandWithNewChannel(command, description: nil, completion: completion) } + + func executeCommandBypassingHeartbeat(_ command: String, completion: @escaping (Result) -> Void) { + executeCommandBypassingHeartbeat(command, description: nil, completion: completion) + } } diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 33be8b8..1a67929 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -409,6 +409,11 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { executeCommand(command, description: description, completion: completion) } + /// Execute command directly without heartbeat verification (used for batch operations) + nonisolated func executeCommandBypassingHeartbeat(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { + client.executeCommandBypassingHeartbeat(command, description: description, completion: completion) + } + // MARK: - SSHClientProtocol Conformance /// Protocol-required connect method with completion handler @@ -422,8 +427,4 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } } } - - - - // executeCommandWithNewChannel is already implemented above with heartbeat protection } diff --git a/Control/Views/ControlView.swift b/Control/Views/ControlView.swift index 114704e..0c0b7ff 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -116,6 +116,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() @@ -255,19 +261,6 @@ 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) } @@ -319,6 +312,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) + } } From ece1d2a09b1579aacc05aaeee24e46f8f4eed82d Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Wed, 2 Jul 2025 21:56:04 -0700 Subject: [PATCH 03/25] add Icon Composer icon for iOS 26 --- Control/AppIcon.icon/Assets/pause.svg | 3 + Control/AppIcon.icon/Assets/play.svg | 3 + Control/AppIcon.icon/icon.json | 92 +++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 Control/AppIcon.icon/Assets/pause.svg create mode 100644 Control/AppIcon.icon/Assets/play.svg create mode 100644 Control/AppIcon.icon/icon.json 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 From c3299a490fcbf57248c67c681e84d029b6d8c171 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Wed, 2 Jul 2025 21:56:37 -0700 Subject: [PATCH 04/25] Remove padding that cut off edge of control screen --- Control/Views/ControlView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Control/Views/ControlView.swift b/Control/Views/ControlView.swift index 0c0b7ff..b24be57 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -170,6 +170,7 @@ struct ControlView: View, SSHConnectedView { .disabled(!volumeInitialized) } } + .padding() .frame(maxWidth: 500, maxHeight: isPhoneLandscape ? 10 : nil) if !isPhoneLandscape { Spacer(minLength: 40) @@ -186,7 +187,7 @@ struct ControlView: View, SSHConnectedView { .animation(.spring(), value: connectionManager.connectionState) .allowsHitTesting(connectionManager.connectionState == .connected) } - .padding() + .padding(.vertical) .navigationTitle("") .toolbarTitleDisplayMode(.inline) .toolbarRole(.editor) From e04c95fa2ce81ff335f971f29661ce4aa5d5a879 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Wed, 2 Jul 2025 22:56:20 -0700 Subject: [PATCH 05/25] speed up platform checks, volume changes, work on adaptive timeout --- Control/Platforms/AppController.swift | 64 ++++++++------------------ Control/SSH/SSHClient.swift | 30 ++++++++++-- Control/SSH/SSHConnectionManager.swift | 18 ++++++++ Control/SSH/SSHViewSupport.swift | 24 ++++++---- Control/Views/ControlView.swift | 43 +++++++++-------- 5 files changed, 102 insertions(+), 77 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 57db2c4..96a2393 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -18,6 +18,9 @@ class AppController: ObservableObject { @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] = [:] + var platforms: [any AppPlatform] { platformRegistry.activePlatforms } @@ -91,57 +94,22 @@ class AppController: ObservableObject { // Mark as batch operation to reduce heartbeats isBatchOperation = true - defer { + defer { isBatchOperation = false appControllerLog("βœ“ State update complete") } - // Update system volume first + // Update system volume first (sequential – very fast) await updateSystemVolume() - // Then check which apps are running - for platform in platforms { - guard isActive else { - appControllerLog("⚠️ Controller became inactive during updates, stopping") - break - } - - appControllerLog("Checking platform: \(platform.name)") - let isRunning = await checkIfRunning(platform) - if isRunning { - appControllerLog("βœ“ \(platform.name) is running, fetching state") - // Directly fetch state since we already know it's running - let result = await executeCommand(platform.fetchState(), description: "\(platform.name): fetch status") - - switch result { - case .success(let output): - if output.contains("Not authorized to send Apple events") { - let newState = AppState( - title: "Permissions Required", - subtitle: "Grant permission in System Settings > Privacy > Automation", - isPlaying: nil, - error: nil - ) - updateStateIfChanged(platform.id, newState) - } else { - let newState = platform.parseState(output) - updateStateIfChanged(platform.id, newState) - } - case .failure(let error): - var currentState = states[platform.id] ?? AppState(title: "", subtitle: "error") - currentState.error = error.localizedDescription - states[platform.id] = currentState - lastKnownStates[platform.id] = currentState + // Fetch states for every platform in parallel so the phone waits for + // just the slowest one instead of all in sequence. + 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) } - } else { - appControllerLog("\(platform.name) is not running") - let newState = AppState( - title: "Not running", - subtitle: "", - isPlaying: nil, - error: nil - ) - updateStateIfChanged(platform.id, newState) } } @@ -156,6 +124,12 @@ class AppController: ObservableObject { func updateState(for platform: any AppPlatform) async { guard isActive else { return } + // Prevent duplicate refreshes within 2 s + if let last = lastStateRefresh[platform.id], Date().timeIntervalSince(last) < 2 { + return + } + lastStateRefresh[platform.id] = Date() + let isRunning = await checkIfRunning(platform) guard isRunning else { let newState = AppState( @@ -258,10 +232,8 @@ class AppController: ObservableObject { let combinedScript = """ try \(actionScript) - delay 0.03 \(statusScript) on error errMsg - delay 0.03 \(statusScript) end try """ diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 5af7c7c..df5f786 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -21,6 +21,9 @@ class SSHClient: SSHClientProtocol { private var authDelegate: PasswordAuthDelegate? private var hasCompletedConnection = false + // Adaptive timeout rolling averages keyed by command description + private static var commandAverages: [String: Double] = [:] + init() { self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) } @@ -244,6 +247,7 @@ class SSHClient: SSHClientProtocol { let promise = connection.eventLoop.makePromise(of: Channel.self) sshLog("Creating SSH session...") + let start = Date() // track duration for adaptive timeout updates connection.pipeline.handler(type: NIOSSHHandler.self).flatMap { handler -> EventLoopFuture in handler.createChannel(promise) { channel, channelType in guard channelType == .session else { @@ -265,6 +269,8 @@ class SSHClient: SSHClientProtocol { sshLog("❌ SSH session creation failed: \(error)") completion(.failure(self?.processError(error) ?? error)) } + + // (Adaptive timeout not tracked for session creation) } } @@ -330,6 +336,7 @@ class SSHClient: SSHClientProtocol { let childPromise = connection.eventLoop.makePromise(of: Channel.self) var commandChannel: Channel? + let start = Date() // track duration for adaptive timeout updates connection.pipeline.handler(type: NIOSSHHandler.self).flatMap { sshHandler -> EventLoopFuture in sshHandler.createChannel(childPromise) { (childChannel: Channel, channelType: SSHChannelType) -> EventLoopFuture in guard channelType == .session else { @@ -371,8 +378,12 @@ class SSHClient: SSHClientProtocol { 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 + // Adaptive timeout: rolling average * 3, min 2, max 8 + let key = commandDesc.prefix(40).description + let average = SSHClient.commandAverages[key] ?? 0.5 + var timeoutSeconds = max(average * 3.0, 2.0) + timeoutSeconds = min(timeoutSeconds, 8.0) + if command.contains("heartbeat-") { timeoutSeconds = 3 } channel.eventLoop.scheduleTask(in: .seconds(Int64(timeoutSeconds))) { if let pendingPromise = handler.pendingCommandPromise { sshLog("⏰ Command execution timed out after \(timeoutSeconds) seconds") @@ -412,6 +423,14 @@ class SSHClient: SSHClientProtocol { completion(.failure(error)) } } + + // Update rolling average on success + if case .success = result { + let duration = Date().timeIntervalSince(start) + let key = commandDesc.prefix(40).description + let prev = SSHClient.commandAverages[key] ?? duration + SSHClient.commandAverages[key] = prev * 0.7 + duration * 0.3 + } } } @@ -501,7 +520,12 @@ class SSHCommandHandler: ChannelInboundHandler { func channelInactive(context: ChannelHandlerContext) { if let promise = pendingCommandPromise { - promise.fail(SSHError.channelError("Connection closed")) + if hasReceivedOutput { + promise.fail(SSHError.channelError("Connection closed")) + } else { + // No output received but channel closed cleanly – treat as success with empty output + promise.succeed("") + } pendingCommandPromise = nil } context.fireChannelInactive() diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 1a67929..51aeccb 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import Network @MainActor class SSHConnectionManager: ObservableObject, SSHClientProtocol { @@ -7,6 +8,8 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { private nonisolated let sshClient: SSHClient private var currentCredentials: Credentials? private var connectionLostHandler: (@MainActor () -> Void)? + private var pathMonitor: NWPathMonitor? + private let monitorQueue = DispatchQueue(label: "SSHPathMonitor") private var backgroundTask: UIBackgroundTaskIdentifier = .invalid static let shared = SSHConnectionManager() @@ -36,6 +39,20 @@ 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() + } + } + } + monitor.start(queue: monitorQueue) } func setConnectionLostHandler(_ handler: @escaping @MainActor () -> Void) { @@ -131,6 +148,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { deinit { sshClient.disconnect() backgroundDisconnectTimer?.cancel() + pathMonitor?.cancel() } func shouldReconnect(host: String, username: String, password: String) -> Bool { diff --git a/Control/SSH/SSHViewSupport.swift b/Control/SSH/SSHViewSupport.swift index 3046f00..452d78f 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 { Task { @MainActor in + let viewName = String(describing: Self.self) if connectionManager.connectionState == .connected { do { try await connectionManager.verifyConnectionHealth() - viewLog("βœ“ \(Self.self): Connection health verified", view: String(describing: Self.self)) + viewLog("βœ“ \(viewName): Connection health verified", view: viewName) } catch { - viewLog("❌ \(Self.self): Connection health check failed: \(error)", view: String(describing: Self.self)) + viewLog("❌ \(viewName): Connection health check failed: \(error)", view: viewName) connectToSSH() } } else { @@ -78,9 +80,10 @@ 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 @@ -93,19 +96,20 @@ extension SSHConnectedView { @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/Views/ControlView.swift b/Control/Views/ControlView.swift index b24be57..3c3cf1b 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -23,6 +23,8 @@ struct ControlView: View, SSHConnectedView { @State private var volumeInitialized: Bool = false @State private var errorMessage: String? @State private var volumeChangeWorkItem: DispatchWorkItem? + @State private var volumeTask: Task? + @State private var lastVolumeCommandDate: Date = .distantPast @State private var isReady: Bool = false @State private var shouldShowLoadingOverlay: Bool = false @State private var _showingConnectionLostAlert = false @@ -146,13 +148,34 @@ struct ControlView: View, SSHConnectedView { set: { newValue in if volumeInitialized { volume = Float(newValue) - debounceVolumeChange() + + // Throttle to at most once every 0.3 s AND ensure only one command is in-flight. + let now = Date() + if now.timeIntervalSince(lastVolumeCommandDate) > 0.2 && volumeTask == nil { + lastVolumeCommandDate = now + volumeTask = Task { + await appController.setVolume(volume) + // Mark task finished so next update can fire + await MainActor.run { volumeTask = nil } + } + } } } ), in: 0...1, step: 0.01, - onEditingChanged: { _ in } + onEditingChanged: { isEditing in + if !isEditing && volumeInitialized { + // Send a final command with the last slider position, but wait for + // any running volumeTask to complete first to avoid overlap. + Task { + if let currentTask = volumeTask { + _ = await currentTask.result // wait for completion + } + await appController.setVolume(volume) + } + } + } ) .accessibilityLabel("Volume Slider") .accessibilityValue("system volume \(Int(volume * 100))%") @@ -346,22 +369,6 @@ struct ControlView: View, SSHConnectedView { 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 { - Task { - await appController.setVolume(volume) - } - } - volumeChangeWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) - } } struct ControlView_Previews: PreviewProvider { From 9e5dba6cc9d3b5bd7dd6fb39c2d609a59def7fba Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Wed, 2 Jul 2025 23:36:03 -0700 Subject: [PATCH 06/25] pool commands and stop volume from triggering errors --- Control/Platforms/AppController.swift | 11 +++++----- Control/SSH/SSHClient.swift | 30 ++++++++++++++++++++++++-- Control/Views/ControlView.swift | 31 ++++++++++----------------- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 96a2393..fe356bf 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -278,8 +278,9 @@ class AppController: ObservableObject { 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)" + let result = await executeCommand(script, description: "System: set volume(\(target))", bypassHeartbeat: true) switch result { case .success(let output): @@ -339,7 +340,7 @@ class AppController: ObservableObject { } // Keep this simpler version for single commands (permissions checks, etc) - private func executeCommand(_ command: String, description: String? = nil) async -> Result { + private func executeCommand(_ command: String, description: String? = nil, bypassHeartbeat: Bool = false) async -> Result { if let description = description { appControllerLog("Executing command: \(description)") } else { @@ -360,8 +361,8 @@ class AppController: ObservableObject { } // Use heartbeat-optimized execution during batch operations - if self.isBatchOperation && !(description?.contains("verification") ?? false) { - // During batch operations, skip individual heartbeats except for verification commands + if self.isBatchOperation || bypassHeartbeat { + // During batch operations or when bypass explicitly requested, skip heartbeats self.sshClient.executeCommandBypassingHeartbeat(wrappedCommand, description: description) { result in switch result { case .success(let output): diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index df5f786..a613f37 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -13,6 +13,24 @@ enum SSHError: Error { case noSession } +// Concurrency limiter for simultaneous SSH channels +actor ChannelLimiter { + private let maxConcurrent: Int + private var current: Int = 0 + init(max: Int) { self.maxConcurrent = max } + func acquire() async { + while current >= maxConcurrent { + await Task.yield() + } + current += 1 + } + func release() { + current = max(0, current - 1) + } +} + +// Shared global limiter – tweak `max` if the server allows more channels +private let sshChannelLimiter = ChannelLimiter(max: 4) class SSHClient: SSHClientProtocol { private var group: EventLoopGroup @@ -314,7 +332,15 @@ class SSHClient: SSHClientProtocol { } func executeCommandWithNewChannel(_ command: String, description: String?, completion: @escaping (Result) -> Void) { - executeCommandDirectly(command, description: description, completion: completion) + // Gate concurrent channel creations so we don't exceed the server's limit + Task { + await sshChannelLimiter.acquire() + executeCommandDirectly(command, description: description) { result in + completion(result) + // Release permit after command is done + Task { await sshChannelLimiter.release() } + } + } } /// Execute command directly without heartbeat checks - used by the heartbeat mechanism itself @@ -381,7 +407,7 @@ class SSHClient: SSHClientProtocol { // Adaptive timeout: rolling average * 3, min 2, max 8 let key = commandDesc.prefix(40).description let average = SSHClient.commandAverages[key] ?? 0.5 - var timeoutSeconds = max(average * 3.0, 2.0) + var timeoutSeconds = max(average * 3.0, command.contains("set volume") ? 1.5 : 2.0) timeoutSeconds = min(timeoutSeconds, 8.0) if command.contains("heartbeat-") { timeoutSeconds = 3 } channel.eventLoop.scheduleTask(in: .seconds(Int64(timeoutSeconds))) { diff --git a/Control/Views/ControlView.swift b/Control/Views/ControlView.swift index 3c3cf1b..c5a9c38 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -22,8 +22,7 @@ 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 volumeTask: Task? + @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 @@ -148,16 +147,10 @@ struct ControlView: View, SSHConnectedView { set: { newValue in if volumeInitialized { volume = Float(newValue) - - // Throttle to at most once every 0.3 s AND ensure only one command is in-flight. let now = Date() - if now.timeIntervalSince(lastVolumeCommandDate) > 0.2 && volumeTask == nil { + if now.timeIntervalSince(lastVolumeCommandDate) > 0.4 { lastVolumeCommandDate = now - volumeTask = Task { - await appController.setVolume(volume) - // Mark task finished so next update can fire - await MainActor.run { volumeTask = nil } - } + Task { await appController.setVolume(volume) } } } } @@ -166,14 +159,8 @@ struct ControlView: View, SSHConnectedView { step: 0.01, onEditingChanged: { isEditing in if !isEditing && volumeInitialized { - // Send a final command with the last slider position, but wait for - // any running volumeTask to complete first to avoid overlap. - Task { - if let currentTask = volumeTask { - _ = await currentTask.result // wait for completion - } - await appController.setVolume(volume) - } + // Send the final value immediately + Task { await appController.setVolume(volume) } } } ) @@ -365,8 +352,12 @@ struct ControlView: View, SSHConnectedView { viewLog("ControlView: Adjusting volume by \(amount)% (\(oldVolume)% -> \(newVolume)%)", view: "ControlView") volume = Float(newVolume) / 100.0 - Task { - await appController.setVolume(volume) + let now = Date() + if now.timeIntervalSince(lastVolumeCommandDate) > 0.3 { + lastVolumeCommandDate = now + Task { + await appController.setVolume(volume) + } } } } From 7b06d56f2d792bb619a5ee0a309462e22a3489a6 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Thu, 3 Jul 2025 01:33:25 -0700 Subject: [PATCH 07/25] update action scripts to be injected within status --- Control/Platforms/AppController.swift | 15 +- .../Platforms/Implementations/Chrome.swift | 125 ++++++++------- .../Platforms/Implementations/IINAApp.swift | 46 +++--- .../Platforms/Implementations/MusicApp.swift | 56 ++++--- .../Implementations/QuickTimeApp.swift | 89 ++++++----- .../Platforms/Implementations/SafariApp.swift | 145 +++++++++--------- .../Implementations/SpotifyApp.swift | 60 ++++---- Control/Platforms/Implementations/TVApp.swift | 141 ++++++++--------- .../Platforms/Implementations/VLCApp.swift | 111 ++++++-------- Control/Platforms/Types.swift | 1 + 10 files changed, 368 insertions(+), 421 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index fe356bf..c1e689c 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -225,18 +225,9 @@ class AppController: ObservableObject { appControllerLog("AppController: Executing action \(action) on \(platform.name)") - // Combine action and status fetch into single script - let actionScript = platform.executeAction(action) - let statusScript = platform.fetchState() - - let combinedScript = """ - try - \(actionScript) - \(statusScript) - on error errMsg - \(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))") diff --git a/Control/Platforms/Implementations/Chrome.swift b/Control/Platforms/Implementations/Chrome.swift index 50d4b91..49539c6 100644 --- a/Control/Platforms/Implementations/Chrome.swift +++ b/Control/Platforms/Implementations/Chrome.swift @@ -25,32 +25,37 @@ struct ChromeApp: AppPlatform { """ } - // 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 +82,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..9d3cf00 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -24,8 +24,11 @@ struct IINAApp: AppPlatform { """ } - private let statusScript = """ + // Template status script that can optionally inject action AppleScript + private func statusScript(actionLines: String = "") -> String { + """ tell application "System Events" + \(actionLines) set isRunning to exists (processes where name is "IINA") if not isRunning then return "Not running ||| ||| stopped |||false" @@ -74,9 +77,12 @@ struct IINAApp: AppPlatform { return cleanTitle & "||| ||| " & isPlaying & " ||| " & isPlaying end tell """ - - func fetchState() -> String { - return statusScript + } + + func fetchState() -> String { statusScript() } + + func actionWithStatus(_ action: AppAction) -> String { + statusScript(actionLines: executeAction(action)) } func parseState(_ output: String) -> AppState { @@ -102,48 +108,38 @@ struct IINAApp: AppPlatform { case .playPauseToggle: return """ tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 49 -- spacebar - end tell + tell process "IINA" + key code 49 -- spacebar end tell """ case .skipBackward: return """ tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 123 -- left arrow - end tell + tell process "IINA" + key code 123 -- left arrow end tell """ case .skipForward: return """ tell application "IINA" to activate - tell application "System Events" - tell process "IINA" - key code 124 -- right arrow - end tell + tell process "IINA" + key code 124 -- right arrow end tell """ 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 + tell process "IINA" + key code 123 using {command down} -- cmd+left end tell """ 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 + tell process "IINA" + key code 124 using {command down} -- cmd+right end tell """ - } + } } } diff --git a/Control/Platforms/Implementations/MusicApp.swift b/Control/Platforms/Implementations/MusicApp.swift index 92386fc..8adbb58 100644 --- a/Control/Platforms/Implementations/MusicApp.swift +++ b/Control/Platforms/Implementations/MusicApp.swift @@ -22,21 +22,29 @@ struct MusicApp: AppPlatform { """ } - 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 + // Template status script that can optionally inject action AppleScript + private func statusScript(actionLines: String = "") -> String { + """ + tell application "Music" + \(actionLines) + 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 { 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 { @@ -60,23 +68,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..d44f5c1 100644 --- a/Control/Platforms/Implementations/QuickTimeApp.swift +++ b/Control/Platforms/Implementations/QuickTimeApp.swift @@ -22,25 +22,26 @@ struct QuickTimeApp: AppPlatform { """ } - 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 +64,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 02276f4..89359cd 100644 --- a/Control/Platforms/Implementations/SafariApp.swift +++ b/Control/Platforms/Implementations/SafariApp.swift @@ -24,35 +24,40 @@ struct SafariApp: AppPlatform { """ } - private let statusScript = """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then - return "Nothing playing ||| ||| false ||| false" - end if - try - set currentTab to current tab of window 1 - set videoScript to " - (function() { - var video = document.querySelector('video'); - if (!video) return 'Nothing playing ||| ||| false ||| false'; - var title = document.title.replace(' - YouTube', '') || 'Unknown Video'; - var siteName = window.location.hostname.replace('www.', ''); - var isPlaying = !video.paused && !video.ended; - - return title + ' ||| ' + siteName + ' ||| ' + (isPlaying ? 'true' : 'false') + ' ||| ' + (isPlaying ? 'true' : 'false'); - })(); - " - set videoInfo to do JavaScript videoScript in currentTab - return videoInfo - end try - - return "Nothing playing ||| ||| false ||| false" - end tell - """ + private func statusScript(actionLines: String = "") -> String { + """ + tell application "Safari" + \(actionLines) + set windowCount to count of windows + if windowCount is 0 then + return "Nothing playing ||| ||| false ||| false" + end if + try + set currentTab to current tab of window 1 + set videoScript to " + (function() { + var video = document.querySelector('video'); + if (!video) return 'Nothing playing ||| ||| false ||| false'; + var title = document.title.replace(' - YouTube', '') || 'Unknown Video'; + var siteName = window.location.hostname.replace('www.', ''); + var isPlaying = !video.paused && !video.ended; + + return title + ' ||| ' + siteName + ' ||| ' + (isPlaying ? 'true' : 'false') + ' ||| ' + (isPlaying ? 'true' : 'false'); + })(); + " + set videoInfo to do JavaScript videoScript in currentTab + return videoInfo + end try + + return "Nothing playing ||| ||| false ||| false" + end tell + """ + } + + func fetchState() -> String { statusScript() } - func fetchState() -> String { - return statusScript + func actionWithStatus(_ action: AppAction) -> String { + statusScript(actionLines: executeAction(action)) } func parseState(_ output: String) -> AppState { @@ -83,58 +88,52 @@ struct SafariApp: AppPlatform { switch action { case .playPauseToggle: return """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then return - try - set currentTab to current tab of window 1 - do JavaScript " - (function() { - var video = document.querySelector('video'); - if (video) { - if (video.paused || video.ended) { - video.play(); - } else { - video.pause(); - } + set windowCount to count of windows + if windowCount is 0 then return + try + set currentTab to current tab of window 1 + do JavaScript " + (function() { + var video = document.querySelector('video'); + if (video) { + if (video.paused || video.ended) { + video.play(); + } else { + video.pause(); } - })(); - " in currentTab - end try - end tell + } + })(); + " in currentTab + end try """ case .skipForward(let seconds): return """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then return - try - set currentTab to current tab of window 1 - do JavaScript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime += \(seconds); - })(); - " in currentTab - end try - end tell + set windowCount to count of windows + if windowCount is 0 then return + try + set currentTab to current tab of window 1 + do JavaScript " + (function() { + const media = document.querySelector('video, audio'); + if (media) media.currentTime += \(seconds); + })(); + " in currentTab + end try """ case .skipBackward(let seconds): return """ - tell application "Safari" - set windowCount to count of windows - if windowCount is 0 then return - - try - set currentTab to current tab of window 1 - do JavaScript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime -= \(seconds); - })(); - " in currentTab - end try - end tell + set windowCount to count of windows + if windowCount is 0 then return + + try + set currentTab to current tab of window 1 + do JavaScript " + (function() { + const media = document.querySelector('video, audio'); + if (media) media.currentTime -= \(seconds); + })(); + " in currentTab + end try """ default: return "" diff --git a/Control/Platforms/Implementations/SpotifyApp.swift b/Control/Platforms/Implementations/SpotifyApp.swift index cc1a85b..d618a78 100644 --- a/Control/Platforms/Implementations/SpotifyApp.swift +++ b/Control/Platforms/Implementations/SpotifyApp.swift @@ -22,26 +22,28 @@ struct SpotifyApp: AppPlatform { """ } - 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 ||| |||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 { statusScript() } + func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") if components.count >= 4 { @@ -63,25 +65,17 @@ 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 { + statusScript(actionLines: executeAction(action)) + } } diff --git a/Control/Platforms/Implementations/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index 923e2d8..ed26caa 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -22,52 +22,57 @@ struct TVApp: AppPlatform { """ } - 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 + private func statusScript(actionLines: String = "") -> String { + """ + tell application "TV" + \(actionLines) + -- Grab the raw player state: can be \"playing\", \"paused\", or \"stopped\". + set rawState to player state as text - if media kind of currentProperties is TV show then - set showName to show of current track - else - set showName to "" - end if + -- 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 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" - end if - else if frontWindow is not "TV" then - if rawState is "playing" then - return frontWindow & "||| |||" & "playing" & "|||" & "true" + 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 "" + 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" + end if + else if frontWindow is not "TV" then + if rawState is \"playing\" then + return frontWindow & "||| |||" & "playing" & "|||" & "true" + else + return frontWindow & "||| |||" & "paused" & "|||" & "false" + end if else - return frontWindow & "||| |||" & "paused" & "|||" & "false" + -- If we can't retrieve a track, there's truly no video playing. + return "Nothing playing ||| ||| stopped ||| 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 - """ + end tell + """ + } + + func fetchState() -> String { statusScript() } - func fetchState() -> String { - return statusScript + func actionWithStatus(_ action: AppAction) -> String { + statusScript(actionLines: executeAction(action)) } func parseState(_ output: String) -> AppState { @@ -91,55 +96,37 @@ 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 - end tell - end if + 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" + tell process "TV" to key code 123 -- Left Arrow + end tell + end if on error errMsg - return "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 + set currentPosition to player position + if currentPosition is not missing value then + set player position to currentPosition + 10 + else + tell application "System Events" to tell process "TV" to key code 124 -- Right Arrow + end if on error errMsg - return "Error: " & errMsg + return "Error: " & errMsg end try - end tell """ default: return "" diff --git a/Control/Platforms/Implementations/VLCApp.swift b/Control/Platforms/Implementations/VLCApp.swift index c41a18d..bf03257 100644 --- a/Control/Platforms/Implementations/VLCApp.swift +++ b/Control/Platforms/Implementations/VLCApp.swift @@ -24,45 +24,50 @@ struct VLCApp: AppPlatform { """ } - private let statusScript = """ - tell application "VLC" - try - -- Check if VLC is currently running - 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 - """ + private func statusScript(actionLines: String = "") -> String { + """ + tell application "VLC" + \(actionLines) + try + -- Check if VLC is currently running + 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 + """ + } + + func fetchState() -> String { statusScript() } - func fetchState() -> String { - return statusScript + func actionWithStatus(_ action: AppAction) -> String { + statusScript(actionLines: executeAction(action)) } func parseState(_ output: String) -> AppState { @@ -86,35 +91,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..369cbc3 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 { From 3f2d4d771960461767b8240256b5f6082ca73324 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Thu, 3 Jul 2025 21:53:16 -0700 Subject: [PATCH 08/25] implement dedicated channels --- Control/Platforms/AppController.swift | 19 +++--- Control/SSH/ChannelExecutor.swift | 86 +++++++++++++++++++++++++ Control/SSH/SSHClient.swift | 61 +++++++++++++++++- Control/SSH/SSHClientProtocol.swift | 7 ++ Control/SSH/SSHConnectionManager.swift | 60 ++++++++++------- Control/SSH/SSHViewSupport.swift | 9 +-- Control/SSH/StreamingShellHandler.swift | 51 +++++++++++++++ 7 files changed, 254 insertions(+), 39 deletions(-) create mode 100644 Control/SSH/ChannelExecutor.swift create mode 100644 Control/SSH/StreamingShellHandler.swift diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index c1e689c..403a187 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -116,7 +116,7 @@ class AppController: ObservableObject { // Send single verification heartbeat at end of batch operation if isActive { appControllerLog("πŸ“¦ Batch operation complete, sending single verification heartbeat") - _ = await executeCommand("true", description: "Batch operation verification") + _ = await executeCommand("true", channelKey: "system", description: "Batch operation verification") hasCompletedInitialUpdate = true } } @@ -151,7 +151,7 @@ class AppController: ObservableObject { return } - let result = await executeCommand(platform.fetchState(), description: "\(platform.name): fetch status") + let result = await executeCommand(platform.fetchState(), channelKey: platform.id, description: "\(platform.name): fetch status") switch result { case .success(let output): @@ -203,6 +203,7 @@ class AppController: ObservableObject { let result = await executeCommand( platform.isRunningScript(), + channelKey: platform.id, description: "\(platform.name): check if running" ) @@ -229,7 +230,7 @@ class AppController: ObservableObject { // 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.name): executeAction(.\(action))") switch result { case .success(let output): @@ -271,7 +272,7 @@ class AppController: ObservableObject { let target = Int(volume * 100) let script = "set volume output volume \(target)" - let result = await executeCommand(script, description: "System: set volume(\(target))", bypassHeartbeat: true) + let result = await executeCommand(script, channelKey: "system", description: "System: set volume(\(target))", bypassHeartbeat: true) switch result { case .success(let output): @@ -302,7 +303,7 @@ class AppController: ObservableObject { output volume of (get volume settings) """ - let result = await executeCommand(script, description: "System: get volume") + let result = await executeCommand(script, channelKey: "system", description: "System: get volume") switch result { case .success(let output): @@ -331,7 +332,7 @@ class AppController: ObservableObject { } // Keep this simpler version for single commands (permissions checks, etc) - private func executeCommand(_ command: String, description: String? = nil, bypassHeartbeat: Bool = false) async -> Result { + private func executeCommand(_ command: String, channelKey: String, description: String? = nil, bypassHeartbeat: Bool = false) async -> Result { if let description = description { appControllerLog("Executing command: \(description)") } else { @@ -353,8 +354,8 @@ class AppController: ObservableObject { // Use heartbeat-optimized execution during batch operations if self.isBatchOperation || bypassHeartbeat { - // During batch operations or when bypass explicitly requested, skip heartbeats - self.sshClient.executeCommandBypassingHeartbeat(wrappedCommand, description: description) { result in + // During batch operations or when bypass explicitly requested, still use the dedicated channel but let the manager decide heartbeat behaviour + self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in switch result { case .success(let output): appControllerLog("βœ“ Command executed successfully (batch mode)") @@ -378,7 +379,7 @@ class AppController: ObservableObject { } } else { // Always use new channel for reliability - revert the session reuse optimization - self.sshClient.executeCommandWithNewChannel(wrappedCommand, description: description) { result in + self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in switch result { case .success(let output): appControllerLog("βœ“ Command executed successfully") diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift new file mode 100644 index 0000000..28f7b50 --- /dev/null +++ b/Control/SSH/ChannelExecutor.swift @@ -0,0 +1,86 @@ +import Foundation +import NIOSSH +import NIOCore + +/// Actor responsible for running commands serially on the SSH connection. +/// It opens a NEW exec channel for every command (required by macOS sshd) but keeps +/// the overhead low by re-using the underlying TCP connection and serialising calls. +@available(iOS 15.0, *) +actor ChannelExecutor { + private unowned let connection: Channel + private var shellChannel: Channel? + private let shellHandler: StreamingShellHandler + + init(connection: Channel) { + self.connection = connection + // 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 { [weak self] (chan: Channel) -> EventLoopFuture in + // Persist the channel reference as soon as it's available + self?.shellChannel = chan + return makeShell(channel: chan) + } + .whenFailure { error in + sshLog("❌ Failed to start interactive shell: \(error)") + } + } + + /// Executes `command` by opening a fresh exec channel on the existing SSH connection. + /// The promise is fulfilled when the command finishes (or fails). + func run(command: String, description: String?) async -> Result { + // Ensure the interactive shell channel is ready. Wait up to 1 s (50 Γ— 20 ms yields). + var retries = 0 + while self.shellChannel == nil && retries < 50 { + retries += 1 + try? await Task.sleep(nanoseconds: 20_000_000) // 20 ms + } + + guard let chan = self.shellChannel else { + return .failure(SSHError.noSession) + } + + return await withCheckedContinuation { continuation in + let sentinel = "__END__\(UUID().uuidString.prefix(6))__" + let promise = chan.eventLoop.makePromise(of: String.self) + self.shellHandler.addCommand(sentinel: sentinel, promise: promise) + let payload = "\(command); printf '\n%s\n' \(sentinel)\n" + var buffer = chan.allocator.buffer(string: payload) + chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: nil) + promise.futureResult.whenComplete { result in + continuation.resume(returning: result) + } + } + } + + /// Close shell channel + func close() { + if let chan = self.shellChannel { + chan.close(promise: nil) + } + } +} + +// A simple passthrough error handler for the exec child channel. +private class ErrorHandler: ChannelInboundHandler { + typealias InboundIn = Any + func errorCaught(context: ChannelHandlerContext, error: Error) { + context.close(promise: nil) + } +} + +private func makeShell(channel: Channel) -> EventLoopFuture { + let execReq = SSHChannelRequestEvent.ExecRequest(command: "/bin/sh -l", wantReply: true) + return channel.triggerUserOutboundEvent(execReq) +} diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index a613f37..381f501 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -32,7 +32,7 @@ actor ChannelLimiter { // Shared global limiter – tweak `max` if the server allows more channels private let sshChannelLimiter = ChannelLimiter(max: 4) -class SSHClient: SSHClientProtocol { +class SSHClient: SSHClientProtocol, @unchecked Sendable { private var group: EventLoopGroup private var connection: Channel? private var session: Channel? @@ -42,6 +42,45 @@ class SSHClient: SSHClientProtocol { // Adaptive timeout rolling averages keyed by command description private static var commandAverages: [String: Double] = [:] + // MARK: - Dedicated Channel Support + /// Executors keyed by logical channel name (e.g. "system", "music", etc.) + private var dedicatedExecutors: [String: ChannelExecutor] = [:] + + /// Retrieve an existing executor for `key` or create a new one if necessary. + private func executor(for key: String) async throws -> ChannelExecutor { + if let existing = dedicatedExecutors[key] { + return existing + } + + // Ensure we have an active SSH TCP connection. + guard let connection = self.connection else { + throw SSHError.channelNotConnected + } + + // Create a single ChannelExecutor which will internally open its own interactive shell. + let executor = ChannelExecutor(connection: connection) + dedicatedExecutors[key] = executor + 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) + return await exec.run(command: command, description: description) + } catch { + 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) } @@ -265,7 +304,6 @@ class SSHClient: SSHClientProtocol { let promise = connection.eventLoop.makePromise(of: Channel.self) sshLog("Creating SSH session...") - let start = Date() // track duration for adaptive timeout updates connection.pipeline.handler(type: NIOSSHHandler.self).flatMap { handler -> EventLoopFuture in handler.createChannel(promise) { channel, channelType in guard channelType == .session else { @@ -345,7 +383,17 @@ class SSHClient: SSHClientProtocol { /// 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) + // Heartbeats can be triggered in parallel from different contexts. Protect the server's + // channel limit by acquiring a permit from the shared limiter before opening a new + // exec-style channel. + Task { + await sshChannelLimiter.acquire() + executeCommandDirectly(command, description: description) { result in + completion(result) + // Always release the permit when the command completes. + Task { await sshChannelLimiter.release() } + } + } } /// Direct execution method that bypasses heartbeat checks (used by heartbeat itself) @@ -466,6 +514,13 @@ class SSHClient: SSHClientProtocol { // Reset connection completion state hasCompletedConnection = false + // Close and clear any dedicated channels + for (key, executor) in dedicatedExecutors { + sshLog("Closing dedicated channel for key: \(key)") + Task { await executor.close() } + } + dedicatedExecutors.removeAll() + // Send exit command to gracefully close remote session if possible if let session = session { sshLog("Sending exit command to gracefully close remote session") diff --git a/Control/SSH/SSHClientProtocol.swift b/Control/SSH/SSHClientProtocol.swift index 56ad88c..2eefafa 100644 --- a/Control/SSH/SSHClientProtocol.swift +++ b/Control/SSH/SSHClientProtocol.swift @@ -5,6 +5,9 @@ protocol SSHClientProtocol { func disconnect() func executeCommandWithNewChannel(_ command: String, description: String?, completion: @escaping (Result) -> Void) func executeCommandBypassingHeartbeat(_ 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 @@ -16,4 +19,8 @@ extension SSHClientProtocol { func executeCommandBypassingHeartbeat(_ command: String, completion: @escaping (Result) -> Void) { executeCommandBypassingHeartbeat(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 51aeccb..7549f94 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -295,15 +295,9 @@ 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, bypassHeartbeat: Bool = false, completion: @escaping (Result) -> Void) { + sshLog("SSHConnectionManager: Executing command on channel \(channelKey) with proactive timeout monitoring") if let description = description { sshLog("Command: \(description)") } @@ -331,8 +325,19 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { sshLog("⏰ [\(commandId)] Starting 6-second proactive timeout monitor") DispatchQueue.global().asyncAfter(deadline: .now() + 6.0, execute: timeoutTask) - // Execute the command - client.executeCommandWithNewChannel(command, description: description) { [weak self] result in + // Choose execution path + if bypassHeartbeat { + client.executeCommandOnDedicatedChannel(channelKey, command, description: description) { [weak self] result in + guard !hasCompleted else { return } + hasCompleted = true + timeoutTask.cancel() + completion(result) + } + return + } + + // Execute the command on dedicated channel with heartbeat verification + client.executeCommandOnDedicatedChannel(channelKey, command, description: description) { [weak self] result in guard !hasCompleted else { sshLog("⚠️ [\(commandId)] Command completed but timeout already triggered, ignoring result") return @@ -346,7 +351,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { switch result { case .success(let output): sshLog("βœ“ [\(commandId)] Command succeeded, sending verification heartbeat") - // Command succeeded, send verification heartbeat + // Command succeeded, send verification heartbeat unless bypass requested self?.sendPostCommandHeartbeat { heartbeatResult in switch heartbeatResult { case .success: @@ -363,7 +368,6 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } 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 @@ -375,6 +379,21 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } } + // Backward-compatibility wrapper + nonisolated func executeCommand(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { + executeCommand(onChannel: "system", command, description: description, bypassHeartbeat: false, completion: completion) + } + + /// Compatibility alias for existing code (kept for minimal external diff) + nonisolated func executeCommandWithNewChannel(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { + executeCommand(onChannel: "system", command, description: description, bypassHeartbeat: false, completion: completion) + } + + /// Execute command directly without heartbeat verification (used for batch operations) + nonisolated func executeCommandBypassingHeartbeat(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { + executeCommand(onChannel: "system", command, description: description, bypassHeartbeat: true, completion: completion) + } + /// 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) @@ -422,16 +441,6 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } } - /// Compatibility alias for existing code - nonisolated func executeCommandWithNewChannel(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - executeCommand(command, description: description, completion: completion) - } - - /// Execute command directly without heartbeat verification (used for batch operations) - nonisolated func executeCommandBypassingHeartbeat(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - client.executeCommandBypassingHeartbeat(command, description: description, completion: completion) - } - // MARK: - SSHClientProtocol Conformance /// Protocol-required connect method with completion handler @@ -445,4 +454,9 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } } } + + /// Protocol conformance – executes on a dedicated channel (default heartbeat behaviour) + nonisolated func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { + executeCommand(onChannel: channelKey, command, description: description, bypassHeartbeat: false, completion: completion) + } } diff --git a/Control/SSH/SSHViewSupport.swift b/Control/SSH/SSHViewSupport.swift index 452d78f..ee349b9 100644 --- a/Control/SSH/SSHViewSupport.swift +++ b/Control/SSH/SSHViewSupport.swift @@ -35,14 +35,14 @@ extension SSHConnectedView { viewLog("\(viewName): Scene phase changed from \(oldPhase) to \(newPhase)", view: viewName) if newPhase == .active { + let currentViewName = viewName Task { @MainActor in - let viewName = String(describing: Self.self) if connectionManager.connectionState == .connected { do { try await connectionManager.verifyConnectionHealth() - viewLog("βœ“ \(viewName): Connection health verified", view: viewName) + viewLog("βœ“ \(currentViewName): Connection health verified", view: currentViewName) } catch { - viewLog("❌ \(viewName): Connection health check failed: \(error)", view: viewName) + viewLog("❌ \(currentViewName): Connection health check failed: \(error)", view: currentViewName) connectToSSH() } } else { @@ -88,8 +88,9 @@ extension SSHConnectedView { @MainActor private func setConnectionLostHandler() { + let viewNameMeta = String(describing: Self.self) connectionManager.setConnectionLostHandler { @MainActor in - viewLog("⚠️ \(Self.self): Connection lost handler triggered", view: String(describing: Self.self)) + viewLog("⚠️ \(viewNameMeta): Connection lost handler triggered", view: viewNameMeta) showingConnectionLostAlert.wrappedValue = true } } diff --git a/Control/SSH/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift new file mode 100644 index 0000000..ffd7820 --- /dev/null +++ b/Control/SSH/StreamingShellHandler.swift @@ -0,0 +1,51 @@ +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] = [] + + /// Called by ChannelExecutor when a new command is queued. + func addCommand(sentinel: String, promise: EventLoopPromise) { + queue.append(Pending(sentinel: sentinel, promise: promise)) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let payload = unwrapInboundIn(data) + guard case .byteBuffer(let buf) = payload.data, + var string = buf.getString(at: 0, length: buf.readableBytes) else { return } + + while !queue.isEmpty { + var front = queue[0] + front.buffer += string + if let range = front.buffer.range(of: front.sentinel) { + let output = String(front.buffer[.. Date: Fri, 4 Jul 2025 02:43:45 -0700 Subject: [PATCH 09/25] applescript streaming WIP -- currently working on refresh --- Control/Platforms/AppController.swift | 71 +++--- .../Platforms/Implementations/Chrome.swift | 5 +- .../Platforms/Implementations/IINAApp.swift | 5 +- .../Platforms/Implementations/MusicApp.swift | 5 +- .../Implementations/QuickTimeApp.swift | 5 +- .../Platforms/Implementations/SafariApp.swift | 5 +- .../Implementations/SpotifyApp.swift | 5 +- Control/Platforms/Implementations/TVApp.swift | 5 +- .../Platforms/Implementations/VLCApp.swift | 5 +- Control/SSH/ChannelExecutor.swift | 168 +++++++++++++-- Control/SSH/SSHClient.swift | 17 +- Control/SSH/SSHConnectionManager.swift | 5 +- Control/SSH/StreamingShellHandler.swift | 202 ++++++++++++++++-- Control/Utilities/ShellCommandUtilities.swift | 14 +- 14 files changed, 398 insertions(+), 119 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 403a187..d076888 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -83,15 +83,19 @@ class AppController: ObservableObject { } func updateAllStates() async { - appControllerLog("AppController: Starting comprehensive state update") - appControllerLog("Controller active: \(isActive)") - appControllerLog("Number of platforms: \(platforms.count)") + appControllerLog("AppController: Starting comprehensive state update (\(platforms.count) platforms)") guard isActive else { appControllerLog("⚠️ Controller not active, skipping state update") return } + // If this is the initial update, give channels a moment to fully initialize + if !hasCompletedInitialUpdate { + appControllerLog("Initial state update - waiting for channels to stabilize") + try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second for initial setup + } + // Mark as batch operation to reduce heartbeats isBatchOperation = true defer { @@ -115,8 +119,7 @@ class AppController: ObservableObject { // Send single verification heartbeat at end of batch operation if isActive { - appControllerLog("πŸ“¦ Batch operation complete, sending single verification heartbeat") - _ = await executeCommand("true", channelKey: "system", description: "Batch operation verification") + _ = await executeCommand("true", channelKey: "system", description: "Batch verification") hasCompletedInitialUpdate = true } } @@ -195,9 +198,7 @@ class AppController: ObservableObject { } 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 } @@ -209,8 +210,9 @@ class AppController: ObservableObject { switch result { case .success(let output): - let isRunning = output.trimmingCharacters(in: .whitespacesAndNewlines) == "true" - appControllerLog(isRunning ? "βœ“ \(platform.name) is running" : "⚠️ \(platform.name) is not running") + let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + // Handle both boolean results (true/false) and string results ("true"/"false") + let isRunning = trimmedOutput == "true" || trimmedOutput == "\"true\"" return isRunning case .failure(let error): appControllerLog("❌ Failed to check if \(platform.name) is running: \(error)") @@ -224,17 +226,14 @@ class AppController: ObservableObject { return } - appControllerLog("AppController: Executing action \(action) on \(platform.name)") - // 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, channelKey: platform.id, description: "\(platform.name): executeAction(.\(action))") + let result = await executeCommand(combinedScript, channelKey: platform.id, description: "\(platform.name): \(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") { @@ -248,7 +247,6 @@ 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)") states[platform.id] = newState } case .failure(let error): @@ -264,7 +262,6 @@ class AppController: ObservableObject { } 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 @@ -272,14 +269,11 @@ class AppController: ObservableObject { let target = Int(volume * 100) let script = "set volume output volume \(target)" - let result = await executeCommand(script, channelKey: "system", description: "System: set volume(\(target))", bypassHeartbeat: true) + let result = await executeCommand(script, channelKey: "system", description: "Set volume(\(target)%)", bypassHeartbeat: true) switch result { case .success(let output): - appControllerLog("βœ“ Volume set successfully") - if !output.isEmpty { - appControllerLog("Volume command output: \(output)") - } + appControllerLog("βœ“ Volume set to \(target)%") case .failure(let error): appControllerLog("❌ Failed to set volume: \(error)") // Check if this is a connection loss @@ -293,32 +287,25 @@ class AppController: ObservableObject { } 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) - """ - - let result = await executeCommand(script, channelKey: "system", description: "System: get volume") + let script = "output volume of (get volume settings)" + + let result = await executeCommand(script, channelKey: "system", description: "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") } 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 @@ -333,18 +320,18 @@ class AppController: ObservableObject { // Keep this simpler version for single commands (permissions checks, etc) private func executeCommand(_ command: String, channelKey: String, description: String? = nil, bypassHeartbeat: Bool = false) async -> Result { - if let description = description { - appControllerLog("Executing command: \(description)") - } else { - appControllerLog("Executing command") - } - guard isActive else { appControllerLog("⚠️ Controller not active, skipping command") return .failure(SSHError.channelError("Controller not active")) } - let wrappedCommand = ShellCommandUtilities.wrapAppleScriptForBash(command) + let wrappedCommand: String + if channelKey == "system" { + // System commands (volume) should use pure AppleScript, not bash-wrapped + wrappedCommand = ShellCommandUtilities.appleScriptForStreaming(command) + } else { + wrappedCommand = ShellCommandUtilities.appleScriptForStreaming(command) + } return await withCheckedContinuation { [weak self] continuation in guard let self = self else { @@ -358,10 +345,6 @@ class AppController: ObservableObject { self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in switch result { case .success(let output): - appControllerLog("βœ“ Command executed successfully (batch mode)") - if !output.isEmpty { - appControllerLog("Command output: \(output)") - } continuation.resume(returning: result) case .failure(let error): appControllerLog("❌ Command failed: \(error)") @@ -382,10 +365,6 @@ class AppController: ObservableObject { self.sshClient.executeCommandOnDedicatedChannel(channelKey, 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)") @@ -394,7 +373,7 @@ class AppController: ObservableObject { 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 + self.isActive = false connectionManager.handleConnectionLost() } diff --git a/Control/Platforms/Implementations/Chrome.swift b/Control/Platforms/Implementations/Chrome.swift index 49539c6..4f03907 100644 --- a/Control/Platforms/Implementations/Chrome.swift +++ b/Control/Platforms/Implementations/Chrome.swift @@ -19,10 +19,7 @@ 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\" to exists (processes where name is \"Google Chrome\")" } // Template status script that can optionally inject action AppleScript diff --git a/Control/Platforms/Implementations/IINAApp.swift b/Control/Platforms/Implementations/IINAApp.swift index 9d3cf00..19be70f 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -18,10 +18,7 @@ struct IINAApp: AppPlatform { } func isRunningScript() -> String { - """ - tell application "System Events" to set isAppOpen to exists (processes where name is "IINA") - return isAppOpen as text - """ + "tell application \"System Events\" to exists (processes where name is \"IINA\")" } // Template status script that can optionally inject action AppleScript diff --git a/Control/Platforms/Implementations/MusicApp.swift b/Control/Platforms/Implementations/MusicApp.swift index 8adbb58..9a85168 100644 --- a/Control/Platforms/Implementations/MusicApp.swift +++ b/Control/Platforms/Implementations/MusicApp.swift @@ -16,10 +16,7 @@ struct MusicApp: AppPlatform { } func isRunningScript() -> String { - """ - tell application "System Events" to set isAppOpen to exists (processes where name is "Music") - return isAppOpen as text - """ + "tell application \"System Events\" to exists (processes where name is \"Music\")" } // Template status script that can optionally inject action AppleScript diff --git a/Control/Platforms/Implementations/QuickTimeApp.swift b/Control/Platforms/Implementations/QuickTimeApp.swift index d44f5c1..bc7c29e 100644 --- a/Control/Platforms/Implementations/QuickTimeApp.swift +++ b/Control/Platforms/Implementations/QuickTimeApp.swift @@ -16,10 +16,7 @@ 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\" to exists (processes where name is \"QuickTime Player\")" } private func statusScript(actionLines: String = "") -> String { diff --git a/Control/Platforms/Implementations/SafariApp.swift b/Control/Platforms/Implementations/SafariApp.swift index 89359cd..a7ccaa3 100644 --- a/Control/Platforms/Implementations/SafariApp.swift +++ b/Control/Platforms/Implementations/SafariApp.swift @@ -18,10 +18,7 @@ struct SafariApp: AppPlatform { } func isRunningScript() -> String { - """ - tell application "System Events" to set isAppOpen to exists (processes where name is "Safari") - return isAppOpen as text - """ + "tell application \"System Events\" to exists (processes where name is \"Safari\")" } private func statusScript(actionLines: String = "") -> String { diff --git a/Control/Platforms/Implementations/SpotifyApp.swift b/Control/Platforms/Implementations/SpotifyApp.swift index d618a78..e605582 100644 --- a/Control/Platforms/Implementations/SpotifyApp.swift +++ b/Control/Platforms/Implementations/SpotifyApp.swift @@ -16,10 +16,7 @@ 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\" to exists (processes where name is \"Spotify\")" } // Template status script that can optionally inject action AppleScript diff --git a/Control/Platforms/Implementations/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index ed26caa..442378e 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -16,10 +16,7 @@ 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 func statusScript(actionLines: String = "") -> String { diff --git a/Control/Platforms/Implementations/VLCApp.swift b/Control/Platforms/Implementations/VLCApp.swift index bf03257..8765496 100644 --- a/Control/Platforms/Implementations/VLCApp.swift +++ b/Control/Platforms/Implementations/VLCApp.swift @@ -18,10 +18,7 @@ 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 func statusScript(actionLines: String = "") -> String { diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index 28f7b50..eee61df 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -2,6 +2,29 @@ import Foundation import NIOSSH import NIOCore +/// Utility function to add timeout to async operations +private func withTimeout(seconds: Double, operation: @escaping () async throws -> T) async throws -> T { + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + return try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TimeoutError() + } + + guard let result = try await group.next() else { + throw TimeoutError() + } + + group.cancelAll() + return result + } +} + +private struct TimeoutError: Error {} + /// Actor responsible for running commands serially on the SSH connection. /// It opens a NEW exec channel for every command (required by macOS sshd) but keeps /// the overhead low by re-using the underlying TCP connection and serialising calls. @@ -10,9 +33,12 @@ actor ChannelExecutor { private unowned let connection: Channel private var shellChannel: Channel? private let shellHandler: StreamingShellHandler + private let interactiveAppleScript: Bool - init(connection: Channel) { + init(connection: Channel, interactiveAppleScript: Bool) { + print("πŸ”§ ChannelExecutor: Initializing \(interactiveAppleScript ? "AppleScript" : "shell") executor") self.connection = connection + self.interactiveAppleScript = interactiveAppleScript // Create a single interactive shell session let promise = connection.eventLoop.makePromise(of: Channel.self) let handler = StreamingShellHandler() @@ -27,37 +53,115 @@ actor ChannelExecutor { } return promise.futureResult } - .flatMap { [weak self] (chan: Channel) -> EventLoopFuture in + .flatMap { (chan: Channel) -> EventLoopFuture in // Persist the channel reference as soon as it's available - self?.shellChannel = chan - return makeShell(channel: chan) + Task { [weak self] in + await self?.setShellChannel(chan) + } + if interactiveAppleScript { + return setupInteractiveShell(channel: chan, command: "/usr/bin/osascript -s s -l AppleScript -i") + } else { + return setupInteractiveShell(channel: chan, command: "/bin/sh -l") + } } - .whenFailure { error in - sshLog("❌ Failed to start interactive shell: \(error)") + .whenComplete { result in + switch result { + case .success: + print("πŸ”§ ChannelExecutor: βœ“ Interactive \(interactiveAppleScript ? "AppleScript" : "shell") ready") + // Send test ping to verify the interactive session is working + Task { [weak self] in + try? await Task.sleep(nanoseconds: 500_000_000) // Wait 500ms for shell to stabilize + await self?.sendTestPing() + } + case .failure(let error): + print("πŸ”§ ChannelExecutor: ❌ Failed to start interactive shell: \(error)") + } } } + /// Send a simple test command to verify the interactive session is responsive + private func sendTestPing() async { + guard let channel = shellChannel else { + print("πŸ”§ ChannelExecutor: No shell channel for test ping") + return + } + + let testSentinel = "__PING_\(UUID().uuidString.prefix(4))__" + let testPayload: String + + if interactiveAppleScript { + testPayload = "1 + 1\n\ndo shell script \"echo \(testSentinel)\"\n\n" + } else { + testPayload = "echo 'test-ok'; printf '\\n%s\\n' \(testSentinel)\n" + } + + // Set up the test command in the handler + let promise = channel.eventLoop.makePromise(of: String.self) + self.shellHandler.addCommand(sentinel: testSentinel, promise: promise) + + // Send the test payload + let buffer = channel.allocator.buffer(string: testPayload) + channel.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: nil) + + // Wait for the test result with a timeout + do { + let testResult = try await withTimeout(seconds: 3.0) { + return try await promise.futureResult.get() + } + print("πŸ”§ ChannelExecutor: βœ“ Test ping successful") + } catch { + print("πŸ”§ ChannelExecutor: ❌ Test ping failed: \(error)") + } + } + /// Executes `command` by opening a fresh exec channel on the existing SSH connection. /// The promise is fulfilled when the command finishes (or fails). func run(command: String, description: String?) async -> Result { - // Ensure the interactive shell channel is ready. Wait up to 1 s (50 Γ— 20 ms yields). + let commandPreview = String(command.prefix(50)) + if let description = description { + print("πŸ”§ ChannelExecutor: \(description)") + } + + // Ensure the interactive shell channel is ready. Wait up to 3s (150 Γ— 20 ms yields). var retries = 0 - while self.shellChannel == nil && retries < 50 { + while self.shellChannel == nil && retries < 150 { retries += 1 try? await Task.sleep(nanoseconds: 20_000_000) // 20 ms } guard let chan = self.shellChannel else { + print("πŸ”§ ChannelExecutor: ❌ No shell channel available") return .failure(SSHError.noSession) } + + // Give the interactive shell a moment to fully stabilize + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms return await withCheckedContinuation { continuation in - let sentinel = "__END__\(UUID().uuidString.prefix(6))__" + let sentinelSuffix = String(UUID().uuidString.prefix(6)) + let sentinel = "__END__\(sentinelSuffix)__" + let promise = chan.eventLoop.makePromise(of: String.self) self.shellHandler.addCommand(sentinel: sentinel, promise: promise) - let payload = "\(command); printf '\n%s\n' \(sentinel)\n" - var buffer = chan.allocator.buffer(string: payload) - chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: nil) + + let payload: String + if self.interactiveAppleScript { + // For interactive AppleScript, send the command, blank line to execute, then sentinel + let escapedSentinel = sentinel.replacingOccurrences(of: "\"", with: "\\\"") + // Introduce a short delay before echoing the sentinel so that the command's + // output has time to reach stdout before we mark completion. This mitigates + // race-conditions where the sentinel might otherwise arrive first and cause + // us to read an empty or incomplete result. + payload = "\(command)\n\n-- Small delay to allow stdout to flush before sentinel\ndelay 0.05\ndo shell script \"echo \(escapedSentinel)\"\n\n" + print("πŸ”§ ChannelExecutor: Sending to osascript: '\(command)'") + } else { + payload = "\(command); printf '\\n%s\\n' \(sentinel)\n" + } + + let buffer = chan.allocator.buffer(string: payload) + let writePromise = chan.eventLoop.makePromise(of: Void.self) + chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: writePromise) + promise.futureResult.whenComplete { result in continuation.resume(returning: result) } @@ -66,21 +170,55 @@ actor ChannelExecutor { /// Close shell channel func close() { + print("πŸ”§ ChannelExecutor: Closing shell channel") if let chan = self.shellChannel { chan.close(promise: nil) } } + + /// Set shell channel from async context + private func setShellChannel(_ channel: Channel) { + self.shellChannel = channel + } } // A simple passthrough error handler for the exec child channel. private class ErrorHandler: ChannelInboundHandler { typealias InboundIn = Any func errorCaught(context: ChannelHandlerContext, error: Error) { + print("πŸ”§ ErrorHandler: ❌ Error caught: \(error)") context.close(promise: nil) } } -private func makeShell(channel: Channel) -> EventLoopFuture { - let execReq = SSHChannelRequestEvent.ExecRequest(command: "/bin/sh -l", wantReply: true) - return channel.triggerUserOutboundEvent(execReq) +private func setupInteractiveShell(channel: Channel, command: String) -> EventLoopFuture { + // First allocate a PTY for proper terminal behavior + 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 + // Now request an interactive shell + let shellRequest = SSHChannelRequestEvent.ShellRequest(wantReply: true) + return channel.triggerUserOutboundEvent(shellRequest) + } + .flatMap { _ -> EventLoopFuture in + // Send the initial command to set up our specific interpreter + 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 + print("πŸ”§ setupInteractiveShell: ❌ Setup failed: \(error)") + return channel.eventLoop.makeFailedFuture(error) + } } diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 381f501..38ad921 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -48,35 +48,50 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { /// Retrieve an existing executor for `key` or create a new one if necessary. private func executor(for key: String) async throws -> ChannelExecutor { + sshLog("πŸ“‘ SSHClient: Getting executor for key '\(key)'") if let existing = dedicatedExecutors[key] { + sshLog("πŸ“‘ SSHClient: Using existing executor for key '\(key)'") return existing } + sshLog("πŸ“‘ SSHClient: Creating new executor for key '\(key)'") // 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 single ChannelExecutor which will internally open its own interactive shell. - let executor = ChannelExecutor(connection: connection) + let usesAppleScript = true // All channels now use AppleScript for consistency + sshLog("πŸ“‘ SSHClient: Creating ChannelExecutor with usesAppleScript=\(usesAppleScript) for key '\(key)'") + let executor = ChannelExecutor(connection: connection, interactiveAppleScript: usesAppleScript) dedicatedExecutors[key] = executor + sshLog("πŸ“‘ SSHClient: βœ“ Executor created and stored for key '\(key)'") 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 { + sshLog("πŸ“‘ SSHClient: Performing command on dedicated channel '\(channelKey)'") + if let description = description { + sshLog("πŸ“‘ SSHClient: Command description: \(description)") + } do { let exec = try await executor(for: channelKey) + sshLog("πŸ“‘ SSHClient: Got executor, running command") return await exec.run(command: command, description: description) } catch { + sshLog("πŸ“‘ SSHClient: ❌ 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) { + sshLog("πŸ“‘ SSHClient: executeCommandOnDedicatedChannel called for key '\(channelKey)'") Task { let result = await performOnDedicatedChannel(channelKey, command: command, description: description) + sshLog("πŸ“‘ SSHClient: Command completed with result: \(result)") completion(result) } } diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 7549f94..23dd45e 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -322,8 +322,9 @@ 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 = 10.0 + sshLog("⏰ [\(commandId)] Starting \(Int(timeoutSeconds))-second proactive timeout monitor") + DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutTask) // Choose execution path if bypassHeartbeat { diff --git a/Control/SSH/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift index ffd7820..24f3a78 100644 --- a/Control/SSH/StreamingShellHandler.swift +++ b/Control/SSH/StreamingShellHandler.swift @@ -3,7 +3,7 @@ import NIOCore import NIOSSH /// Handles an interactive shell channel and fulfils promises when sentinels are encountered. -final class StreamingShellHandler: ChannelInboundHandler { +final class StreamingShellHandler: ChannelInboundHandler, Sendable { typealias InboundIn = SSHChannelData struct Pending { @@ -13,39 +13,209 @@ final class StreamingShellHandler: ChannelInboundHandler { } private var queue: [Pending] = [] + private var hasReceivedAnyData = false + private var totalDataReceived = 0 /// 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) { + print("πŸ” StreamingShellHandler: βœ“ Channel active") + context.fireChannelActive() + } + + func channelInactive(context: ChannelHandlerContext) { + if !queue.isEmpty { + print("πŸ” StreamingShellHandler: ❌ Channel closed with pending commands") + } + context.fireChannelInactive() + } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + if !hasReceivedAnyData { + hasReceivedAnyData = true + print("πŸ” StreamingShellHandler: βœ“ Channel receiving data") + } + let payload = unwrapInboundIn(data) + guard case .byteBuffer(let buf) = payload.data, - var string = buf.getString(at: 0, length: buf.readableBytes) else { return } - - while !queue.isEmpty { - var front = queue[0] - front.buffer += string - if let range = front.buffer.range(of: front.sentinel) { - let output = String(front.buffer[.. "sentinel" + // 2. Shell format: plain sentinel + let osascriptSentinelPattern = "=> \"\(expectedSentinel)\"" + let shellSentinelPattern = expectedSentinel + + 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: shellSentinelPattern) { + sentinelRange = range + isOsascriptFormat = false + } + + if let sentinelRange = sentinelRange { + // Found the sentinel - extract the output before it + let outputPart = String(currentBuffer[.. "result" lines + scriptOutput = extractAppleScriptResult(from: outputPart) } else { - // Not complete yet - queue[0] = front - break + // Parse shell/mixed output - look for AppleScript results or clean shell output + scriptOutput = extractCleanOutput(from: outputPart) + } + + print("πŸ” StreamingShellHandler: βœ“ Result: '\(scriptOutput)'") + + // Complete the promise with the parsed output + let pending = queue.removeFirst() + pending.promise.succeed(scriptOutput) + } + } + + /// Extract clean AppleScript result from => "result" format + private func extractAppleScriptResult(from output: String) -> String { + 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) + if trimmed.hasPrefix("=> ") { + let content = String(trimmed.dropFirst(3)) + // Remove surrounding quotes if present + if content.hasPrefix("\"") && content.hasSuffix("\"") && content.count > 1 { + return String(content.dropFirst().dropLast()) + } else { + return content + } + } + } + + // If no => format found, look for any meaningful result + for line in lines.reversed() { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty && + !trimmed.hasPrefix("?>") && + !trimmed.hasPrefix(">>") && + !trimmed.hasPrefix(">") && + !trimmed.contains("ryan@") && + !trimmed.hasPrefix("[") && + !trimmed.hasPrefix("]") && + !trimmed.contains("Welcome to fish") && + !trimmed.contains("osascript") && + !trimmed.hasPrefix("tell ") && + !trimmed.hasPrefix("end tell") && + !trimmed.contains("do shell script") && + !trimmed.contains("echo ") && + 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 "" + } + + /// 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 + 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("do shell script") || + trimmed.contains("osascript") || + trimmed.contains("echo ") || + 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) { + print("πŸ” 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) } -} \ No newline at end of file +} diff --git a/Control/Utilities/ShellCommandUtilities.swift b/Control/Utilities/ShellCommandUtilities.swift index 060abda..8c14ffd 100644 --- a/Control/Utilities/ShellCommandUtilities.swift +++ b/Control/Utilities/ShellCommandUtilities.swift @@ -20,13 +20,13 @@ struct ShellCommandUtilities { let escapedScript = escapeBashString(appleScript) return """ - bash -c "osascript << 'APPLESCRIPT' - try - \(escapedScript) - on error errMsg - return errMsg - end try - APPLESCRIPT" + bash -c \"osascript << 'APPLESCRIPT'\n try\n \(escapedScript)\n on error errMsg\n return errMsg\n end try\n 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 + } } From d6a927b833a44c98db6ef4d48a4f0aec14a47243 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sat, 5 Jul 2025 04:52:02 -0700 Subject: [PATCH 10/25] all apps except safari and TV working with streaming shell --- Control/Components/PlatformControlPanel.swift | 2 + Control/Platforms/AppController.swift | 43 +++++-- .../Platforms/Implementations/Chrome.swift | 10 +- .../Platforms/Implementations/IINAApp.swift | 110 ++++++------------ .../Implementations/QuickTimeApp.swift | 10 +- .../Platforms/Implementations/SafariApp.swift | 14 ++- .../Implementations/SpotifyApp.swift | 15 ++- Control/Platforms/Implementations/TVApp.swift | 73 ++++++------ .../Platforms/Implementations/VLCApp.swift | 51 ++++---- Control/SSH/ChannelExecutor.swift | 29 +++-- Control/SSH/SSHConnectionManager.swift | 66 ++--------- Control/SSH/StreamingShellHandler.swift | 91 +++++++++++---- 12 files changed, 276 insertions(+), 238 deletions(-) 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 d076888..560165e 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -92,8 +92,12 @@ class AppController: ObservableObject { // If this is the initial update, give channels a moment to fully initialize if !hasCompletedInitialUpdate { - appControllerLog("Initial state update - waiting for channels to stabilize") - try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second for initial setup + appControllerLog("Initial state update - waiting for channels to stabilize (shortened)") + // 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 } // Mark as batch operation to reduce heartbeats @@ -158,6 +162,8 @@ class AppController: ObservableObject { switch result { case .success(let output): + appControllerLog("πŸ“Š \(platform.name) status response: [\(output)]") + if output.contains("Not authorized to send Apple events") { let newState = AppState( title: "Permissions Required", @@ -177,6 +183,8 @@ class AppController: ObservableObject { } } else { let newState = platform.parseState(output) + appControllerLog("πŸ“Š \(platform.name) parsed state: title=[\(newState.title)], subtitle=[\(newState.subtitle)], isPlaying=\(String(describing: newState.isPlaying))") + // 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 || @@ -189,11 +197,25 @@ class AppController: ObservableObject { } } 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 + 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 + } } } @@ -211,8 +233,11 @@ class AppController: ObservableObject { switch result { case .success(let output): let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) + appControllerLog("πŸ“Š \(platform.name) isRunning response: [\(trimmedOutput)]") + // Handle both boolean results (true/false) and string results ("true"/"false") let isRunning = trimmedOutput == "true" || trimmedOutput == "\"true\"" + appControllerLog("πŸ“Š \(platform.name) isRunning parsed: \(isRunning)") return isRunning case .failure(let error): appControllerLog("❌ Failed to check if \(platform.name) is running: \(error)") @@ -247,6 +272,8 @@ class AppController: ObservableObject { } else if let lastLine = lines.last?.trimmingCharacters(in: .whitespacesAndNewlines), !lastLine.isEmpty { let newState = platform.parseState(lastLine) + appControllerLog("πŸ“Š \(platform.name) action response: [\(lastLine)]") + appControllerLog("πŸ“Š \(platform.name) parsed state after action: title=[\(newState.title)], subtitle=[\(newState.subtitle)], isPlaying=\(String(describing: newState.isPlaying))") states[platform.id] = newState } case .failure(let error): @@ -272,7 +299,7 @@ class AppController: ObservableObject { let result = await executeCommand(script, channelKey: "system", description: "Set volume(\(target)%)", bypassHeartbeat: true) switch result { - case .success(let output): + case .success(_): appControllerLog("βœ“ Volume set to \(target)%") case .failure(let error): appControllerLog("❌ Failed to set volume: \(error)") diff --git a/Control/Platforms/Implementations/Chrome.swift b/Control/Platforms/Implementations/Chrome.swift index 4f03907..994d8dd 100644 --- a/Control/Platforms/Implementations/Chrome.swift +++ b/Control/Platforms/Implementations/Chrome.swift @@ -19,7 +19,15 @@ struct ChromeApp: AppPlatform { // Checks if Chrome is running func isRunningScript() -> String { - "tell application \"System Events\" to exists (processes where name is \"Google Chrome\")" + """ + tell application "System Events" + if exists (processes where name is "Google Chrome") then + return "true" + else + return "false" + end if + end tell + """ } // Template status script that can optionally inject action AppleScript diff --git a/Control/Platforms/Implementations/IINAApp.swift b/Control/Platforms/Implementations/IINAApp.swift index 19be70f..e760c95 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -18,51 +18,27 @@ struct IINAApp: AppPlatform { } func isRunningScript() -> String { - "tell application \"System Events\" to exists (processes where name is \"IINA\")" + // Match Spotify's pattern for System Events + """ + tell application "System Events" + if exists (processes where name is "IINA") then + return "true" + else + return "false" + end if + end tell + """ } - // Template status script that can optionally inject action AppleScript + // Everything must be done through System Events since IINA has no AppleScript support private func statusScript(actionLines: String = "") -> String { """ tell application "System Events" \(actionLines) - 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" + if not (exists (processes where name is "IINA")) then + return "Not running||| |||stopped|||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,8 +46,14 @@ 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 & "|||false" + else + return "No window||| |||" & isPlaying & "|||false" + end if + end tell end tell """ } @@ -79,12 +61,14 @@ struct IINAApp: AppPlatform { func fetchState() -> String { statusScript() } func actionWithStatus(_ action: AppAction) -> String { - statusScript(actionLines: executeAction(action)) + // Add delay after action like Spotify does + let delayScript = "delay 0.3\n" + 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), @@ -101,42 +85,24 @@ struct IINAApp: AppPlatform { } func executeAction(_ action: AppAction) -> String { + // Actions need IINA to be frontmost + var cmd = "tell process \"IINA\" to set frontmost to true\n" + cmd += "delay 0.1\n" + switch action { case .playPauseToggle: - return """ - tell application "IINA" to activate - tell process "IINA" - key code 49 -- spacebar - end tell - """ + cmd += "keystroke space" case .skipBackward: - return """ - tell application "IINA" to activate - tell process "IINA" - key code 123 -- left arrow - end tell - """ + cmd += "key code 123" case .skipForward: - return """ - tell application "IINA" to activate - tell process "IINA" - key code 124 -- right arrow - end tell - """ + cmd += "key code 124" case .previousTrack: - return """ - tell application "IINA" to activate - tell process "IINA" - key code 123 using {command down} -- cmd+left - end tell - """ + cmd += "key code 123 using {command down}" case .nextTrack: - return """ - tell application "IINA" to activate - tell process "IINA" - key code 124 using {command down} -- cmd+right - end tell - """ + cmd += "key code 124 using {command down}" } + + return cmd } } + diff --git a/Control/Platforms/Implementations/QuickTimeApp.swift b/Control/Platforms/Implementations/QuickTimeApp.swift index bc7c29e..1c2e1bb 100644 --- a/Control/Platforms/Implementations/QuickTimeApp.swift +++ b/Control/Platforms/Implementations/QuickTimeApp.swift @@ -16,7 +16,15 @@ struct QuickTimeApp: AppPlatform { } func isRunningScript() -> String { - "tell application \"System Events\" to exists (processes where name is \"QuickTime Player\")" + """ + tell application "System Events" + if exists (processes where name is "QuickTime Player") then + return "true" + else + return "false" + end if + end tell + """ } private func statusScript(actionLines: String = "") -> String { diff --git a/Control/Platforms/Implementations/SafariApp.swift b/Control/Platforms/Implementations/SafariApp.swift index a7ccaa3..634bbbb 100644 --- a/Control/Platforms/Implementations/SafariApp.swift +++ b/Control/Platforms/Implementations/SafariApp.swift @@ -18,7 +18,15 @@ struct SafariApp: AppPlatform { } func isRunningScript() -> String { - "tell application \"System Events\" to exists (processes where name is \"Safari\")" + """ + tell application "System Events" + if exists (processes where name is "Safari") then + return "true" + else + return "false" + end if + end tell + """ } private func statusScript(actionLines: String = "") -> String { @@ -60,10 +68,10 @@ struct SafariApp: AppPlatform { func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") - if components.count >= 3 { + if components.count >= 4 { let title = components[0].trimmingCharacters(in: .whitespacesAndNewlines) let subtitle = components[1].trimmingCharacters(in: .whitespacesAndNewlines) - let isPlayingStr = components[2].trimmingCharacters(in: .whitespacesAndNewlines) + let isPlayingStr = components[3].trimmingCharacters(in: .whitespacesAndNewlines) let isPlaying = isPlayingStr == "true" return AppState( diff --git a/Control/Platforms/Implementations/SpotifyApp.swift b/Control/Platforms/Implementations/SpotifyApp.swift index e605582..886c927 100644 --- a/Control/Platforms/Implementations/SpotifyApp.swift +++ b/Control/Platforms/Implementations/SpotifyApp.swift @@ -16,7 +16,15 @@ struct SpotifyApp: AppPlatform { } func isRunningScript() -> String { - "tell application \"System Events\" to exists (processes where name is \"Spotify\")" + """ + tell application "System Events" + if exists (processes where name is "Spotify") then + return "true" + else + return "false" + end if + end tell + """ } // Template status script that can optionally inject action AppleScript @@ -73,6 +81,9 @@ struct SpotifyApp: AppPlatform { } func actionWithStatus(_ action: AppAction) -> String { - statusScript(actionLines: executeAction(action)) + // 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 442378e..151441b 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -23,45 +23,52 @@ struct TVApp: AppPlatform { """ tell application "TV" \(actionLines) - -- 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 + -- Grab the raw player state: can be "playing", "paused", or "stopped". + set rawState to player state as text - if media kind of currentProperties is TV show then - set showName to show of current track - else - set showName to "" - end if + -- Initialize variables + set currentTrack to missing value + set frontWindow to missing value + set trackName to "" + set showName to "" - 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" + -- Try to get the current track + try + set currentTrack to name of current track + set trackName to currentTrack + + -- Try to get show name if it's a TV show + try + set currentProperties to properties of current track + if media kind of currentProperties is TV show then + set showName to show of current track + end if + end try + end try + + -- If no track, try to get window name + if currentTrack is missing value then + try + set frontWindow to name of front window + if frontWindow is not "TV" then + set trackName to frontWindow + end if + end try end if - else if frontWindow is not "TV" then - if rawState is \"playing\" then - return frontWindow & "||| |||" & "playing" & "|||" & "true" + + -- Determine final output based on what we found + if trackName is "" then + return "Nothing playing ||| ||| stopped ||| false" + else if rawState is "playing" then + return trackName & "|||" & showName & "|||playing|||true" else - return frontWindow & "||| |||" & "paused" & "|||" & "false" + return trackName & "|||" & showName & "|||paused|||false" end if - else - -- If we can't retrieve a track, there's truly no video playing. - return "Nothing playing ||| ||| stopped ||| false" - end if + + on error errMsg + return "Error: " & errMsg & "||| ||| error ||| false" + end try end tell """ } diff --git a/Control/Platforms/Implementations/VLCApp.swift b/Control/Platforms/Implementations/VLCApp.swift index 8765496..292b8f3 100644 --- a/Control/Platforms/Implementations/VLCApp.swift +++ b/Control/Platforms/Implementations/VLCApp.swift @@ -25,36 +25,21 @@ struct VLCApp: AppPlatform { """ tell application "VLC" \(actionLines) + if not running then + return "Not running ||| ||| stopped |||false" + end if try - -- Check if VLC is currently running - if not running then - return "Not running ||| ||| stopped |||false" - end if - - -- Check playback status + set mediaName to name of current item 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" + return mediaName & "||| ||| playing ||| true" else - try - set mediaName to name of current item - return mediaName & "||| ||| false ||| false " - on error - return "Nothing playing ||| ||| false ||| false" - end try + return mediaName & "||| ||| paused ||| false" end if - - on error errMsg - -- Handle errors gracefully - if errMsg contains "Not authorized to send Apple events" then - error errMsg + on error + if playing then + return "Unknown media ||| ||| playing ||| true" else - return "Error: " & errMsg & "||| false |||false" + return "Nothing playing ||| ||| paused ||| false" end if end try end tell @@ -64,16 +49,26 @@ struct VLCApp: AppPlatform { func fetchState() -> String { statusScript() } func actionWithStatus(_ action: AppAction) -> String { - statusScript(actionLines: executeAction(action)) + 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 ) } diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index eee61df..97a4e40 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -86,11 +86,11 @@ actor ChannelExecutor { return } - let testSentinel = "__PING_\(UUID().uuidString.prefix(4))__" + let testSentinel = ">>>VOLCTL_PING_\(UUID().uuidString.prefix(4))<<<" let testPayload: String if interactiveAppleScript { - testPayload = "1 + 1\n\ndo shell script \"echo \(testSentinel)\"\n\n" + testPayload = "1 + 1\n\n\"\(testSentinel)\"\n\n" } else { testPayload = "echo 'test-ok'; printf '\\n%s\\n' \(testSentinel)\n" } @@ -105,10 +105,10 @@ actor ChannelExecutor { // Wait for the test result with a timeout do { - let testResult = try await withTimeout(seconds: 3.0) { + _ = try await withTimeout(seconds: 3.0) { return try await promise.futureResult.get() } - print("πŸ”§ ChannelExecutor: βœ“ Test ping successful") + // Test ping successful - no need to log } catch { print("πŸ”§ ChannelExecutor: ❌ Test ping failed: \(error)") } @@ -117,7 +117,6 @@ actor ChannelExecutor { /// Executes `command` by opening a fresh exec channel on the existing SSH connection. /// The promise is fulfilled when the command finishes (or fails). func run(command: String, description: String?) async -> Result { - let commandPreview = String(command.prefix(50)) if let description = description { print("πŸ”§ ChannelExecutor: \(description)") } @@ -139,7 +138,7 @@ actor ChannelExecutor { return await withCheckedContinuation { continuation in let sentinelSuffix = String(UUID().uuidString.prefix(6)) - let sentinel = "__END__\(sentinelSuffix)__" + let sentinel = ">>>VOLCTL_END_\(sentinelSuffix)<<<" let promise = chan.eventLoop.makePromise(of: String.self) self.shellHandler.addCommand(sentinel: sentinel, promise: promise) @@ -148,12 +147,11 @@ actor ChannelExecutor { if self.interactiveAppleScript { // For interactive AppleScript, send the command, blank line to execute, then sentinel let escapedSentinel = sentinel.replacingOccurrences(of: "\"", with: "\\\"") - // Introduce a short delay before echoing the sentinel so that the command's - // output has time to reach stdout before we mark completion. This mitigates - // race-conditions where the sentinel might otherwise arrive first and cause - // us to read an empty or incomplete result. - payload = "\(command)\n\n-- Small delay to allow stdout to flush before sentinel\ndelay 0.05\ndo shell script \"echo \(escapedSentinel)\"\n\n" - print("πŸ”§ ChannelExecutor: Sending to osascript: '\(command)'") + payload = "\(command)\n\n\"\(escapedSentinel)\"\n\n" + // Only log the description, not the full command + if let desc = description { + print("πŸ”§ ChannelExecutor: \(desc)") + } } else { payload = "\(command); printf '\\n%s\\n' \(sentinel)\n" } @@ -162,7 +160,14 @@ actor ChannelExecutor { let writePromise = chan.eventLoop.makePromise(of: Void.self) chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: writePromise) + // Add timeout to the promise + let timeoutTask = chan.eventLoop.scheduleTask(in: .seconds(8)) { + print("πŸ”§ ChannelExecutor: ⏰ Command timed out after 8 seconds") + promise.fail(SSHError.timeout) + } + promise.futureResult.whenComplete { result in + timeoutTask.cancel() continuation.resume(returning: result) } } diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 23dd45e..b96f316 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -297,7 +297,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { /// 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, bypassHeartbeat: Bool = false, completion: @escaping (Result) -> Void) { - sshLog("SSHConnectionManager: Executing command on channel \(channelKey) with proactive timeout monitoring") + sshLog("SSHConnectionManager: Executing command on channel \(channelKey)") if let description = description { sshLog("Command: \(description)") } @@ -313,7 +313,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 @@ -322,22 +321,12 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { completion(.failure(SSHError.timeout)) } - let timeoutSeconds: Double = 10.0 - sshLog("⏰ [\(commandId)] Starting \(Int(timeoutSeconds))-second proactive timeout monitor") + let timeoutSeconds: Double = 15.0 + sshLog("⏰ [\(commandId)] Starting \(Int(timeoutSeconds))-second timeout monitor") DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutTask) - // Choose execution path - if bypassHeartbeat { - client.executeCommandOnDedicatedChannel(channelKey, command, description: description) { [weak self] result in - guard !hasCompleted else { return } - hasCompleted = true - timeoutTask.cancel() - completion(result) - } - return - } - - // Execute the command on dedicated channel with heartbeat verification + // 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") @@ -351,22 +340,8 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { switch result { case .success(let output): - sshLog("βœ“ [\(commandId)] Command succeeded, sending verification heartbeat") - // Command succeeded, send verification heartbeat unless bypass requested - 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)") if self?.isConnectionLossError(error) == true { @@ -395,47 +370,22 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { executeCommand(onChannel: "system", command, description: description, bypassHeartbeat: true, completion: completion) } - /// 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 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) } } diff --git a/Control/SSH/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift index 24f3a78..27bd5f0 100644 --- a/Control/SSH/StreamingShellHandler.swift +++ b/Control/SSH/StreamingShellHandler.swift @@ -55,6 +55,11 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { // Handle stderr separately if payload.type == .stdErr { print("πŸ” StreamingShellHandler: ❌ Stderr: '\(string.trimmingCharacters(in: .whitespacesAndNewlines))'") + // 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 } @@ -69,11 +74,19 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { let currentBuffer = queue[0].buffer let expectedSentinel = queue[0].sentinel + // Check if buffer is getting too large (possible stuck command) + if currentBuffer.count > 50000 { + print("πŸ” StreamingShellHandler: ⚠️ Buffer overflow - command may be stuck") + let pending = queue.removeFirst() + pending.promise.fail(SSHError.channelError("Buffer overflow - response too large")) + return + } + // Try both formats: // 1. Interactive osascript format: => "sentinel" - // 2. Shell format: plain sentinel + // 2. Direct AppleScript result format with our sentinel let osascriptSentinelPattern = "=> \"\(expectedSentinel)\"" - let shellSentinelPattern = expectedSentinel + let directSentinelPattern = "=> \"\(expectedSentinel)\"" // Same as osascript pattern var sentinelRange: Range? var isOsascriptFormat = false @@ -82,9 +95,10 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { if let range = currentBuffer.range(of: osascriptSentinelPattern) { sentinelRange = range isOsascriptFormat = true - } else if let range = currentBuffer.range(of: shellSentinelPattern) { + } else if let range = currentBuffer.range(of: directSentinelPattern, options: .backwards) { + // This is also an AppleScript result format sentinelRange = range - isOsascriptFormat = false + isOsascriptFormat = true } if let sentinelRange = sentinelRange { @@ -95,14 +109,19 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { if isOsascriptFormat { // Parse AppleScript output - look for => "result" lines - scriptOutput = extractAppleScriptResult(from: outputPart) + let (result, isError) = extractAppleScriptResult(from: outputPart) + if isError { + // Complete with error + let pending = queue.removeFirst() + pending.promise.fail(SSHError.channelError("AppleScript error: \(result)")) + return + } + scriptOutput = result } else { // Parse shell/mixed output - look for AppleScript results or clean shell output scriptOutput = extractCleanOutput(from: outputPart) } - print("πŸ” StreamingShellHandler: βœ“ Result: '\(scriptOutput)'") - // Complete the promise with the parsed output let pending = queue.removeFirst() pending.promise.succeed(scriptOutput) @@ -110,19 +129,46 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { } /// Extract clean AppleScript result from => "result" format - private func extractAppleScriptResult(from output: String) -> String { + /// 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 if content.hasPrefix("\"") && content.hasSuffix("\"") && content.count > 1 { - return String(content.dropFirst().dropLast()) + let unquoted = String(content.dropFirst().dropLast()) + // One more check on the unquoted content + if unquoted.hasPrefix("!!") || unquoted.contains("error") { + return (unquoted, true) + } + return (unquoted, false) } else { - return content + return (content, false) } } } @@ -130,6 +176,8 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { // 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(">>") && @@ -138,20 +186,26 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { !trimmed.hasPrefix("[") && !trimmed.hasPrefix("]") && !trimmed.contains("Welcome to fish") && - !trimmed.contains("osascript") && !trimmed.hasPrefix("tell ") && !trimmed.hasPrefix("end tell") && - !trimmed.contains("do shell script") && - !trimmed.contains("echo ") && 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()) + return (String(trimmed.dropFirst()), false) } - return trimmed + return (trimmed, false) } } - return "" + return ("", false) } /// Extract clean output from mixed shell/AppleScript output @@ -174,7 +228,7 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { for line in lines.reversed() { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - // Skip all the noise + // Skip all the noise - but be more precise about what we filter if trimmed.isEmpty || trimmed.hasPrefix("?>") || trimmed.hasPrefix(">>") || @@ -189,9 +243,6 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { trimmed.hasPrefix("bash -c") || trimmed.hasPrefix("tell ") || trimmed.hasPrefix("end tell") || - trimmed.contains("do shell script") || - trimmed.contains("osascript") || - trimmed.contains("echo ") || trimmed.contains("APPLESCRIPT") { continue } From 59969ebf3e8de87fb66170fe962bdece88270d7c Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sat, 5 Jul 2025 05:23:50 -0700 Subject: [PATCH 11/25] get tv working with streaming and local files --- Control/Platforms/Implementations/TVApp.swift | 83 +++++++------------ 1 file changed, 32 insertions(+), 51 deletions(-) diff --git a/Control/Platforms/Implementations/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index 151441b..ddc9413 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -24,50 +24,34 @@ struct TVApp: AppPlatform { tell application "TV" \(actionLines) try - -- Grab the raw player state: can be "playing", "paused", or "stopped". set rawState to player state as text + if rawState is "stopped" then + return "Nothing playing||| |||stopped|||false" + end if - -- Initialize variables - set currentTrack to missing value - set frontWindow to missing value set trackName to "" - set showName to "" - - -- Try to get the current track try - set currentTrack to name of current track - set trackName to currentTrack - - -- Try to get show name if it's a TV show - try - set currentProperties to properties of current track - if media kind of currentProperties is TV show then - set showName to show of current track - end if - end try + set trackName to name of current track end try - -- If no track, try to get window name - if currentTrack is missing value then + -- If no track name, try window for streaming content + if trackName is "" then try - set frontWindow to name of front window - if frontWindow is not "TV" then - set trackName to frontWindow + set windowName to name of front window + if windowName is not "TV" then + set trackName to windowName end if end try end if - -- Determine final output based on what we found if trackName is "" then - return "Nothing playing ||| ||| stopped ||| false" - else if rawState is "playing" then - return trackName & "|||" & showName & "|||playing|||true" - else - return trackName & "|||" & showName & "|||paused|||false" + return "Nothing playing||| |||stopped|||false" end if + set isPlaying to (rawState is "playing") + return trackName & "||| |||" & rawState & "|||" & isPlaying on error errMsg - return "Error: " & errMsg & "||| ||| error ||| false" + return "Error||| |||error|||false" end try end tell """ @@ -76,7 +60,15 @@ struct TVApp: AppPlatform { func fetchState() -> String { statusScript() } func actionWithStatus(_ action: AppAction) -> String { - statusScript(actionLines: executeAction(action)) + 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 { @@ -103,33 +95,22 @@ struct TVApp: AppPlatform { return "playpause" case .skipBackward: return """ - activate + tell application "TV" to activate + delay 0.1 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" - tell process "TV" to key code 123 -- Left Arrow - end tell - end if - on error errMsg - return "Error: " & errMsg + tell application "TV" to set player position to ((get player position) - 10) + on error + tell application "System Events" to tell process "TV" to key code 123 end try """ case .skipForward: return """ - activate + tell application "TV" to activate + delay 0.1 try - set currentPosition to player position - if currentPosition is not missing value then - set player position to currentPosition + 10 - else - tell application "System Events" to tell process "TV" to key code 124 -- Right Arrow - end if - on error errMsg - return "Error: " & errMsg + tell application "TV" to set player position to ((get player position) + 10) + on error + tell application "System Events" to tell process "TV" to key code 124 end try """ default: From 17d72fb08df1d20f77e6776c5fed50174080a201 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sat, 5 Jul 2025 11:35:31 -0700 Subject: [PATCH 12/25] get safari working --- .../Platforms/Implementations/SafariApp.swift | 157 +++++++----------- 1 file changed, 57 insertions(+), 100 deletions(-) diff --git a/Control/Platforms/Implementations/SafariApp.swift b/Control/Platforms/Implementations/SafariApp.swift index 634bbbb..ce5f059 100644 --- a/Control/Platforms/Implementations/SafariApp.swift +++ b/Control/Platforms/Implementations/SafariApp.swift @@ -18,130 +18,87 @@ struct SafariApp: AppPlatform { } func isRunningScript() -> String { - """ - tell application "System Events" - if exists (processes where name is "Safari") then - return "true" - else - return "false" - end if - end tell - """ + // 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 statusScript(actionLines: String = "") -> String { - """ + private func jsForStatus() -> String { + return "(function() { const v = document.querySelector('video'); if (!v) return 'No video found|||Safari|||stopped|||false'; const title = document.title.replace(' - YouTube', '') || 'Unknown Video'; const site = window.location.hostname.replace('www.', ''); const playing = !v.paused && !v.ended; const state = playing ? 'playing' : 'paused'; return title + '|||' + site + '|||' + state + '|||' + 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" - \(actionLines) - set windowCount to count of windows - if windowCount is 0 then - return "Nothing playing ||| ||| false ||| false" + if (count of windows) is 0 then + return "No windows open|||Safari|||stopped|||false" end if - try - set currentTab to current tab of window 1 - set videoScript to " - (function() { - var video = document.querySelector('video'); - if (!video) return 'Nothing playing ||| ||| false ||| false'; - var title = document.title.replace(' - YouTube', '') || 'Unknown Video'; - var siteName = window.location.hostname.replace('www.', ''); - var isPlaying = !video.paused && !video.ended; - - return title + ' ||| ' + siteName + ' ||| ' + (isPlaying ? 'true' : 'false') + ' ||| ' + (isPlaying ? 'true' : 'false'); - })(); - " - set videoInfo to do JavaScript videoScript in currentTab - return videoInfo - end try - - return "Nothing playing ||| ||| false ||| false" + return do JavaScript "\(js)" in current tab of front window end tell """ } - func fetchState() -> String { statusScript() } - func actionWithStatus(_ action: AppAction) -> String { - statusScript(actionLines: executeAction(action)) + let actionJs = jsForAction(action) + let statusJs = jsForStatus() + + // Build a single, direct script with the window check. + return """ + tell application "Safari" + if (count of windows) is 0 then + return "No windows open|||Safari|||stopped|||false" + end if + do JavaScript "\(actionJs)" in current tab of front window + delay 0.3 + 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 >= 4 { - let title = components[0].trimmingCharacters(in: .whitespacesAndNewlines) - let subtitle = components[1].trimmingCharacters(in: .whitespacesAndNewlines) - let isPlayingStr = components[3].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[3].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: "Safari", 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 """ - set windowCount to count of windows - if windowCount is 0 then return - try - set currentTab to current tab of window 1 - do JavaScript " - (function() { - var video = document.querySelector('video'); - if (video) { - if (video.paused || video.ended) { - video.play(); - } else { - video.pause(); - } - } - })(); - " in currentTab - end try - """ - case .skipForward(let seconds): - return """ - set windowCount to count of windows - if windowCount is 0 then return - try - set currentTab to current tab of window 1 - do JavaScript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime += \(seconds); - })(); - " in currentTab - end try - """ - case .skipBackward(let seconds): - return """ - set windowCount to count of windows - if windowCount is 0 then return - - try - set currentTab to current tab of window 1 - do JavaScript " - (function() { - const media = document.querySelector('video, audio'); - if (media) media.currentTime -= \(seconds); - })(); - " in currentTab - end try - """ - default: - return "" - } + return "" } } From 7e4bb1c302f100b7ba8e66f9c20022d523bec044 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sat, 5 Jul 2025 13:01:31 -0700 Subject: [PATCH 13/25] get permissions and all apps working + remove legacy methods --- Control/Platforms/AppController.swift | 98 +--- .../Platforms/Implementations/IINAApp.swift | 16 +- .../Platforms/Implementations/MusicApp.swift | 8 +- .../Platforms/Implementations/SafariApp.swift | 10 +- .../Implementations/SpotifyApp.swift | 10 +- Control/Platforms/Implementations/TVApp.swift | 12 +- Control/SSH/ChannelExecutor.swift | 63 +-- Control/SSH/SSHClient.swift | 521 ++---------------- Control/SSH/SSHClientProtocol.swift | 10 - Control/SSH/SSHConnectionManager.swift | 23 +- Control/Utilities/String+Extensions.swift | 16 + Control/Views/PermissionsView.swift | 19 +- 12 files changed, 139 insertions(+), 667 deletions(-) create mode 100644 Control/Utilities/String+Extensions.swift diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 560165e..09f44df 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -6,7 +6,6 @@ class AppController: ObservableObject { private var platformRegistry: PlatformRegistry private var isUpdating = false @Published var isActive = true - static var debugMode = true // Add debug flag for troubleshooting // Add batch operation flag to reduce heartbeats private var isBatchOperation = false @@ -26,7 +25,7 @@ class AppController: ObservableObject { } init(sshClient: SSHClientProtocol, platformRegistry: PlatformRegistry) { - appControllerLog("AppController: Initializing") + appControllerLog("AppController: Initializing with \(platformRegistry.activePlatforms.count) active platforms") self.sshClient = sshClient self.platformRegistry = platformRegistry @@ -43,7 +42,6 @@ class AppController: ObservableObject { isActive = true isUpdating = false hasCompletedInitialUpdate = false - // Don't reset states - they'll update naturally when we get new data } func cleanup() { @@ -58,9 +56,7 @@ class AppController: ObservableObject { } func updatePlatformRegistry(_ newRegistry: PlatformRegistry) { - appControllerLog("AppController: Updating platform registry") - appControllerLog("Previous platform count: \(platformRegistry.platforms.count)") - appControllerLog("New platform count: \(newRegistry.platforms.count)") + appControllerLog("AppController: Updating platform registry to \(newRegistry.activePlatforms.map { $0.name })") self.platformRegistry = newRegistry @@ -77,9 +73,6 @@ 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 { @@ -92,7 +85,6 @@ class AppController: ObservableObject { // If this is the initial update, give channels a moment to fully initialize if !hasCompletedInitialUpdate { - appControllerLog("Initial state update - waiting for channels to stabilize (shortened)") // 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 @@ -104,6 +96,7 @@ class AppController: ObservableObject { isBatchOperation = true defer { isBatchOperation = false + hasCompletedInitialUpdate = true appControllerLog("βœ“ State update complete") } @@ -120,12 +113,6 @@ class AppController: ObservableObject { } } } - - // Send single verification heartbeat at end of batch operation - if isActive { - _ = await executeCommand("true", channelKey: "system", description: "Batch verification") - hasCompletedInitialUpdate = true - } } func updateState(for platform: any AppPlatform) async { @@ -162,7 +149,6 @@ class AppController: ObservableObject { switch result { case .success(let output): - appControllerLog("πŸ“Š \(platform.name) status response: [\(output)]") if output.contains("Not authorized to send Apple events") { let newState = AppState( @@ -183,7 +169,7 @@ class AppController: ObservableObject { } } else { let newState = platform.parseState(output) - appControllerLog("πŸ“Š \(platform.name) parsed state: title=[\(newState.title)], subtitle=[\(newState.subtitle)], isPlaying=\(String(describing: newState.isPlaying))") + appControllerLog("πŸ“Š \(platform.name) parsed state: title=[\(newState.title.redacted())], isPlaying=\(String(describing: newState.isPlaying))") // Only update if we don't have a previous state or if the state has changed let currentState = states[platform.id] @@ -233,11 +219,8 @@ class AppController: ObservableObject { switch result { case .success(let output): let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) - appControllerLog("πŸ“Š \(platform.name) isRunning response: [\(trimmedOutput)]") - - // Handle both boolean results (true/false) and string results ("true"/"false") let isRunning = trimmedOutput == "true" || trimmedOutput == "\"true\"" - appControllerLog("πŸ“Š \(platform.name) isRunning parsed: \(isRunning)") + appControllerLog("πŸ“Š \(platform.name) isRunning: \(isRunning)") return isRunning case .failure(let error): appControllerLog("❌ Failed to check if \(platform.name) is running: \(error)") @@ -272,8 +255,7 @@ class AppController: ObservableObject { } else if let lastLine = lines.last?.trimmingCharacters(in: .whitespacesAndNewlines), !lastLine.isEmpty { let newState = platform.parseState(lastLine) - appControllerLog("πŸ“Š \(platform.name) action response: [\(lastLine)]") - appControllerLog("πŸ“Š \(platform.name) parsed state after action: title=[\(newState.title)], subtitle=[\(newState.subtitle)], isPlaying=\(String(describing: newState.isPlaying))") + appControllerLog("πŸ“Š \(platform.name) parsed state after action: title=[\(newState.title.redacted())], isPlaying=\(String(describing: newState.isPlaying))") states[platform.id] = newState } case .failure(let error): @@ -289,10 +271,7 @@ class AppController: ObservableObject { } func setVolume(_ volume: Float) async { - guard isActive else { - appControllerLog("⚠️ Controller not active, skipping volume change") - return - } + guard isActive else { return } let target = Int(volume * 100) let script = "set volume output volume \(target)" @@ -300,7 +279,8 @@ class AppController: ObservableObject { switch result { case .success(_): - appControllerLog("βœ“ Volume set to \(target)%") + // 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 @@ -326,7 +306,7 @@ class AppController: ObservableObject { case .success(let output): if let volume = Float(output.trimmingCharacters(in: .whitespacesAndNewlines)) { currentVolume = volume / 100.0 - appControllerLog("βœ“ Current volume: \(Int(volume))%") + appControllerLog("βœ“ System volume: \(Int(volume))%") } else { appControllerLog("⚠️ Could not parse volume from output: '\(output)'") currentVolume = nil @@ -352,13 +332,7 @@ class AppController: ObservableObject { return .failure(SSHError.channelError("Controller not active")) } - let wrappedCommand: String - if channelKey == "system" { - // System commands (volume) should use pure AppleScript, not bash-wrapped - wrappedCommand = ShellCommandUtilities.appleScriptForStreaming(command) - } else { - wrappedCommand = ShellCommandUtilities.appleScriptForStreaming(command) - } + let wrappedCommand = ShellCommandUtilities.appleScriptForStreaming(command) return await withCheckedContinuation { [weak self] continuation in guard let self = self else { @@ -366,47 +340,19 @@ class AppController: ObservableObject { return } - // Use heartbeat-optimized execution during batch operations - if self.isBatchOperation || bypassHeartbeat { - // During batch operations or when bypass explicitly requested, still use the dedicated channel but let the manager decide heartbeat behaviour - self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in - switch result { - case .success(let output): - continuation.resume(returning: result) - case .failure(let error): - appControllerLog("❌ Command 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 - connectionManager.handleConnectionLost() - } - - continuation.resume(returning: result) - } - } - } else { - // Always use new channel for reliability - revert the session reuse optimization - self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in - switch result { - case .success(let output): - continuation.resume(returning: result) - case .failure(let error): - appControllerLog("❌ Command 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 - connectionManager.handleConnectionLost() - } - - continuation.resume(returning: result) + self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in + if case .failure(let error) = result { + appControllerLog("❌ Command 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 + connectionManager.handleConnectionLost() } } + continuation.resume(returning: result) } } } diff --git a/Control/Platforms/Implementations/IINAApp.swift b/Control/Platforms/Implementations/IINAApp.swift index e760c95..7541b5e 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -36,7 +36,7 @@ struct IINAApp: AppPlatform { tell application "System Events" \(actionLines) if not (exists (processes where name is "IINA")) then - return "Not running||| |||stopped|||false" + return "Not running||| |||false" end if set isPlaying to false try @@ -49,9 +49,9 @@ struct IINAApp: AppPlatform { tell process "IINA" if (count of windows) > 0 then set windowTitle to name of front window - return windowTitle & "||| |||" & isPlaying & "|||false" + return windowTitle & "||| |||" & isPlaying else - return "No window||| |||" & isPlaying & "|||false" + return "No window||| |||" & isPlaying end if end tell end tell @@ -68,9 +68,15 @@ struct IINAApp: AppPlatform { func parseState(_ output: String) -> AppState { let components = output.components(separatedBy: "|||") - if components.count >= 4 { + if components.count >= 3 { + var title = components[0].trimmingCharacters(in: .whitespacesAndNewlines) + // IINA often includes the full path after a dash, so we strip it. + if let range = title.range(of: " β€” ") { + title = String(title[.. 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 ) } diff --git a/Control/Platforms/Implementations/SafariApp.swift b/Control/Platforms/Implementations/SafariApp.swift index ce5f059..cf27399 100644 --- a/Control/Platforms/Implementations/SafariApp.swift +++ b/Control/Platforms/Implementations/SafariApp.swift @@ -23,7 +23,7 @@ struct SafariApp: AppPlatform { } private func jsForStatus() -> String { - return "(function() { const v = document.querySelector('video'); if (!v) return 'No video found|||Safari|||stopped|||false'; const title = document.title.replace(' - YouTube', '') || 'Unknown Video'; const site = window.location.hostname.replace('www.', ''); const playing = !v.paused && !v.ended; const state = playing ? 'playing' : 'paused'; return title + '|||' + site + '|||' + state + '|||' + playing; })();" + return "(function() { const v = document.querySelector('video'); if (!v) return 'No video found|||Safari|||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 { @@ -48,7 +48,7 @@ struct SafariApp: AppPlatform { return """ tell application "Safari" if (count of windows) is 0 then - return "No windows open|||Safari|||stopped|||false" + return "No windows open|||Safari|||false" end if return do JavaScript "\(js)" in current tab of front window end tell @@ -63,7 +63,7 @@ struct SafariApp: AppPlatform { return """ tell application "Safari" if (count of windows) is 0 then - return "No windows open|||Safari|||stopped|||false" + return "No windows open|||Safari|||false" end if do JavaScript "\(actionJs)" in current tab of front window delay 0.3 @@ -75,11 +75,11 @@ struct SafariApp: AppPlatform { 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 ) } diff --git a/Control/Platforms/Implementations/SpotifyApp.swift b/Control/Platforms/Implementations/SpotifyApp.swift index 886c927..4e678a9 100644 --- a/Control/Platforms/Implementations/SpotifyApp.swift +++ b/Control/Platforms/Implementations/SpotifyApp.swift @@ -33,16 +33,16 @@ struct SpotifyApp: AppPlatform { tell application "Spotify" \(actionLines) if not running then - return "Not running ||| |||stopped|||false" + 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 & "|||" & playerState & "|||" & isPlaying + return trackName & "|||" & artistName & "|||" & isPlaying end try - return "Nothing playing ||| |||" & false & "|||" & false + return "Nothing playing ||| |||" & false end tell """ } @@ -51,11 +51,11 @@ struct SpotifyApp: AppPlatform { 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 ) } diff --git a/Control/Platforms/Implementations/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index ddc9413..b08ea36 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -26,7 +26,7 @@ struct TVApp: AppPlatform { try set rawState to player state as text if rawState is "stopped" then - return "Nothing playing||| |||stopped|||false" + return "Nothing playing||| |||false" end if set trackName to "" @@ -45,13 +45,13 @@ struct TVApp: AppPlatform { end if if trackName is "" then - return "Nothing playing||| |||stopped|||false" + return "Nothing playing||| |||false" end if set isPlaying to (rawState is "playing") - return trackName & "||| |||" & rawState & "|||" & isPlaying + return trackName & "||| |||" & isPlaying on error errMsg - return "Error||| |||error|||false" + return "Error||| |||false" end try end tell """ @@ -73,11 +73,11 @@ struct TVApp: AppPlatform { 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 ) } diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index 97a4e40..95acca0 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -33,12 +33,13 @@ actor ChannelExecutor { private unowned let connection: Channel private var shellChannel: Channel? private let shellHandler: StreamingShellHandler - private let interactiveAppleScript: Bool + private let channelKey: String - init(connection: Channel, interactiveAppleScript: Bool) { - print("πŸ”§ ChannelExecutor: Initializing \(interactiveAppleScript ? "AppleScript" : "shell") executor") + init(connection: Channel, channelKey: String) { + sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: Initializing AppleScript executor") self.connection = connection - self.interactiveAppleScript = interactiveAppleScript + self.channelKey = channelKey + // Create a single interactive shell session let promise = connection.eventLoop.makePromise(of: Channel.self) let handler = StreamingShellHandler() @@ -58,23 +59,21 @@ actor ChannelExecutor { Task { [weak self] in await self?.setShellChannel(chan) } - if interactiveAppleScript { - return setupInteractiveShell(channel: chan, command: "/usr/bin/osascript -s s -l AppleScript -i") - } else { - return setupInteractiveShell(channel: chan, command: "/bin/sh -l") - } + // Always use the interactive AppleScript shell + return setupInteractiveShell(channel: chan, command: "/usr/bin/osascript -s s -l AppleScript -i") } - .whenComplete { result in + .whenComplete { [weak self] result in + guard let self = self else { return } switch result { case .success: - print("πŸ”§ ChannelExecutor: βœ“ Interactive \(interactiveAppleScript ? "AppleScript" : "shell") ready") + sshLog("πŸ”§ [\(self.channelKey)] ChannelExecutor: βœ“ Interactive AppleScript ready") // Send test ping to verify the interactive session is working Task { [weak self] in try? await Task.sleep(nanoseconds: 500_000_000) // Wait 500ms for shell to stabilize await self?.sendTestPing() } case .failure(let error): - print("πŸ”§ ChannelExecutor: ❌ Failed to start interactive shell: \(error)") + sshLog("πŸ”§ [\(self.channelKey)] ChannelExecutor: ❌ Failed to start interactive shell: \(error)") } } } @@ -82,18 +81,12 @@ actor ChannelExecutor { /// Send a simple test command to verify the interactive session is responsive private func sendTestPing() async { guard let channel = shellChannel else { - print("πŸ”§ ChannelExecutor: No shell channel for test ping") + sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: No shell channel for test ping") return } let testSentinel = ">>>VOLCTL_PING_\(UUID().uuidString.prefix(4))<<<" - let testPayload: String - - if interactiveAppleScript { - testPayload = "1 + 1\n\n\"\(testSentinel)\"\n\n" - } else { - testPayload = "echo 'test-ok'; printf '\\n%s\\n' \(testSentinel)\n" - } + let testPayload = "1 + 1\n\n\"\(testSentinel)\"\n\n" // Set up the test command in the handler let promise = channel.eventLoop.makePromise(of: String.self) @@ -110,7 +103,7 @@ actor ChannelExecutor { } // Test ping successful - no need to log } catch { - print("πŸ”§ ChannelExecutor: ❌ Test ping failed: \(error)") + sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: ❌ Test ping failed: \(error)") } } @@ -118,7 +111,7 @@ actor ChannelExecutor { /// The promise is fulfilled when the command finishes (or fails). func run(command: String, description: String?) async -> Result { if let description = description { - print("πŸ”§ ChannelExecutor: \(description)") + sshLog("πŸ”§ [\(channelKey)] \(description)") } // Ensure the interactive shell channel is ready. Wait up to 3s (150 Γ— 20 ms yields). @@ -129,7 +122,7 @@ actor ChannelExecutor { } guard let chan = self.shellChannel else { - print("πŸ”§ ChannelExecutor: ❌ No shell channel available") + sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: ❌ No shell channel available") return .failure(SSHError.noSession) } @@ -143,26 +136,16 @@ actor ChannelExecutor { let promise = chan.eventLoop.makePromise(of: String.self) self.shellHandler.addCommand(sentinel: sentinel, promise: promise) - let payload: String - if self.interactiveAppleScript { - // For interactive AppleScript, send the command, blank line to execute, then sentinel - let escapedSentinel = sentinel.replacingOccurrences(of: "\"", with: "\\\"") - payload = "\(command)\n\n\"\(escapedSentinel)\"\n\n" - // Only log the description, not the full command - if let desc = description { - print("πŸ”§ ChannelExecutor: \(desc)") - } - } else { - payload = "\(command); printf '\\n%s\\n' \(sentinel)\n" - } + // For interactive AppleScript, send the command, blank line to execute, then sentinel + let escapedSentinel = sentinel.replacingOccurrences(of: "\"", with: "\\\"") + let payload = "\(command)\n\n\"\(escapedSentinel)\"\n\n" let buffer = chan.allocator.buffer(string: payload) - let writePromise = chan.eventLoop.makePromise(of: Void.self) - chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: writePromise) + chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: nil) // Add timeout to the promise let timeoutTask = chan.eventLoop.scheduleTask(in: .seconds(8)) { - print("πŸ”§ ChannelExecutor: ⏰ Command timed out after 8 seconds") + sshLog("πŸ”§ [\(self.channelKey)] ChannelExecutor: ⏰ Command timed out after 8 seconds") promise.fail(SSHError.timeout) } @@ -175,7 +158,7 @@ actor ChannelExecutor { /// Close shell channel func close() { - print("πŸ”§ ChannelExecutor: Closing shell channel") + sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: Closing shell channel") if let chan = self.shellChannel { chan.close(promise: nil) } @@ -191,7 +174,7 @@ actor ChannelExecutor { private class ErrorHandler: ChannelInboundHandler { typealias InboundIn = Any func errorCaught(context: ChannelHandlerContext, error: Error) { - print("πŸ”§ ErrorHandler: ❌ Error caught: \(error)") + sshLog("πŸ”§ ErrorHandler: ❌ Error caught: \(error)") context.close(promise: nil) } } diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 38ad921..e77a01a 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -13,48 +13,21 @@ enum SSHError: Error { case noSession } -// Concurrency limiter for simultaneous SSH channels -actor ChannelLimiter { - private let maxConcurrent: Int - private var current: Int = 0 - init(max: Int) { self.maxConcurrent = max } - func acquire() async { - while current >= maxConcurrent { - await Task.yield() - } - current += 1 - } - func release() { - current = max(0, current - 1) - } -} - -// Shared global limiter – tweak `max` if the server allows more channels -private let sshChannelLimiter = ChannelLimiter(max: 4) - 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 - // Adaptive timeout rolling averages keyed by command description - private static var commandAverages: [String: Double] = [:] - // MARK: - Dedicated Channel Support /// Executors keyed by logical channel name (e.g. "system", "music", etc.) private var dedicatedExecutors: [String: ChannelExecutor] = [:] /// Retrieve an existing executor for `key` or create a new one if necessary. private func executor(for key: String) async throws -> ChannelExecutor { - sshLog("πŸ“‘ SSHClient: Getting executor for key '\(key)'") if let existing = dedicatedExecutors[key] { - sshLog("πŸ“‘ SSHClient: Using existing executor for key '\(key)'") return existing } - sshLog("πŸ“‘ SSHClient: Creating new executor for key '\(key)'") // Ensure we have an active SSH TCP connection. guard let connection = self.connection else { sshLog("πŸ“‘ SSHClient: ❌ No active connection for executor creation") @@ -62,23 +35,16 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { } // Create a single ChannelExecutor which will internally open its own interactive shell. - let usesAppleScript = true // All channels now use AppleScript for consistency - sshLog("πŸ“‘ SSHClient: Creating ChannelExecutor with usesAppleScript=\(usesAppleScript) for key '\(key)'") - let executor = ChannelExecutor(connection: connection, interactiveAppleScript: usesAppleScript) + let executor = ChannelExecutor(connection: connection, channelKey: key) dedicatedExecutors[key] = executor - sshLog("πŸ“‘ SSHClient: βœ“ Executor created and stored for key '\(key)'") + sshLog("πŸ“‘ SSHClient: βœ“ Executor created for key '\(key)'") 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 { - sshLog("πŸ“‘ SSHClient: Performing command on dedicated channel '\(channelKey)'") - if let description = description { - sshLog("πŸ“‘ SSHClient: Command description: \(description)") - } do { let exec = try await executor(for: channelKey) - sshLog("πŸ“‘ SSHClient: Got executor, running command") return await exec.run(command: command, description: description) } catch { sshLog("πŸ“‘ SSHClient: ❌ Failed to get executor or run command: \(error)") @@ -88,10 +54,8 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { // Protocol-facing entry point (completion-handler style) func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - sshLog("πŸ“‘ SSHClient: executeCommandOnDedicatedChannel called for key '\(channelKey)'") Task { let result = await performOnDedicatedChannel(channelKey, command: command, description: description) - sshLog("πŸ“‘ SSHClient: Command completed with result: \(result)") completion(result) } } @@ -109,10 +73,8 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { // 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 { @@ -140,25 +102,30 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { 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") @@ -170,89 +137,34 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { case .success(let channel): 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") } @@ -260,269 +172,19 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { 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 refused (e.g., Remote Login disabled) + if let posixError = error as? POSIXError, posixError.code == .ECONNREFUSED { + return SSHError.connectionFailed("Remote Login is not enabled") } - // 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 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)) - } - - // (Adaptive timeout not tracked for session creation) - } - } - - 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) { - // Gate concurrent channel creations so we don't exceed the server's limit - Task { - await sshChannelLimiter.acquire() - executeCommandDirectly(command, description: description) { result in - completion(result) - // Release permit after command is done - Task { await sshChannelLimiter.release() } - } - } - } - - /// Execute command directly without heartbeat checks - used by the heartbeat mechanism itself - func executeCommandBypassingHeartbeat(_ command: String, description: String?, completion: @escaping (Result) -> Void) { - // Heartbeats can be triggered in parallel from different contexts. Protect the server's - // channel limit by acquiring a permit from the shared limiter before opening a new - // exec-style channel. - Task { - await sshChannelLimiter.acquire() - executeCommandDirectly(command, description: description) { result in - completion(result) - // Always release the permit when the command completes. - Task { await sshChannelLimiter.release() } - } - } - } - - /// 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? - - let start = Date() // track duration for adaptive timeout updates - 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 - // Adaptive timeout: rolling average * 3, min 2, max 8 - let key = commandDesc.prefix(40).description - let average = SSHClient.commandAverages[key] ?? 0.5 - var timeoutSeconds = max(average * 3.0, command.contains("set volume") ? 1.5 : 2.0) - timeoutSeconds = min(timeoutSeconds, 8.0) - if command.contains("heartbeat-") { timeoutSeconds = 3 } - 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)) - } - } - - // Update rolling average on success - if case .success = result { - let duration = Date().timeIntervalSince(start) - let key = commandDesc.prefix(40).description - let prev = SSHClient.commandAverages[key] ?? duration - SSHClient.commandAverages[key] = prev * 0.7 + duration * 0.3 - } - } - } - func disconnect() { sshLog("SSHClient: Starting disconnect process") @@ -536,110 +198,15 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { } dedicatedExecutors.removeAll() - // 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")) - } - } - } - - // 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 - } - } - - 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 { - if hasReceivedOutput { - promise.fail(SSHError.channelError("Connection closed")) - } else { - // No output received but channel closed cleanly – treat as success with empty output - promise.succeed("") - } - pendingCommandPromise = nil - } - context.fireChannelInactive() - } - - func errorCaught(context: ChannelHandlerContext, error: Error) { - if let promise = pendingCommandPromise { - promise.fail(error) - pendingCommandPromise = nil - } - context.close(promise: nil) - } -} - class PasswordAuthDelegate: NIOSSHClientUserAuthenticationDelegate { private let username: String private let password: String - private var authAttempts = 0 private(set) var authFailed = false var onAuthFailure: (() -> Void)? @@ -652,27 +219,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, @@ -693,6 +249,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 2eefafa..4458813 100644 --- a/Control/SSH/SSHClientProtocol.swift +++ b/Control/SSH/SSHClientProtocol.swift @@ -3,8 +3,6 @@ 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) - func executeCommandBypassingHeartbeat(_ 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) @@ -12,14 +10,6 @@ protocol SSHClientProtocol { // Default implementation for optional description parameter extension SSHClientProtocol { - func executeCommandWithNewChannel(_ command: String, completion: @escaping (Result) -> Void) { - executeCommandWithNewChannel(command, description: nil, completion: completion) - } - - func executeCommandBypassingHeartbeat(_ command: String, completion: @escaping (Result) -> Void) { - executeCommandBypassingHeartbeat(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 b96f316..261c9b9 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -355,29 +355,12 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } } - // Backward-compatibility wrapper - nonisolated func executeCommand(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - executeCommand(onChannel: "system", command, description: description, bypassHeartbeat: false, completion: completion) - } - - /// Compatibility alias for existing code (kept for minimal external diff) - nonisolated func executeCommandWithNewChannel(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - executeCommand(onChannel: "system", command, description: description, bypassHeartbeat: false, completion: completion) - } - - /// Execute command directly without heartbeat verification (used for batch operations) - nonisolated func executeCommandBypassingHeartbeat(_ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - executeCommand(onChannel: "system", command, description: description, bypassHeartbeat: true, completion: completion) - } - - - /// 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)\"" - 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-") { @@ -407,7 +390,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } /// Protocol conformance – executes on a dedicated channel (default heartbeat behaviour) - nonisolated func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, description: String? = nil, completion: @escaping (Result) -> Void) { - executeCommand(onChannel: channelKey, command, description: description, bypassHeartbeat: false, completion: completion) + nonisolated func executeCommandOnDedicatedChannel(_ channelKey: String, _ command: String, description: String?, completion: @escaping (Result) -> Void) { + client.executeCommandOnDedicatedChannel(channelKey, command, description: description, completion: completion) } } diff --git a/Control/Utilities/String+Extensions.swift b/Control/Utilities/String+Extensions.swift new file mode 100644 index 0000000..ad7f795 --- /dev/null +++ b/Control/Utilities/String+Extensions.swift @@ -0,0 +1,16 @@ +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 length = self.count + if length <= 4 { + return String(self.prefix(1)) + "***" + } else { + let prefixLength = min(length / 2, 4) + return String(self.prefix(prefixLength)) + "***" + } + } +} \ No newline at end of file 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) } } From a07289a545a4925aa617cbbb399fc18d9252f9db Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sun, 6 Jul 2025 17:35:27 -0700 Subject: [PATCH 14/25] improve parsing to handle quick commands --- Control/Components/PlatformControlPanel.swift | 2 +- Control/Platforms/AppController.swift | 10 +- .../Platforms/Implementations/IINAApp.swift | 31 ++- Control/Platforms/Implementations/TVApp.swift | 20 +- Control/SSH/ChannelExecutor.swift | 210 +++++++++++------- Control/SSH/SSHClient.swift | 58 ++++- Control/SSH/StreamingShellHandler.swift | 57 ++++- .../ExperimentalPlatformsView.swift | 2 +- 8 files changed, 269 insertions(+), 121 deletions(-) diff --git a/Control/Components/PlatformControlPanel.swift b/Control/Components/PlatformControlPanel.swift index beff524..06fe8b2 100644 --- a/Control/Components/PlatformControlPanel.swift +++ b/Control/Components/PlatformControlPanel.swift @@ -100,7 +100,7 @@ struct PlatformControl: View { .alert("\(platform.name) support is experimental", isPresented: $showingExperimentalAlert) { Button("OK") { } } message: { - Text(platform.reasonForExperimental) + Text(platform.reasonForExperimental ?? "") } } } diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 09f44df..02dc3f0 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -145,7 +145,7 @@ class AppController: ObservableObject { return } - let result = await executeCommand(platform.fetchState(), channelKey: platform.id, description: "\(platform.name): fetch status") + let result = await executeCommand(platform.fetchState(), channelKey: platform.id, description: "\(platform.id): fetch status") switch result { case .success(let output): @@ -213,7 +213,7 @@ class AppController: ObservableObject { let result = await executeCommand( platform.isRunningScript(), channelKey: platform.id, - description: "\(platform.name): check if running" + description: "\(platform.id): check if running" ) switch result { @@ -238,7 +238,7 @@ class AppController: ObservableObject { // status script into a single AppleScript round-trip. let combinedScript = platform.actionWithStatus(action) - let result = await executeCommand(combinedScript, channelKey: platform.id, description: "\(platform.name): \(action)") + let result = await executeCommand(combinedScript, channelKey: platform.id, description: "\(platform.id): \(action)") switch result { case .success(let output): @@ -275,7 +275,7 @@ class AppController: ObservableObject { let target = Int(volume * 100) let script = "set volume output volume \(target)" - let result = await executeCommand(script, channelKey: "system", description: "Set volume(\(target)%)", bypassHeartbeat: true) + let result = await executeCommand(script, channelKey: "system", description: "system: set volume to \(target)%") switch result { case .success(_): @@ -300,7 +300,7 @@ class AppController: ObservableObject { let script = "output volume of (get volume settings)" - let result = await executeCommand(script, channelKey: "system", description: "Get volume") + let result = await executeCommand(script, channelKey: "system", description: "system: get volume") switch result { case .success(let output): diff --git a/Control/Platforms/Implementations/IINAApp.swift b/Control/Platforms/Implementations/IINAApp.swift index 7541b5e..b4570f5 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -91,24 +91,33 @@ struct IINAApp: AppPlatform { } func executeAction(_ action: AppAction) -> String { - // Actions need IINA to be frontmost - var cmd = "tell process \"IINA\" to set frontmost to true\n" - cmd += "delay 0.1\n" - + // Only bring IINA to front (with small delay) if it's not already frontmost. + let keyLine: String switch action { case .playPauseToggle: - cmd += "keystroke space" + keyLine = "keystroke space" case .skipBackward: - cmd += "key code 123" + keyLine = "key code 123" case .skipForward: - cmd += "key code 124" + keyLine = "key code 124" case .previousTrack: - cmd += "key code 123 using {command down}" + keyLine = "key code 123 using {command down}" case .nextTrack: - cmd += "key code 124 using {command down}" + keyLine = "key code 124 using {command down}" } - - return cmd + + // 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/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index b08ea36..f3b1e87 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -95,22 +95,30 @@ struct TVApp: AppPlatform { return "playpause" case .skipBackward: return """ - tell application "TV" to activate - delay 0.1 try tell application "TV" to set player position to ((get player position) - 10) on error - tell application "System Events" to tell process "TV" to key code 123 + tell application "System Events" + if frontmost of application "TV" is false then + tell application "TV" to activate + delay 0.25 + end if + tell application "System Events" to tell process "TV" to key code 123 + end tell end try """ case .skipForward: return """ - tell application "TV" to activate - delay 0.1 try tell application "TV" to set player position to ((get player position) + 10) on error - tell application "System Events" to tell process "TV" to key code 124 + tell application "System Events" + if frontmost of application "TV" is false then + tell application "TV" to activate + delay 0.25 + end if + tell application "System Events" to tell process "TV" to key code 124 + end tell end try """ default: diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index 95acca0..0cfb53a 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -30,13 +30,50 @@ private struct TimeoutError: Error {} /// the overhead low by re-using the underlying TCP connection and serialising calls. @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 + + // 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 = 6 + + /// 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 = 2 + + /// Command watchdog duration. AppleScript can legitimately take >2 s under load. + private let commandTimeoutSeconds: TimeAmount = .seconds(3) + init(connection: Channel, channelKey: String) { - sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: Initializing AppleScript executor") + var id = 0 + ChannelExecutor.idQueue.sync { + id = ChannelExecutor.nextExecutorID + ChannelExecutor.nextExecutorID += 1 + } + self.executorId = id + + sshLog("πŸ”§ [E\(self.executorId)] ChannelExecutor: Initializing for key '\(channelKey)'") self.connection = connection self.channelKey = channelKey @@ -66,99 +103,120 @@ actor ChannelExecutor { guard let self = self else { return } switch result { case .success: - sshLog("πŸ”§ [\(self.channelKey)] ChannelExecutor: βœ“ Interactive AppleScript ready") - // Send test ping to verify the interactive session is working - Task { [weak self] in - try? await Task.sleep(nanoseconds: 500_000_000) // Wait 500ms for shell to stabilize - await self?.sendTestPing() - } + sshLog("πŸ”§ [E\(self.executorId)] ChannelExecutor: βœ“ Interactive AppleScript ready") case .failure(let error): - sshLog("πŸ”§ [\(self.channelKey)] ChannelExecutor: ❌ Failed to start interactive shell: \(error)") + sshLog("πŸ”§ [E\(self.executorId)] ChannelExecutor: ❌ Failed to start interactive shell: \(error)") } } } - /// Send a simple test command to verify the interactive session is responsive - private func sendTestPing() async { - guard let channel = shellChannel else { - sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: No shell channel for test ping") - return + /// Executes `command` by queueing it. Exactly one command is inflight on the interactive shell. + func run(command: String, description: String?) async -> Result { + // 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")) + } } - - let testSentinel = ">>>VOLCTL_PING_\(UUID().uuidString.prefix(4))<<<" - let testPayload = "1 + 1\n\n\"\(testSentinel)\"\n\n" - - // Set up the test command in the handler - let promise = channel.eventLoop.makePromise(of: String.self) - self.shellHandler.addCommand(sentinel: testSentinel, promise: promise) - - // Send the test payload - let buffer = channel.allocator.buffer(string: testPayload) - channel.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: nil) - - // Wait for the test result with a timeout - do { - _ = try await withTimeout(seconds: 3.0) { - return try await promise.futureResult.get() + + // Build unique command id & sentinel + let cmdId = commandCounter + commandCounter &+= 1 + let cmdIdHex = String(format: "%04X", cmdId & 0xFFFF) + let sentinel = ">>>VOLCTL_\(cmdIdHex)<<<" + + // AppleScript payload (command already wrapped upstream) + let escapedSentinel = sentinel.replacingOccurrences(of: "\"", with: "\\\"") + let payload = "-- \(cmdIdHex) \(description ?? "")\n\(command)\n\n\"\(escapedSentinel)\"\n\n" + + let preview = description ?? String(command.prefix(40)) + sshLog("πŸ”§ [E\(executorId):\(channelKey)] ⬆️ \(cmdIdHex) \(preview)") + + return await withCheckedContinuation { [weak self] continuation in + guard let self = self else { + continuation.resume(returning: .failure(SSHError.channelError("Executor deallocated"))) + return } - // Test ping successful - no need to log - } catch { - sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: ❌ Test ping failed: \(error)") + Task { await self.enqueueWorkItem(payload: payload, sentinel: sentinel, description: description, continuation: continuation) } } } - - /// Executes `command` by opening a fresh exec channel on the existing SSH connection. - /// The promise is fulfilled when the command finishes (or fails). - func run(command: String, description: String?) async -> Result { - if let description = description { - sshLog("πŸ”§ [\(channelKey)] \(description)") + + 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() } + } } - - // Ensure the interactive shell channel is ready. Wait up to 3s (150 Γ— 20 ms yields). - var retries = 0 - while self.shellChannel == nil && retries < 150 { - retries += 1 - try? await Task.sleep(nanoseconds: 20_000_000) // 20 ms + + 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() + } - guard let chan = self.shellChannel else { - sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: ❌ No shell channel available") - return .failure(SSHError.noSession) + private func ensureShellChannelReady() async -> Channel? { + var retries = 0 + while shellChannel == nil && retries < 150 { + retries += 1 + try? await Task.sleep(nanoseconds: 10_000_000) } - - // Give the interactive shell a moment to fully stabilize - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - - return await withCheckedContinuation { continuation in - let sentinelSuffix = String(UUID().uuidString.prefix(6)) - let sentinel = ">>>VOLCTL_END_\(sentinelSuffix)<<<" - - let promise = chan.eventLoop.makePromise(of: String.self) - self.shellHandler.addCommand(sentinel: sentinel, promise: promise) - - // For interactive AppleScript, send the command, blank line to execute, then sentinel - let escapedSentinel = sentinel.replacingOccurrences(of: "\"", with: "\\\"") - let payload = "\(command)\n\n\"\(escapedSentinel)\"\n\n" - - let buffer = chan.allocator.buffer(string: payload) - chan.writeAndFlush(NIOAny(SSHChannelData(type: .channel, data: .byteBuffer(buffer))), promise: nil) - - // Add timeout to the promise - let timeoutTask = chan.eventLoop.scheduleTask(in: .seconds(8)) { - sshLog("πŸ”§ [\(self.channelKey)] ChannelExecutor: ⏰ Command timed out after 8 seconds") - promise.fail(SSHError.timeout) - } - - promise.futureResult.whenComplete { result in - timeoutTask.cancel() - continuation.resume(returning: result) - } + if shellChannel == nil { + sshLog("πŸ”§ [E\(executorId):\(channelKey)] ❌ No shell channel available") } + return shellChannel } /// Close shell channel func close() { - sshLog("πŸ”§ [\(channelKey)] ChannelExecutor: Closing shell channel") + sshLog("πŸ”§ [E\(executorId)] ChannelExecutor: Closing shell channel") if let chan = self.shellChannel { chan.close(promise: nil) } diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index e77a01a..2f221f2 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -17,14 +17,40 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { private var group: EventLoopGroup private var connection: Channel? private var hasCompletedConnection = false + private let executorLock = NSLock() // MARK: - Dedicated Channel Support - /// Executors keyed by logical channel name (e.g. "system", "music", etc.) + private let appChannelPoolSize = 4 + + /// Executors keyed by physical channel name (e.g. "system", "app-0", "app-1") private var dedicatedExecutors: [String: ChannelExecutor] = [:] - /// Retrieve an existing executor for `key` or create a new one if necessary. + /// 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 { - if let existing = dedicatedExecutors[key] { + let executorKey: String + + if key == "system" { + executorKey = "system" + } else { + // This is an app, so we use the pool. + // Using hashValue provides a stable distribution of apps to channels. + let poolIndex = abs(key.hashValue) % appChannelPoolSize + executorKey = "app-\(poolIndex)" + } + + // Non-locking check for performance. In the common case where the executor + // already exists, we avoid the lock entirely. + if let existing = dedicatedExecutors[executorKey] { + return existing + } + + // The executor doesn't exist. Acquire a lock to ensure only one thread can create it. + executorLock.lock() + defer { executorLock.unlock() } + + // Double-check: Another thread might have created it while we were waiting for the lock. + if let existing = dedicatedExecutors[executorKey] { return existing } @@ -34,18 +60,32 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { throw SSHError.channelNotConnected } - // Create a single ChannelExecutor which will internally open its own interactive shell. - let executor = ChannelExecutor(connection: connection, channelKey: key) - dedicatedExecutors[key] = executor - sshLog("πŸ“‘ SSHClient: βœ“ Executor created for key '\(key)'") + // Create a new ChannelExecutor for this physical key ("app-N" or "system") + let executor = ChannelExecutor(connection: connection, channelKey: executorKey) + dedicatedExecutors[executorKey] = executor + sshLog("πŸ“‘ SSHClient: βœ“ Executor created for key '\(executorKey)'") 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 { + private func performOnDedicatedChannel(_ channelKey: String, command: String, description: String?, attemptsLeft: Int = 1) async -> Result { do { let exec = try await executor(for: channelKey) - return await exec.run(command: command, description: description) + 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 = channelKey == "system" ? "system" : "app-\(abs(channelKey.hashValue) % appChannelPoolSize)" + dedicatedExecutors.removeValue(forKey: physicalKey) + sshLog("πŸ“‘ SSHClient: Removed executor for key '\(physicalKey)' due to error – will recreate on next use") + if attemptsLeft > 0 { + return await performOnDedicatedChannel(channelKey, command: command, description: description, attemptsLeft: attemptsLeft - 1) + } + } + } + return result } catch { sshLog("πŸ“‘ SSHClient: ❌ Failed to get executor or run command: \(error)") return .failure(error) diff --git a/Control/SSH/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift index 27bd5f0..39e2ff7 100644 --- a/Control/SSH/StreamingShellHandler.swift +++ b/Control/SSH/StreamingShellHandler.swift @@ -15,6 +15,7 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { 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) { @@ -32,7 +33,11 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { func channelInactive(context: ChannelHandlerContext) { if !queue.isEmpty { - print("πŸ” StreamingShellHandler: ❌ Channel closed with pending commands") + print("πŸ” 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() } @@ -54,7 +59,7 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { // Handle stderr separately if payload.type == .stdErr { - print("πŸ” StreamingShellHandler: ❌ Stderr: '\(string.trimmingCharacters(in: .whitespacesAndNewlines))'") + print("πŸ” 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() @@ -67,8 +72,25 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { return } - // Accumulate all incoming data - queue[0].buffer += string + 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 @@ -79,6 +101,7 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { print("πŸ” 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 } @@ -114,6 +137,7 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { // Complete with error let pending = queue.removeFirst() pending.promise.fail(SSHError.channelError("AppleScript error: \(result)")) + context.close(promise: nil) return } scriptOutput = result @@ -124,6 +148,9 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { // Complete the promise with the parsed output let pending = queue.removeFirst() + let preview = scriptOutput.replacingOccurrences(of: "\n", with: " ") + .prefix(120) + print("πŸ” StreamingShellHandler: β‡’ Result for \(pending.sentinel.prefix(6)) β†’ \(preview)") pending.promise.succeed(scriptOutput) } } @@ -160,16 +187,22 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { } // Remove surrounding quotes if present - if content.hasPrefix("\"") && content.hasSuffix("\"") && content.count > 1 { - let unquoted = String(content.dropFirst().dropLast()) - // One more check on the unquoted content - if unquoted.hasPrefix("!!") || unquoted.contains("error") { - return (unquoted, true) - } + 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) - } else { - return (content, false) } + + // Skip noisy "set ..." echoes from the interpreter + if unquoted.hasPrefix("set ") { + continue + } + + return (unquoted, false) } } diff --git a/Control/Views/Preferences/ExperimentalPlatformsView.swift b/Control/Views/Preferences/ExperimentalPlatformsView.swift index 756b628..1a6a2b3 100644 --- a/Control/Views/Preferences/ExperimentalPlatformsView.swift +++ b/Control/Views/Preferences/ExperimentalPlatformsView.swift @@ -49,7 +49,7 @@ struct ExperimentalPlatformsView: View { .padding() .background(.ultraThinMaterial.opacity(0.5)) .cornerRadius(12) - Text(platform.reasonForExperimental) + Text(platform.reasonForExperimental ?? "") .font(.body) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) From c77a30c6f9e3f45bbc1b0ea4d48c32e7c5f415a6 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sun, 6 Jul 2025 19:28:14 -0700 Subject: [PATCH 15/25] switch to 1 app channel with combined running/status scripts, improve tv/iina --- Control/Platforms/AppController.swift | 106 ++++++------------ .../Platforms/Implementations/IINAApp.swift | 7 +- Control/Platforms/Implementations/TVApp.swift | 50 ++++----- Control/Platforms/Types.swift | 19 ++++ Control/SSH/ChannelExecutor.swift | 17 ++- Control/SSH/SSHClient.swift | 3 +- 6 files changed, 97 insertions(+), 105 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 02dc3f0..8c64b8a 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -103,15 +103,25 @@ class AppController: ObservableObject { // Update system volume first (sequential – very fast) await updateSystemVolume() - // Fetch states for every platform in parallel so the phone waits for - // just the slowest one instead of all in sequence. - 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) + // Slow-start strategy: the very first comprehensive refresh runs + // sequentially to avoid overloading the single shared app channel. All + // subsequent refreshes revert to the existing fully-parallel approach. + + 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) + } } } + } else { + // Initial sweep: do one platform at a time. + for platform in platforms { + await updateState(for: platform) + } } } @@ -124,31 +134,23 @@ class AppController: ObservableObject { } lastStateRefresh[platform.id] = Date() - 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 - } - return - } - - let result = await executeCommand(platform.fetchState(), channelKey: platform.id, description: "\(platform.id): fetch 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( @@ -157,30 +159,11 @@ 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) appControllerLog("πŸ“Š \(platform.name) parsed state: title=[\(newState.title.redacted())], isPlaying=\(String(describing: newState.isPlaying))") - - // 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) } case .failure(let error): appControllerLog("❌ \(platform.name) status fetch failed: \(error)") @@ -205,29 +188,6 @@ class AppController: ObservableObject { } } - private func checkIfRunning(_ platform: any AppPlatform) async -> Bool { - guard isActive else { - return false - } - - let result = await executeCommand( - platform.isRunningScript(), - channelKey: platform.id, - description: "\(platform.id): check if running" - ) - - switch result { - case .success(let output): - let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) - let isRunning = trimmedOutput == "true" || trimmedOutput == "\"true\"" - appControllerLog("πŸ“Š \(platform.name) isRunning: \(isRunning)") - return isRunning - case .failure(let error): - appControllerLog("❌ Failed to check if \(platform.name) is running: \(error)") - return false - } - } - func executeAction(platform: any AppPlatform, action: AppAction) async { guard isActive else { appControllerLog("⚠️ Controller not active, skipping action") diff --git a/Control/Platforms/Implementations/IINAApp.swift b/Control/Platforms/Implementations/IINAApp.swift index b4570f5..4be8ffe 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -61,9 +61,10 @@ struct IINAApp: AppPlatform { func fetchState() -> String { statusScript() } func actionWithStatus(_ action: AppAction) -> String { - // Add delay after action like Spotify does - let delayScript = "delay 0.3\n" - return statusScript(actionLines: executeAction(action) + "\n" + delayScript) + // 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 { diff --git a/Control/Platforms/Implementations/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index f3b1e87..3503b44 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -23,36 +23,32 @@ struct TVApp: AppPlatform { """ tell application "TV" \(actionLines) + set rawState to player state as text + if rawState is "stopped" then + return "Nothing playing||| |||false" + end if + + set trackName to "" try - set rawState to player state as text - if rawState is "stopped" then - return "Nothing playing||| |||false" - end if - - set trackName to "" + set trackName to name of current track + end try + + -- If no track name, try window for streaming content + if trackName is "" then try - set trackName to name of current track + set windowName to name of front window + if windowName is not "TV" then + set trackName to windowName + end if 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 - - if trackName is "" then - return "Nothing playing||| |||false" - end if - - set isPlaying to (rawState is "playing") - return trackName & "||| |||" & isPlaying - on error errMsg - return "Error||| |||false" - end try + end if + + if trackName is "" then + return "Nothing playing||| |||false" + end if + + set isPlaying to (rawState is "playing") + return trackName & "||| |||" & isPlaying end tell """ } diff --git a/Control/Platforms/Types.swift b/Control/Platforms/Types.swift index 369cbc3..dc0a063 100644 --- a/Control/Platforms/Types.swift +++ b/Control/Platforms/Types.swift @@ -110,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 exists (application process \"\(name)\") 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.swift b/Control/SSH/ChannelExecutor.swift index 0cfb53a..ee4af28 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -51,11 +51,17 @@ actor ChannelExecutor { 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 = 6 + 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. @@ -112,6 +118,15 @@ actor ChannelExecutor { /// 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 { diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 2f221f2..3664e55 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -20,7 +20,8 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { private let executorLock = NSLock() // MARK: - Dedicated Channel Support - private let appChannelPoolSize = 4 + // 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] = [:] From 3d94645c80d7d15faa5afceb3c35661ed5e1da3d Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sun, 6 Jul 2025 20:15:18 -0700 Subject: [PATCH 16/25] improve logging --- Control/Components/PlatformControlPanel.swift | 2 +- Control/Platforms/AppController.swift | 27 ++++++++++++++----- .../Platforms/Implementations/IINAApp.swift | 2 +- .../Platforms/Implementations/SafariApp.swift | 10 +++---- Control/SSH/ChannelExecutor.swift | 10 +++---- Control/SSH/SSHClient.swift | 15 ++--------- Control/SSH/SSHConnectionManager.swift | 2 +- Control/SSH/StreamingShellHandler.swift | 9 +++---- .../ExperimentalPlatformsView.swift | 2 +- 9 files changed, 39 insertions(+), 40 deletions(-) diff --git a/Control/Components/PlatformControlPanel.swift b/Control/Components/PlatformControlPanel.swift index 06fe8b2..beff524 100644 --- a/Control/Components/PlatformControlPanel.swift +++ b/Control/Components/PlatformControlPanel.swift @@ -100,7 +100,7 @@ struct PlatformControl: View { .alert("\(platform.name) support is experimental", isPresented: $showingExperimentalAlert) { Button("OK") { } } message: { - Text(platform.reasonForExperimental ?? "") + Text(platform.reasonForExperimental) } } } diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 8c64b8a..903da1e 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -76,7 +76,7 @@ class AppController: ObservableObject { } func updateAllStates() async { - appControllerLog("AppController: Starting comprehensive state update (\(platforms.count) platforms)") + appControllerLog("πŸ”„ Starting update for \(platforms.count) platforms") guard isActive else { appControllerLog("⚠️ Controller not active, skipping state update") @@ -130,10 +130,13 @@ class AppController: ObservableObject { // 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() + appControllerLog("πŸ”„ \(platform.name): checking status") + let result = await executeCommand(platform.combinedStatusScript(), channelKey: platform.id, description: "\(platform.id): combined status") switch result { @@ -162,7 +165,10 @@ class AppController: ObservableObject { updateStateIfChanged(platform.id, newState) } else { let newState = platform.parseState(output) - appControllerLog("πŸ“Š \(platform.name) parsed state: title=[\(newState.title.redacted())], isPlaying=\(String(describing: newState.isPlaying))") + 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): @@ -194,6 +200,8 @@ class AppController: ObservableObject { return } + appControllerLog("🎬 \(platform.name): \(action.label)") + // 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) @@ -215,7 +223,10 @@ class AppController: ObservableObject { } else if let lastLine = lines.last?.trimmingCharacters(in: .whitespacesAndNewlines), !lastLine.isEmpty { let newState = platform.parseState(lastLine) - appControllerLog("πŸ“Š \(platform.name) parsed state after action: title=[\(newState.title.redacted())], isPlaying=\(String(describing: newState.isPlaying))") + 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): @@ -235,6 +246,7 @@ class AppController: ObservableObject { 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 { @@ -258,6 +270,8 @@ class AppController: ObservableObject { return } + appControllerLog("πŸ”„ System: checking volume") + let script = "output volume of (get volume settings)" let result = await executeCommand(script, channelKey: "system", description: "system: get volume") @@ -266,7 +280,7 @@ class AppController: ObservableObject { case .success(let output): if let volume = Float(output.trimmingCharacters(in: .whitespacesAndNewlines)) { currentVolume = volume / 100.0 - appControllerLog("βœ“ System volume: \(Int(volume))%") + appControllerLog("πŸ“Š System volume Β· \(Int(volume))%") } else { appControllerLog("⚠️ Could not parse volume from output: '\(output)'") currentVolume = nil @@ -302,12 +316,13 @@ class AppController: ObservableObject { self.sshClient.executeCommandOnDedicatedChannel(channelKey, wrappedCommand, description: description) { result in if case .failure(let error) = result { - appControllerLog("❌ Command failed: \(error)") + 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") + appControllerLog("🚨 Connection lost - marking controller inactive") self.isActive = false connectionManager.handleConnectionLost() } diff --git a/Control/Platforms/Implementations/IINAApp.swift b/Control/Platforms/Implementations/IINAApp.swift index 4be8ffe..93e4f5a 100644 --- a/Control/Platforms/Implementations/IINAApp.swift +++ b/Control/Platforms/Implementations/IINAApp.swift @@ -71,7 +71,7 @@ struct IINAApp: AppPlatform { let components = output.components(separatedBy: "|||") if components.count >= 3 { var title = components[0].trimmingCharacters(in: .whitespacesAndNewlines) - // IINA often includes the full path after a dash, so we strip it. + // IINA shows "filename β€” /full/path" (two spaces + em dash). if let range = title.range(of: " β€” ") { title = String(title[.. String { - return "(function() { const v = document.querySelector('video'); if (!v) return 'No video found|||Safari|||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; })();" + 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 { @@ -48,7 +48,7 @@ struct SafariApp: AppPlatform { return """ tell application "Safari" if (count of windows) is 0 then - return "No windows open|||Safari|||false" + return "No windows open||| |||false" end if return do JavaScript "\(js)" in current tab of front window end tell @@ -63,10 +63,10 @@ struct SafariApp: AppPlatform { return """ tell application "Safari" if (count of windows) is 0 then - return "No windows open|||Safari|||false" + return "No windows open||| |||false" end if do JavaScript "\(actionJs)" in current tab of front window - delay 0.3 + delay 0.15 return do JavaScript "\(statusJs)" in current tab of front window end tell """ @@ -86,7 +86,7 @@ struct SafariApp: AppPlatform { // Handle cases where the script might return fewer components if !output.isEmpty && !output.contains("|||") { - return AppState(title: output, subtitle: "Safari", isPlaying: nil) + return AppState(title: output, subtitle: "", isPlaying: nil) } return AppState( diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index ee4af28..016d6ef 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -79,7 +79,7 @@ actor ChannelExecutor { } self.executorId = id - sshLog("πŸ”§ [E\(self.executorId)] ChannelExecutor: Initializing for key '\(channelKey)'") + // Initialization logging handled by SSHClient self.connection = connection self.channelKey = channelKey @@ -108,8 +108,7 @@ actor ChannelExecutor { .whenComplete { [weak self] result in guard let self = self else { return } switch result { - case .success: - sshLog("πŸ”§ [E\(self.executorId)] ChannelExecutor: βœ“ Interactive AppleScript ready") + case .success: break case .failure(let error): sshLog("πŸ”§ [E\(self.executorId)] ChannelExecutor: ❌ Failed to start interactive shell: \(error)") } @@ -140,14 +139,13 @@ actor ChannelExecutor { let cmdId = commandCounter commandCounter &+= 1 let cmdIdHex = String(format: "%04X", cmdId & 0xFFFF) - let sentinel = ">>>VOLCTL_\(cmdIdHex)<<<" + 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" - let preview = description ?? String(command.prefix(40)) - sshLog("πŸ”§ [E\(executorId):\(channelKey)] ⬆️ \(cmdIdHex) \(preview)") + // Only log command attempts on failure; success logged by AppController return await withCheckedContinuation { [weak self] continuation in guard let self = self else { diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 3664e55..1ba32c7 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -17,7 +17,6 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { private var group: EventLoopGroup private var connection: Channel? private var hasCompletedConnection = false - private let executorLock = NSLock() // MARK: - Dedicated Channel Support // Using a single app channel improves stability by serialising all app commands @@ -40,17 +39,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { executorKey = "app-\(poolIndex)" } - // Non-locking check for performance. In the common case where the executor - // already exists, we avoid the lock entirely. - if let existing = dedicatedExecutors[executorKey] { - return existing - } - - // The executor doesn't exist. Acquire a lock to ensure only one thread can create it. - executorLock.lock() - defer { executorLock.unlock() } - - // Double-check: Another thread might have created it while we were waiting for the lock. + // Create a new executor if still missing (non-blocking; possible race creates extra but harmless) if let existing = dedicatedExecutors[executorKey] { return existing } @@ -64,7 +53,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { // Create a new ChannelExecutor for this physical key ("app-N" or "system") let executor = ChannelExecutor(connection: connection, channelKey: executorKey) dedicatedExecutors[executorKey] = executor - sshLog("πŸ“‘ SSHClient: βœ“ Executor created for key '\(executorKey)'") + sshLog("πŸ”§ SSH: Channel '\(executorKey)' ready") return executor } diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 261c9b9..29e5ecb 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -60,7 +60,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } nonisolated var client: SSHClient { - sshLog("SSHConnectionManager: Accessing SSH client") + // Accessor no longer logs every call to reduce console noise. return sshClient } diff --git a/Control/SSH/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift index 39e2ff7..770ac3f 100644 --- a/Control/SSH/StreamingShellHandler.swift +++ b/Control/SSH/StreamingShellHandler.swift @@ -3,7 +3,7 @@ import NIOCore import NIOSSH /// Handles an interactive shell channel and fulfils promises when sentinels are encountered. -final class StreamingShellHandler: ChannelInboundHandler, Sendable { +final class StreamingShellHandler: ChannelInboundHandler { typealias InboundIn = SSHChannelData struct Pending { @@ -27,7 +27,7 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { } func channelActive(context: ChannelHandlerContext) { - print("πŸ” StreamingShellHandler: βœ“ Channel active") + // Channel setup logged by SSHClient context.fireChannelActive() } @@ -45,7 +45,7 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { func channelRead(context: ChannelHandlerContext, data: NIOAny) { if !hasReceivedAnyData { hasReceivedAnyData = true - print("πŸ” StreamingShellHandler: βœ“ Channel receiving data") + // Data flow is implied by successful commands; no need to log } let payload = unwrapInboundIn(data) @@ -148,9 +148,6 @@ final class StreamingShellHandler: ChannelInboundHandler, Sendable { // Complete the promise with the parsed output let pending = queue.removeFirst() - let preview = scriptOutput.replacingOccurrences(of: "\n", with: " ") - .prefix(120) - print("πŸ” StreamingShellHandler: β‡’ Result for \(pending.sentinel.prefix(6)) β†’ \(preview)") pending.promise.succeed(scriptOutput) } } diff --git a/Control/Views/Preferences/ExperimentalPlatformsView.swift b/Control/Views/Preferences/ExperimentalPlatformsView.swift index 1a6a2b3..756b628 100644 --- a/Control/Views/Preferences/ExperimentalPlatformsView.swift +++ b/Control/Views/Preferences/ExperimentalPlatformsView.swift @@ -49,7 +49,7 @@ struct ExperimentalPlatformsView: View { .padding() .background(.ultraThinMaterial.opacity(0.5)) .cornerRadius(12) - Text(platform.reasonForExperimental ?? "") + Text(platform.reasonForExperimental) .font(.body) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) From ebb53e0945ab962b7aca332aa140369a81446e81 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sun, 6 Jul 2025 20:33:07 -0700 Subject: [PATCH 17/25] rate limit TV actions and improve channel stability --- Control/Platforms/AppController.swift | 13 +++++++ Control/Platforms/Implementations/TVApp.swift | 36 ++++++++----------- Control/SSH/ChannelExecutor.swift | 2 +- Control/SSH/SSHClient.swift | 2 ++ Control/SSH/StreamingShellHandler.swift | 2 +- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 903da1e..c684484 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -20,6 +20,9 @@ class AppController: ObservableObject { // 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 } @@ -200,6 +203,16 @@ class AppController: ObservableObject { return } + // 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() + } + appControllerLog("🎬 \(platform.name): \(action.label)") // Leverage the shared helper on the platform to combine the action and diff --git a/Control/Platforms/Implementations/TVApp.swift b/Control/Platforms/Implementations/TVApp.swift index 3503b44..37e1671 100644 --- a/Control/Platforms/Implementations/TVApp.swift +++ b/Control/Platforms/Implementations/TVApp.swift @@ -91,31 +91,23 @@ struct TVApp: AppPlatform { return "playpause" case .skipBackward: return """ - try - tell application "TV" to set player position to ((get player position) - 10) - on error - tell application "System Events" - if frontmost of application "TV" is false then - tell application "TV" to activate - delay 0.25 - end if - tell application "System Events" to tell process "TV" to key code 123 - end tell - end try + tell application "System Events" + 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 """ case .skipForward: return """ - try - tell application "TV" to set player position to ((get player position) + 10) - on error - tell application "System Events" - if frontmost of application "TV" is false then - tell application "TV" to activate - delay 0.25 - end if - tell application "System Events" to tell process "TV" to key code 124 - end tell - end try + tell application "System Events" + 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: return "" diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index 016d6ef..20be1f8 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -66,7 +66,7 @@ actor ChannelExecutor { /// 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 = 2 + private let maxConsecutiveTimeouts = 1 /// Command watchdog duration. AppleScript can legitimately take >2 s under load. private let commandTimeoutSeconds: TimeAmount = .seconds(3) diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 1ba32c7..1e2d809 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -71,6 +71,8 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { dedicatedExecutors.removeValue(forKey: physicalKey) sshLog("πŸ“‘ SSHClient: Removed executor for key '\(physicalKey)' due to error – will recreate on next use") if attemptsLeft > 0 { + // Add brief delay to prevent rapid channel recreation + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds return await performOnDedicatedChannel(channelKey, command: command, description: description, attemptsLeft: attemptsLeft - 1) } } diff --git a/Control/SSH/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift index 770ac3f..c68eef6 100644 --- a/Control/SSH/StreamingShellHandler.swift +++ b/Control/SSH/StreamingShellHandler.swift @@ -97,7 +97,7 @@ final class StreamingShellHandler: ChannelInboundHandler { let expectedSentinel = queue[0].sentinel // Check if buffer is getting too large (possible stuck command) - if currentBuffer.count > 50000 { + if currentBuffer.count > 100000 { print("πŸ” StreamingShellHandler: ⚠️ Buffer overflow - command may be stuck") let pending = queue.removeFirst() pending.promise.fail(SSHError.channelError("Buffer overflow - response too large")) From c394ab67da6bba3ddd03f5cc2babd3250fd3aa1c Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sun, 6 Jul 2025 22:38:17 -0700 Subject: [PATCH 18/25] implement heartbeat and connection lost handling --- Control/Platforms/AppController.swift | 8 +- Control/SSH/SSHClient.swift | 19 +-- Control/SSH/SSHConnectionManager.swift | 164 +++++++++++++++++++++++-- Control/SSH/SSHViewSupport.swift | 15 ++- Control/Views/ControlView.swift | 2 + 5 files changed, 184 insertions(+), 24 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index c684484..155cc10 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -249,7 +249,7 @@ class AppController: ObservableObject { sshClient.isConnectionLossError(error) { appControllerLog("🚨 Connection lost during action execution - marking controller inactive") self.isActive = false - sshClient.handleConnectionLost() + sshClient.handleConnectionLost(because: error) } } } @@ -273,7 +273,7 @@ class AppController: ObservableObject { sshClient.isConnectionLossError(error) { appControllerLog("🚨 Connection lost during volume change - marking controller inactive") self.isActive = false - sshClient.handleConnectionLost() + sshClient.handleConnectionLost(because: error) } } } @@ -307,7 +307,7 @@ class AppController: ObservableObject { sshClient.isConnectionLossError(error) { appControllerLog("🚨 Connection lost during volume update - marking controller inactive") self.isActive = false - sshClient.handleConnectionLost() + sshClient.handleConnectionLost(because: error) } } } @@ -337,7 +337,7 @@ class AppController: ObservableObject { connectionManager.isConnectionLossError(error) { appControllerLog("🚨 Connection lost - marking controller inactive") self.isActive = false - connectionManager.handleConnectionLost() + connectionManager.handleConnectionLost(because: error) } } continuation.resume(returning: result) diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 1e2d809..57af63b 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -32,6 +32,9 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { if key == "system" { executorKey = "system" + } else if key == "heartbeat" { + // Dedicated persistent channel for heartbeats + executorKey = "heartbeat" } else { // This is an app, so we use the pool. // Using hashValue provides a stable distribution of apps to channels. @@ -58,7 +61,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { } /// Async helper that runs a command on a dedicated channel and returns the Result. - private func performOnDedicatedChannel(_ channelKey: String, command: String, description: String?, attemptsLeft: Int = 1) async -> 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) @@ -67,14 +70,16 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { if case SSHError.timeout = error { shouldReset = true } if case SSHError.channelError = error { shouldReset = true } if shouldReset { - let physicalKey = channelKey == "system" ? "system" : "app-\(abs(channelKey.hashValue) % appChannelPoolSize)" + let physicalKey: String + if channelKey == "system" { + physicalKey = "system" + } else if channelKey == "heartbeat" { + physicalKey = "heartbeat" + } else { + physicalKey = "app-\(abs(channelKey.hashValue) % appChannelPoolSize)" + } dedicatedExecutors.removeValue(forKey: physicalKey) sshLog("πŸ“‘ SSHClient: Removed executor for key '\(physicalKey)' due to error – will recreate on next use") - if attemptsLeft > 0 { - // Add brief delay to prevent rapid channel recreation - try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds - return await performOnDedicatedChannel(channelKey, command: command, description: description, attemptsLeft: attemptsLeft - 1) - } } } return result diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 29e5ecb..64bfa0c 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -7,11 +7,23 @@ 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 + static let shared = SSHConnectionManager() struct Credentials: Equatable { @@ -24,6 +36,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { case disconnected case connecting case connected + case recovering case failed(String) var description: String { @@ -31,6 +44,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))" } } @@ -48,14 +62,14 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { if path.status != .satisfied { connectionLog("🚨 Network path no longer satisfied – assuming connection lost") Task { @MainActor in - self.handleConnectionLost() + 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 } @@ -64,15 +78,24 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { 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() } } @@ -110,6 +133,11 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { case .success: connectionLog("βœ“ [\(connectionId)] Connection successful") self.connectionState = .connected + // Start heartbeat monitoring once connected + self.startHeartbeat() + self.consecutiveHeartbeatFailures = 0 + self.lastHeartbeatSuccess = Date() + self.recoveryDeadline = nil continuation.resume() case .failure(let error): connectionLog("❌ [\(connectionId)] Connection failed: \(error)") @@ -118,6 +146,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) } } @@ -142,6 +171,8 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { self.currentCredentials = nil self.cancelBackgroundDisconnect() self.endBackgroundTask() + // Stop heartbeat monitoring + self.stopHeartbeat() } } @@ -302,6 +333,11 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { 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() @@ -391,6 +427,112 @@ 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 + private 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() + 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 + } } diff --git a/Control/SSH/SSHViewSupport.swift b/Control/SSH/SSHViewSupport.swift index ee349b9..6b51b65 100644 --- a/Control/SSH/SSHViewSupport.swift +++ b/Control/SSH/SSHViewSupport.swift @@ -89,8 +89,19 @@ extension SSHConnectedView { @MainActor private func setConnectionLostHandler() { let viewNameMeta = String(describing: Self.self) - connectionManager.setConnectionLostHandler { @MainActor in - viewLog("⚠️ \(viewNameMeta): Connection lost handler triggered", view: viewNameMeta) + 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 } } diff --git a/Control/Views/ControlView.swift b/Control/Views/ControlView.swift index c5a9c38..70e5112 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -303,6 +303,8 @@ struct ControlView: View, SSHConnectedView { viewLog("🚨 ControlView: Connection is disconnected", view: "ControlView") case .connecting: viewLog("ControlView: Currently connecting...", view: "ControlView") + case .recovering: + viewLog("ControlView: Recovering connection...", view: "ControlView") case .connected: viewLog("βœ“ ControlView: Connection established", view: "ControlView") case .failed(let error): From 4db658129217f4401a3ae1bb0983eefd9bd82dee Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Sun, 6 Jul 2025 22:59:34 -0700 Subject: [PATCH 19/25] remove unused functions --- Control/Platforms/AppController.swift | 23 +++++-------------- Control/SSH/ChannelExecutor.swift | 23 ------------------- Control/SSH/SSHConnectionManager.swift | 2 +- Control/Utilities/ShellCommandUtilities.swift | 11 --------- 4 files changed, 7 insertions(+), 52 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 155cc10..f3ead84 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -7,9 +7,6 @@ class AppController: ObservableObject { private var isUpdating = false @Published var isActive = true - // Add batch operation flag to reduce heartbeats - private var isBatchOperation = false - // Track initial comprehensive update completion @Published var hasCompletedInitialUpdate = false @@ -95,21 +92,9 @@ class AppController: ObservableObject { try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds } - // Mark as batch operation to reduce heartbeats - isBatchOperation = true - defer { - isBatchOperation = false - hasCompletedInitialUpdate = true - appControllerLog("βœ“ State update complete") - } - // Update system volume first (sequential – very fast) await updateSystemVolume() - // Slow-start strategy: the very first comprehensive refresh runs - // sequentially to avoid overloading the single shared app channel. All - // subsequent refreshes revert to the existing fully-parallel approach. - if hasCompletedInitialUpdate { // Parallel path – after the initial warm-up everything is fast again. await withTaskGroup(of: Void.self) { group in @@ -126,6 +111,11 @@ class AppController: ObservableObject { await updateState(for: platform) } } + + defer { + hasCompletedInitialUpdate = true + appControllerLog("βœ“ State update complete") + } } func updateState(for platform: any AppPlatform) async { @@ -312,8 +302,7 @@ class AppController: ObservableObject { } } - // Keep this simpler version for single commands (permissions checks, etc) - private func executeCommand(_ command: String, channelKey: String, description: String? = nil, bypassHeartbeat: Bool = false) async -> Result { + 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")) diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index 20be1f8..eb22a3d 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -2,29 +2,6 @@ import Foundation import NIOSSH import NIOCore -/// Utility function to add timeout to async operations -private func withTimeout(seconds: Double, operation: @escaping () async throws -> T) async throws -> T { - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - return try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TimeoutError() - } - - guard let result = try await group.next() else { - throw TimeoutError() - } - - group.cancelAll() - return result - } -} - -private struct TimeoutError: Error {} - /// Actor responsible for running commands serially on the SSH connection. /// It opens a NEW exec channel for every command (required by macOS sshd) but keeps /// the overhead low by re-using the underlying TCP connection and serialising calls. diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 64bfa0c..52ef8d6 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -327,7 +327,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } /// 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, bypassHeartbeat: Bool = false, completion: @escaping (Result) -> Void) { + 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)") diff --git a/Control/Utilities/ShellCommandUtilities.swift b/Control/Utilities/ShellCommandUtilities.swift index 8c14ffd..783dbcd 100644 --- a/Control/Utilities/ShellCommandUtilities.swift +++ b/Control/Utilities/ShellCommandUtilities.swift @@ -13,17 +13,6 @@ 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'\n try\n \(escapedScript)\n on error errMsg\n return errMsg\n end try\n 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 { From 5ed849c66a72149471d0a52c28ea2374130b873b Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Mon, 7 Jul 2025 00:15:42 -0700 Subject: [PATCH 20/25] ensure proper reset on background + refactor and cleanup --- Control/Platforms/AppController.swift | 6 +-- Control/SSH/ChannelExecutor+ShellSetup.swift | 38 +++++++++++++ Control/SSH/ChannelExecutor.swift | 57 +++++--------------- Control/SSH/README.md | 42 +++++++++++++++ Control/SSH/SSHClient.swift | 30 ++++------- Control/SSH/SSHConnectionManager.swift | 21 ++++++-- Control/SSH/StreamingShellHandler.swift | 13 +++-- Control/Views/ControlView.swift | 6 ++- 8 files changed, 135 insertions(+), 78 deletions(-) create mode 100644 Control/SSH/ChannelExecutor+ShellSetup.swift create mode 100644 Control/SSH/README.md diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index f3ead84..98fe61a 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -112,10 +112,8 @@ class AppController: ObservableObject { } } - defer { - hasCompletedInitialUpdate = true - appControllerLog("βœ“ State update complete") - } + hasCompletedInitialUpdate = true + appControllerLog("βœ“ State update complete") } func updateState(for platform: any AppPlatform) async { diff --git a/Control/SSH/ChannelExecutor+ShellSetup.swift b/Control/SSH/ChannelExecutor+ShellSetup.swift new file mode 100644 index 0000000..734c478 --- /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) + } +} \ No newline at end of file diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index eb22a3d..e14c295 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -2,9 +2,13 @@ import Foundation import NIOSSH import NIOCore -/// Actor responsible for running commands serially on the SSH connection. -/// It opens a NEW exec channel for every command (required by macOS sshd) but keeps -/// the overhead low by re-using the underlying TCP connection and serialising calls. +/// 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") @@ -208,53 +212,16 @@ actor ChannelExecutor { func close() { sshLog("πŸ”§ [E\(executorId)] ChannelExecutor: Closing shell channel") if let chan = self.shellChannel { - chan.close(promise: nil) + 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 } -} - -// A simple passthrough error handler for the exec child channel. -private class ErrorHandler: ChannelInboundHandler { - typealias InboundIn = Any - func errorCaught(context: ChannelHandlerContext, error: Error) { - sshLog("πŸ”§ ErrorHandler: ❌ Error caught: \(error)") - context.close(promise: nil) - } -} - -private func setupInteractiveShell(channel: Channel, command: String) -> EventLoopFuture { - // First allocate a PTY for proper terminal behavior - 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 - // Now request an interactive shell - let shellRequest = SSHChannelRequestEvent.ShellRequest(wantReply: true) - return channel.triggerUserOutboundEvent(shellRequest) - } - .flatMap { _ -> EventLoopFuture in - // Send the initial command to set up our specific interpreter - 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 - print("πŸ”§ setupInteractiveShell: ❌ Setup failed: \(error)") - return channel.eventLoop.makeFailedFuture(error) - } } 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 57af63b..10f683f 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -28,19 +28,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { /// 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: String - - if key == "system" { - executorKey = "system" - } else if key == "heartbeat" { - // Dedicated persistent channel for heartbeats - executorKey = "heartbeat" - } else { - // This is an app, so we use the pool. - // Using hashValue provides a stable distribution of apps to channels. - let poolIndex = abs(key.hashValue) % appChannelPoolSize - executorKey = "app-\(poolIndex)" - } + 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] { @@ -70,14 +58,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { if case SSHError.timeout = error { shouldReset = true } if case SSHError.channelError = error { shouldReset = true } if shouldReset { - let physicalKey: String - if channelKey == "system" { - physicalKey = "system" - } else if channelKey == "heartbeat" { - physicalKey = "heartbeat" - } else { - physicalKey = "app-\(abs(channelKey.hashValue) % appChannelPoolSize)" - } + 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") } @@ -239,6 +220,13 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { connection = nil sshLog("βœ“ SSHClient disconnected and cleaned up") } + + /// 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 { diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index 52ef8d6..fe09ded 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -23,6 +23,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { private var recoveryDeadline: Date? private var heartbeatCounter: UInt32 = 0 private let heartbeatReplyTimeout: TimeInterval = 1.0 + private var heartbeatReadyContinuations: [CheckedContinuation] = [] static let shared = SSHConnectionManager() @@ -133,8 +134,6 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { case .success: connectionLog("βœ“ [\(connectionId)] Connection successful") self.connectionState = .connected - // Start heartbeat monitoring once connected - self.startHeartbeat() self.consecutiveHeartbeatFailures = 0 self.lastHeartbeatSuccess = Date() self.recoveryDeadline = nil @@ -435,7 +434,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { } // MARK: - Heartbeat Helpers - private func startHeartbeat() { + func startHeartbeat() { stopHeartbeat() consecutiveHeartbeatFailures = 0 currentHeartbeatInterval = minHeartbeatInterval @@ -503,6 +502,11 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { 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)") @@ -535,4 +539,15 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { private func resetHeartbeatInterval() { currentHeartbeatInterval = minHeartbeatInterval } + + // 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/StreamingShellHandler.swift b/Control/SSH/StreamingShellHandler.swift index c68eef6..d456dec 100644 --- a/Control/SSH/StreamingShellHandler.swift +++ b/Control/SSH/StreamingShellHandler.swift @@ -33,7 +33,7 @@ final class StreamingShellHandler: ChannelInboundHandler { func channelInactive(context: ChannelHandlerContext) { if !queue.isEmpty { - print("πŸ” StreamingShellHandler: ❌ Channel closed with \(queue.count) pending commands – failing them") + sshLog("πŸ” StreamingShellHandler: ❌ Channel closed with \(queue.count) pending commands – failing them") for pending in queue { pending.promise.fail(SSHError.channelError("Channel closed unexpectedly")) } @@ -59,7 +59,7 @@ final class StreamingShellHandler: ChannelInboundHandler { // Handle stderr separately if payload.type == .stdErr { - print("πŸ” StreamingShellHandler: ❌ Stderr: '\(string.trimmingCharacters(in: .whitespacesAndNewlines).prefix(120))'") + 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() @@ -98,7 +98,7 @@ final class StreamingShellHandler: ChannelInboundHandler { // Check if buffer is getting too large (possible stuck command) if currentBuffer.count > 100000 { - print("πŸ” StreamingShellHandler: ⚠️ Buffer overflow - command may be stuck") + 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) @@ -291,7 +291,12 @@ final class StreamingShellHandler: ChannelInboundHandler { } func errorCaught(context: ChannelHandlerContext, error: Error) { - print("πŸ” StreamingShellHandler: ❌ Error caught: \(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) diff --git a/Control/Views/ControlView.swift b/Control/Views/ControlView.swift index 70e5112..b7fe74c 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -41,7 +41,11 @@ 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") From 0534219a66a8a5839752ebe709df3cf6fc63c2ed Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Mon, 7 Jul 2025 00:55:35 -0700 Subject: [PATCH 21/25] ensure connection cleanup when moving back to connectionsView --- Control/Views/ConnectionsView.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Control/Views/ConnectionsView.swift b/Control/Views/ConnectionsView.swift index 26e0ee2..151d216 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 } From f775326424d3a5d0df1f0c57fd00d37da5eee8d0 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Mon, 7 Jul 2025 01:07:02 -0700 Subject: [PATCH 22/25] lower volume debounce --- Control/Views/ControlView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Control/Views/ControlView.swift b/Control/Views/ControlView.swift index b7fe74c..d874f47 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -359,7 +359,7 @@ struct ControlView: View, SSHConnectedView { volume = Float(newVolume) / 100.0 let now = Date() - if now.timeIntervalSince(lastVolumeCommandDate) > 0.3 { + if now.timeIntervalSince(lastVolumeCommandDate) > 0.05 { lastVolumeCommandDate = now Task { await appController.setVolume(volume) From b4c5851a261ba4e81d0f66dc5fb40d51c9b87af2 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Mon, 7 Jul 2025 02:10:10 -0700 Subject: [PATCH 23/25] make status logs redact more --- Control/Platforms/AppController.swift | 2 +- Control/Utilities/String+Extensions.swift | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 98fe61a..5640726 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -159,7 +159,7 @@ class AppController: ObservableObject { 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)") + appControllerLog("β†’ \(platform.name) state: \(newState.title.redacted())\(subtitleSegment) Β· \(playString)") updateStateIfChanged(platform.id, newState) } case .failure(let error): diff --git a/Control/Utilities/String+Extensions.swift b/Control/Utilities/String+Extensions.swift index ad7f795..cefb836 100644 --- a/Control/Utilities/String+Extensions.swift +++ b/Control/Utilities/String+Extensions.swift @@ -5,12 +5,7 @@ extension String { /// Example: "My Private Video" becomes "My P***" func redacted() -> String { guard !self.isEmpty else { return "" } - let length = self.count - if length <= 4 { - return String(self.prefix(1)) + "***" - } else { - let prefixLength = min(length / 2, 4) - return String(self.prefix(prefixLength)) + "***" - } + let prefixLength = min(2, self.count) + return String(self.prefix(prefixLength)) + "***" } -} \ No newline at end of file +} From d7eb3635d27396d116374a694aa24ba88803cc6e Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Mon, 7 Jul 2025 10:39:15 -0700 Subject: [PATCH 24/25] fix broken handling of not running cases that killed the whole channel --- Control/Platforms/Types.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Control/Platforms/Types.swift b/Control/Platforms/Types.swift index dc0a063..cc75c76 100644 --- a/Control/Platforms/Types.swift +++ b/Control/Platforms/Types.swift @@ -120,7 +120,7 @@ extension AppPlatform { func combinedStatusScript() -> String { return """ tell application \"System Events\" - if exists (application process \"\(name)\") then + if (count of (processes where name is \"\(name)\")) > 0 then -- App is running; delegate to the platform-specific status fetch \(fetchState()) else From 4988edd01fd5d38d37e4d5b6209fad7932f6e3e7 Mon Sep 17 00:00:00 2001 From: Ryan Whitney Date: Mon, 7 Jul 2025 17:04:42 -0700 Subject: [PATCH 25/25] use fewer emoji in logs --- Control/Platforms/AppController.swift | 24 +++++++------- Control/SSH/ChannelExecutor+ShellSetup.swift | 4 +-- Control/SSH/ChannelExecutor.swift | 12 +++---- Control/SSH/SSHClient.swift | 14 ++++---- Control/SSH/SSHConnectionManager.swift | 19 ++++++----- Control/ViewModels/ConnectionsViewModel.swift | 6 ++-- Control/Views/ConnectionsView.swift | 2 +- Control/Views/ControlView.swift | 32 +++++++++---------- 8 files changed, 56 insertions(+), 57 deletions(-) diff --git a/Control/Platforms/AppController.swift b/Control/Platforms/AppController.swift index 5640726..6ead098 100644 --- a/Control/Platforms/AppController.swift +++ b/Control/Platforms/AppController.swift @@ -25,7 +25,7 @@ class AppController: ObservableObject { } init(sshClient: SSHClientProtocol, platformRegistry: PlatformRegistry) { - appControllerLog("AppController: Initializing with \(platformRegistry.activePlatforms.count) active platforms") + appControllerLog("Initializing with \(platformRegistry.activePlatforms.count) active platforms") self.sshClient = sshClient self.platformRegistry = platformRegistry @@ -38,25 +38,25 @@ class AppController: ObservableObject { } func reset() { - appControllerLog("AppController: Resetting state") + appControllerLog("Resetting state") isActive = true isUpdating = false 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 to \(newRegistry.activePlatforms.map { $0.name })") + appControllerLog("Updating platform registry to \(newRegistry.activePlatforms.map { $0.name })") self.platformRegistry = newRegistry @@ -76,7 +76,7 @@ class AppController: ObservableObject { } func updateAllStates() async { - appControllerLog("πŸ”„ Starting update for \(platforms.count) platforms") + appControllerLog("β‡οΈŽ Starting update for \(platforms.count) platforms") guard isActive else { appControllerLog("⚠️ Controller not active, skipping state update") @@ -126,7 +126,7 @@ class AppController: ObservableObject { } lastStateRefresh[platform.id] = Date() - appControllerLog("πŸ”„ \(platform.name): checking status") + appControllerLog("⚐ \(platform.name): checking status") let result = await executeCommand(platform.combinedStatusScript(), channelKey: platform.id, description: "\(platform.id): combined status") @@ -159,7 +159,7 @@ class AppController: ObservableObject { 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)") + appControllerLog("βš‘ \(platform.name) state: \(newState.title.redacted())\(subtitleSegment) Β· \(playString)") updateStateIfChanged(platform.id, newState) } case .failure(let error): @@ -201,7 +201,7 @@ class AppController: ObservableObject { lastActionTime[platform.id] = Date() } - appControllerLog("🎬 \(platform.name): \(action.label)") + appControllerLog("⚑︎ \(platform.name): \(action.label)") // Leverage the shared helper on the platform to combine the action and // status script into a single AppleScript round-trip. @@ -227,7 +227,7 @@ class AppController: ObservableObject { 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)") + appControllerLog("❖ \(platform.name) after action: \(newState.title.redacted())\(subtitleSegment) Β· \(playString)") states[platform.id] = newState } case .failure(let error): @@ -271,7 +271,7 @@ class AppController: ObservableObject { return } - appControllerLog("πŸ”„ System: checking volume") + appControllerLog("⚐ System: checking volume") let script = "output volume of (get volume settings)" @@ -281,7 +281,7 @@ class AppController: ObservableObject { case .success(let output): if let volume = Float(output.trimmingCharacters(in: .whitespacesAndNewlines)) { currentVolume = volume / 100.0 - appControllerLog("πŸ“Š System volume Β· \(Int(volume))%") + appControllerLog("βš‘ System volume Β· \(Int(volume))%") } else { appControllerLog("⚠️ Could not parse volume from output: '\(output)'") currentVolume = nil diff --git a/Control/SSH/ChannelExecutor+ShellSetup.swift b/Control/SSH/ChannelExecutor+ShellSetup.swift index 734c478..54d793b 100644 --- a/Control/SSH/ChannelExecutor+ShellSetup.swift +++ b/Control/SSH/ChannelExecutor+ShellSetup.swift @@ -32,7 +32,7 @@ func setupInteractiveShell(channel: Channel, command: String) -> EventLoopFuture return writePromise.futureResult } .flatMapError { error in - sshLog("πŸ”§ setupInteractiveShell: ❌ Setup failed: \(error)") + sshLog("β˜„οΈŽ setupInteractiveShell: ❌ Setup failed: \(error)") return channel.eventLoop.makeFailedFuture(error) } -} \ No newline at end of file +} diff --git a/Control/SSH/ChannelExecutor.swift b/Control/SSH/ChannelExecutor.swift index e14c295..76fa7c6 100644 --- a/Control/SSH/ChannelExecutor.swift +++ b/Control/SSH/ChannelExecutor.swift @@ -91,7 +91,7 @@ actor ChannelExecutor { switch result { case .success: break case .failure(let error): - sshLog("πŸ”§ [E\(self.executorId)] ChannelExecutor: ❌ Failed to start interactive shell: \(error)") + sshLog("β˜„οΈŽ [E\(self.executorId)] ChannelExecutor: ❌ Failed to start interactive shell: \(error)") } } } @@ -111,7 +111,7 @@ actor ChannelExecutor { if isBusy || !workQueue.isEmpty { if workQueue.count >= maxQueuedCommands { let dropPreview = description ?? String(command.prefix(30)) - sshLog("πŸ”§ [E\(executorId):\(channelKey)] ⚠️ Queue full – rejecting cmd \(dropPreview)") + sshLog("β˜„οΈŽ [E\(executorId):\(channelKey)] ⚠️ Queue full – rejecting cmd \(dropPreview)") return .failure(SSHError.channelError("Executor queue full")) } } @@ -163,13 +163,13 @@ actor ChannelExecutor { // 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") + 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") + sshLog("β˜„οΈŽ [E\(executorId)] ChannelExecutor: ⚠️ Too many consecutive timeouts – closing shell channel") Task { await self.close() } self.consecutiveTimeouts = 0 } else { @@ -203,14 +203,14 @@ actor ChannelExecutor { try? await Task.sleep(nanoseconds: 10_000_000) } if shellChannel == nil { - sshLog("πŸ”§ [E\(executorId):\(channelKey)] ❌ No shell channel available") + sshLog("β˜„οΈŽ [E\(executorId):\(channelKey)] ❌ No shell channel available") } return shellChannel } /// Close shell channel func close() { - sshLog("πŸ”§ [E\(executorId)] ChannelExecutor: Closing shell channel") + sshLog("β˜„οΈŽ [E\(executorId)] ChannelExecutor: Closing shell channel") if let chan = self.shellChannel { chan.close(mode: .all, promise: nil) } diff --git a/Control/SSH/SSHClient.swift b/Control/SSH/SSHClient.swift index 10f683f..1e19515 100644 --- a/Control/SSH/SSHClient.swift +++ b/Control/SSH/SSHClient.swift @@ -44,7 +44,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { // Create a new ChannelExecutor for this physical key ("app-N" or "system") let executor = ChannelExecutor(connection: connection, channelKey: executorKey) dedicatedExecutors[executorKey] = executor - sshLog("πŸ”§ SSH: Channel '\(executorKey)' ready") + sshLog("β˜•οΈŽ Channel '\(executorKey)' ready") return executor } @@ -65,7 +65,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { } return result } catch { - sshLog("πŸ“‘ SSHClient: ❌ Failed to get executor or run command: \(error)") + sshLog("❌ Failed to get executor or run command: \(error)") return .failure(error) } } @@ -92,7 +92,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { hasCompletedConnection = false let connectionId = String(UUID().uuidString.prefix(8)) - sshLog("πŸ†” [\(connectionId)] SSHClient: Connecting to \(host) as \(username)") + sshLog("⚯ [\(connectionId)] SSHClient: Connecting to \(host) as \(username)") // Only clean up if we have an active connection if connection != nil { @@ -153,7 +153,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { switch result { case .success(let channel): - sshLog("βœ“ [\(connectionId)] TCP connection established") + sshLog("⚭ [\(connectionId)] TCP connection established") self.connection = channel // With client-side channels, we don't need to pre-create a main session. // The connection is ready to be used by ChannelExecutors. @@ -162,7 +162,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { self.hasCompletedConnection = true completion(.failure(SSHError.authenticationFailed)) } else { - sshLog("βœ“ [\(connectionId)] SSH connection ready for channels") + sshLog("β˜•οΈŽ [\(connectionId)] SSH connection ready for channels") self.hasCompletedConnection = true completion(.success(())) } @@ -204,7 +204,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { } func disconnect() { - sshLog("SSHClient: Starting disconnect process") + sshLog("⚯ Starting disconnect process") // Reset connection completion state hasCompletedConnection = false @@ -218,7 +218,7 @@ class SSHClient: SSHClientProtocol, @unchecked Sendable { connection?.close(promise: nil) connection = nil - sshLog("βœ“ SSHClient disconnected and cleaned up") + sshLog("⚰︎ SSHClient disconnected and cleaned up") } /// Maps a logical channel key ("system", "heartbeat", app id) to its underlying executor key. diff --git a/Control/SSH/SSHConnectionManager.swift b/Control/SSH/SSHConnectionManager.swift index fe09ded..6121a65 100644 --- a/Control/SSH/SSHConnectionManager.swift +++ b/Control/SSH/SSHConnectionManager.swift @@ -128,7 +128,6 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { return } hasResumed = true - connectionLog("πŸ”„ [\(connectionId)] Processing connection result: \(result)") switch result { case .success: @@ -238,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() { @@ -260,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() @@ -269,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 } @@ -449,7 +448,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { self.currentHeartbeatInterval = min(self.currentHeartbeatInterval + 1, self.maxHeartbeatInterval) } } - connectionLog("πŸ”„ Heartbeat started (interval \(minHeartbeatInterval)s -> \(maxHeartbeatInterval)s)") + connectionLog("β™‘ Heartbeat started (interval \(minHeartbeatInterval)s -> \(maxHeartbeatInterval)s)") lastHeartbeatSuccess = Date() recoveryDeadline = nil @@ -458,7 +457,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { private func stopHeartbeat() { heartbeatTask?.cancel() heartbeatTask = nil - connectionLog("⏹️ Heartbeat stopped") + connectionLog("β›”οΈŽ Heartbeat stopped") recoveryDeadline = nil } @@ -511,7 +510,7 @@ class SSHConnectionManager: ObservableObject, SSHClientProtocol { connectionState = .connected connectionLog("βœ… Recovery complete – connection restored (\(String(format: "%.0f", rtt*1000)) ms)") } else { - connectionLog("βœ“ Heartbeat OK (\(id), \(String(format: "%.0f", rtt*1000)) ms)") + connectionLog("β™‘ Heartbeat OK (\(id), \(String(format: "%.0f", rtt*1000)) ms)") } recoveryDeadline = nil } 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 151d216..3506e92 100644 --- a/Control/Views/ConnectionsView.swift +++ b/Control/Views/ConnectionsView.swift @@ -260,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 d874f47..e80b6e9 100644 --- a/Control/Views/ControlView.swift +++ b/Control/Views/ControlView.swift @@ -48,7 +48,7 @@ struct ControlView: View, SSHConnectedView { } DispatchQueue.main.asyncAfter(deadline: .now() + 0.025) { isReady = true - viewLog("ControlView: Ready state activated", view: "ControlView") + viewLog("Ready state activated", view: "ControlView") } } @@ -79,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) @@ -257,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") @@ -280,39 +280,39 @@ struct ControlView: View, SSHConnectedView { 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("ControlView: Recovering connection...", view: "ControlView") + 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) { @@ -355,7 +355,7 @@ 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 let now = Date()