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