From ca1c4367b7a2875a584471ed797f4fb5eee8215a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 5 May 2025 15:52:19 +1000 Subject: [PATCH 1/6] feat: support RDP-specific deep links --- Coder-Desktop/Coder-Desktop/URLHandler.swift | 63 ++++++++++++++++--- .../Coder-Desktop/VPN/MenuState.swift | 9 +++ Coder-Desktop/VPNLib/CoderRouter.swift | 27 +++++++- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/URLHandler.swift b/Coder-Desktop/Coder-Desktop/URLHandler.swift index 191c19d..5ad3caf 100644 --- a/Coder-Desktop/Coder-Desktop/URLHandler.swift +++ b/Coder-Desktop/Coder-Desktop/URLHandler.swift @@ -20,20 +20,65 @@ class URLHandler { guard deployment.host() == url.host else { throw .invalidAuthority(url.host() ?? "") } + let route: CoderRoute do { - switch try router.match(url: url) { - case let .open(workspace, agent, type): - switch type { - case let .rdp(creds): - handleRDP(workspace: workspace, agent: agent, creds: creds) - } - } + route = try router.match(url: url) } catch { throw .matchError(url: url) } - func handleRDP(workspace _: String, agent _: String, creds _: RDPCredentials) { - // TODO: Handle RDP + switch route { + case let .open(workspace, agent, type): + switch type { + case let .rdp(creds): + try handleRDP(workspace: workspace, agent: agent, creds: creds) + } + } + } + + private func handleRDP(workspace: String, agent: String, creds: RDPCredentials) throws(URLError) { + guard vpn.state == .connected else { + throw .openError(.coderConnectOffline) + } + + guard let workspace = vpn.menuState.findWorkspace(name: workspace) else { + throw .openError(.invalidWorkspace(workspace: workspace)) + } + + guard let agent = vpn.menuState.findAgent(workspaceID: workspace.id, name: agent) else { + throw .openError(.invalidAgent(workspace: workspace.name, agent: agent)) + } + + var rdpString = "rdp:full address=s:\(agent.primaryHost):3389" + if let username = creds.username { + rdpString += "&username=s:\(username)" + } + guard let url = URL(string: rdpString) else { + throw .openError(.couldNotCreateRDPURL(rdpString)) + } + + let alert = NSAlert() + alert.messageText = "Opening RDP" + alert.informativeText = "Connecting to \(agent.primaryHost)." + if let username = creds.username { + alert.informativeText += "\nUsername: \(username)" + } + if creds.password != nil { + alert.informativeText += "\nThe password will be copied to your clipboard." + } + + alert.alertStyle = .informational + alert.addButton(withTitle: "Open") + alert.addButton(withTitle: "Cancel") + let response = alert.runModal() + if response == .alertFirstButtonReturn { + if let password = creds.password { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(password, forType: .string) + } + NSWorkspace.shared.open(url) + } else { + // User cancelled } } } diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index 59dfae0..c989c1d 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -58,6 +58,15 @@ struct VPNMenuState { // or have any invalid UUIDs. var invalidAgents: [Vpn_Agent] = [] + public func findAgent(workspaceID: UUID, name: String) -> Agent? { + agents.first(where: { $0.value.wsID == workspaceID && $0.value.name == name })?.value + } + + public func findWorkspace(name: String) -> Workspace? { + workspaces + .first(where: { $0.value.name == name })?.value + } + mutating func upsertAgent(_ agent: Vpn_Agent) { guard let id = UUID(uuidData: agent.id), diff --git a/Coder-Desktop/VPNLib/CoderRouter.swift b/Coder-Desktop/VPNLib/CoderRouter.swift index de849e7..f030bf4 100644 --- a/Coder-Desktop/VPNLib/CoderRouter.swift +++ b/Coder-Desktop/VPNLib/CoderRouter.swift @@ -33,8 +33,9 @@ public enum RouterError: Error { case invalidAuthority(String) case matchError(url: URL) case noSession + case openError(OpenError) - public var description: String { + var description: String { switch self { case let .invalidAuthority(authority): "Authority '\(authority)' does not match the host of the current Coder deployment." @@ -42,6 +43,30 @@ public enum RouterError: Error { "Failed to handle \(url.absoluteString) because the format is unsupported." case .noSession: "Not logged in." + case let .openError(error): + error.description + } + } + + var localizedDescription: String { description } +} + +public enum OpenError: Error { + case invalidWorkspace(workspace: String) + case invalidAgent(workspace: String, agent: String) + case coderConnectOffline + case couldNotCreateRDPURL(String) + + public var description: String { + switch self { + case let .invalidWorkspace(ws): + "Could not find workspace '\(ws)'. Does it exist?" + case .coderConnectOffline: + "Coder Connect must be running." + case let .invalidAgent(workspace: workspace, agent: agent): + "Could not find agent '\(agent)' in workspace '\(workspace)'. Is the workspace running?" + case let .couldNotCreateRDPURL(rdpString): + "Could not create construct RDP url from '\(rdpString)'." } } From b0a30345482e7b25056becf16561ba9a6e8e600f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 5 May 2025 21:23:11 +1000 Subject: [PATCH 2/6] switch to xcbeautify --- scripts/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build.sh b/scripts/build.sh index 227f04b..de6f34a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -131,7 +131,7 @@ xcodebuild \ CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" \ CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO \ CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION=YES \ - OTHER_CODE_SIGN_FLAGS='--timestamp' | LC_ALL="en_US.UTF-8" xcpretty + OTHER_CODE_SIGN_FLAGS='--timestamp' | xcbeautify # Create exportOptions.plist EXPORT_OPTIONS_PATH="./build/exportOptions.plist" From 8e8165379cfcc626ba69ecbeab571feabb525962 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 6 May 2025 12:46:18 +1000 Subject: [PATCH 3/6] improve error handling --- Coder-Desktop/Coder-Desktop/URLHandler.swift | 21 ++++++++++++-------- Coder-Desktop/VPNLib/CoderRouter.swift | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/URLHandler.swift b/Coder-Desktop/Coder-Desktop/URLHandler.swift index 5ad3caf..0dbc924 100644 --- a/Coder-Desktop/Coder-Desktop/URLHandler.swift +++ b/Coder-Desktop/Coder-Desktop/URLHandler.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import VPNLib @MainActor @@ -29,24 +30,28 @@ class URLHandler { switch route { case let .open(workspace, agent, type): - switch type { - case let .rdp(creds): - try handleRDP(workspace: workspace, agent: agent, creds: creds) + do { + switch type { + case let .rdp(creds): + try handleRDP(workspace: workspace, agent: agent, creds: creds) + } + } catch { + throw .openError(error) } } } - private func handleRDP(workspace: String, agent: String, creds: RDPCredentials) throws(URLError) { + private func handleRDP(workspace: String, agent: String, creds: RDPCredentials) throws(OpenError) { guard vpn.state == .connected else { - throw .openError(.coderConnectOffline) + throw .coderConnectOffline } guard let workspace = vpn.menuState.findWorkspace(name: workspace) else { - throw .openError(.invalidWorkspace(workspace: workspace)) + throw .invalidWorkspace(workspace: workspace) } guard let agent = vpn.menuState.findAgent(workspaceID: workspace.id, name: agent) else { - throw .openError(.invalidAgent(workspace: workspace.name, agent: agent)) + throw .invalidAgent(workspace: workspace.name, agent: agent) } var rdpString = "rdp:full address=s:\(agent.primaryHost):3389" @@ -54,7 +59,7 @@ class URLHandler { rdpString += "&username=s:\(username)" } guard let url = URL(string: rdpString) else { - throw .openError(.couldNotCreateRDPURL(rdpString)) + throw .couldNotCreateRDPURL(rdpString) } let alert = NSAlert() diff --git a/Coder-Desktop/VPNLib/CoderRouter.swift b/Coder-Desktop/VPNLib/CoderRouter.swift index f030bf4..c758ccf 100644 --- a/Coder-Desktop/VPNLib/CoderRouter.swift +++ b/Coder-Desktop/VPNLib/CoderRouter.swift @@ -35,7 +35,7 @@ public enum RouterError: Error { case noSession case openError(OpenError) - var description: String { + public var description: String { switch self { case let .invalidAuthority(authority): "Authority '\(authority)' does not match the host of the current Coder deployment." @@ -48,7 +48,7 @@ public enum RouterError: Error { } } - var localizedDescription: String { description } + public var localizedDescription: String { description } } public enum OpenError: Error { From 391b1311c43f2af3cd9e9b94de4b49ea6be75cbb Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 6 May 2025 13:55:31 +1000 Subject: [PATCH 4/6] reference issue --- Coder-Desktop/VPNLib/CoderRouter.swift | 1 + Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Coder-Desktop/VPNLib/CoderRouter.swift b/Coder-Desktop/VPNLib/CoderRouter.swift index c758ccf..ff05992 100644 --- a/Coder-Desktop/VPNLib/CoderRouter.swift +++ b/Coder-Desktop/VPNLib/CoderRouter.swift @@ -2,6 +2,7 @@ import Foundation import URLRouting // This is in VPNLib to avoid depending on `swift-collections` in both the app & extension. +// https://github.com/coder/coder-desktop-macos/issues/149 public struct CoderRouter: ParserPrinter { public init() {} diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 01e1d6b..9e5df20 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -24,6 +24,9 @@ public protocol FileSyncDaemon: ObservableObject { func resetSessions(ids: [String]) async throws(DaemonError) } + +// File Sync related code is in VPNLib to workaround a linking issue +// https://github.com/coder/coder-desktop-macos/issues/149 @MainActor public class MutagenDaemon: FileSyncDaemon { let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen") From ed6515e3d4586ffd74c087b3e27d9fecbfdc1872 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 6 May 2025 13:56:22 +1000 Subject: [PATCH 5/6] fmt --- Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 9e5df20..98807e3 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -24,7 +24,6 @@ public protocol FileSyncDaemon: ObservableObject { func resetSessions(ids: [String]) async throws(DaemonError) } - // File Sync related code is in VPNLib to workaround a linking issue // https://github.com/coder/coder-desktop-macos/issues/149 @MainActor From 1227eec2013d78aa6d79fd4c2d36dc02ef00d094 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 6 May 2025 14:04:35 +1000 Subject: [PATCH 6/6] words --- Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift | 2 +- Coder-Desktop/VPNLib/CoderRouter.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index e2fe3ab..307e079 100644 --- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift +++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift @@ -85,7 +85,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { image: "MenuBarIcon", onAppear: { // If the VPN is enabled, it's likely the token isn't expired - guard case .disabled = self.vpn.state, self.state.hasSession else { return } + guard self.vpn.state != .connected, self.state.hasSession else { return } Task { @MainActor in await self.state.handleTokenExpiry() } diff --git a/Coder-Desktop/VPNLib/CoderRouter.swift b/Coder-Desktop/VPNLib/CoderRouter.swift index ff05992..d562e39 100644 --- a/Coder-Desktop/VPNLib/CoderRouter.swift +++ b/Coder-Desktop/VPNLib/CoderRouter.swift @@ -67,7 +67,7 @@ public enum OpenError: Error { case let .invalidAgent(workspace: workspace, agent: agent): "Could not find agent '\(agent)' in workspace '\(workspace)'. Is the workspace running?" case let .couldNotCreateRDPURL(rdpString): - "Could not create construct RDP url from '\(rdpString)'." + "Could not construct RDP URL from '\(rdpString)'." } }