Skip to content

Commit 0050fc7

Browse files
committed
chore: add handler and router for coder scheme URIs
1 parent 6417d16 commit 0050fc7

File tree

6 files changed

+237
-3
lines changed

6 files changed

+237
-3
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+17-3
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ struct DesktopApp: App {
1717
Window("Sign In", id: Windows.login.rawValue) {
1818
LoginForm()
1919
.environmentObject(appDelegate.state)
20-
}
21-
.windowResizability(.contentSize)
20+
}.handlesExternalEvents(matching: Set()) // Don't handle deep links
21+
.windowResizability(.contentSize)
2222
SwiftUI.Settings {
2323
SettingsView<CoderVPNService>()
2424
.environmentObject(appDelegate.vpn)
@@ -30,7 +30,7 @@ struct DesktopApp: App {
3030
.environmentObject(appDelegate.state)
3131
.environmentObject(appDelegate.fileSyncDaemon)
3232
.environmentObject(appDelegate.vpn)
33-
}
33+
}.handlesExternalEvents(matching: Set()) // Don't handle deep links
3434
}
3535
}
3636

@@ -40,6 +40,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4040
let vpn: CoderVPNService
4141
let state: AppState
4242
let fileSyncDaemon: MutagenDaemon
43+
let urlHandler: URLHandler
4344

4445
override init() {
4546
vpn = CoderVPNService()
@@ -65,6 +66,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6566
await fileSyncDaemon.tryStart()
6667
}
6768
self.fileSyncDaemon = fileSyncDaemon
69+
urlHandler = URLHandler(state: state, vpn: vpn)
6870
}
6971

7072
func applicationDidFinishLaunching(_: Notification) {
@@ -126,6 +128,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
126128
func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
127129
false
128130
}
131+
132+
// If a deep link wasn't handled by any scenes in the `App`, it'll be sent here
133+
func application(_: NSApplication, open urls: [URL]) {
134+
guard let url = urls.first else {
135+
// We only accept one at time, for now
136+
return
137+
}
138+
do { try urlHandler.handle(url) } catch {
139+
// TODO: Push notification
140+
print(error.description)
141+
}
142+
}
129143
}
130144

131145
extension AppDelegate {

Coder-Desktop/Coder-Desktop/Info.plist

+15
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>CFBundleURLTypes</key>
6+
<array>
7+
<dict>
8+
<key>CFBundleTypeRole</key>
9+
<string>Editor</string>
10+
<key>CFBundleURLIconFile</key>
11+
<string>1024</string>
12+
<key>CFBundleURLName</key>
13+
<string>com.coder.Coder-Desktop</string>
14+
<key>CFBundleURLSchemes</key>
15+
<array>
16+
<string>coder</string>
17+
</array>
18+
</dict>
19+
</array>
520
<key>NSAppTransportSecurity</key>
621
<dict>
722
<!--
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import Foundation
2+
import URLRouting
3+
4+
@MainActor
5+
class URLHandler {
6+
let state: AppState
7+
let vpn: any VPNService
8+
let router: CoderRouter
9+
10+
init(state: AppState, vpn: any VPNService) {
11+
self.state = state
12+
self.vpn = vpn
13+
router = CoderRouter()
14+
}
15+
16+
func handle(_ url: URL) throws(URLError) {
17+
guard state.hasSession, let deployment = state.baseAccessURL else {
18+
throw .noSession
19+
}
20+
guard deployment.host() == url.host else {
21+
throw .invalidAuthority(url.host() ?? "<none>")
22+
}
23+
do {
24+
switch try router.match(url: url) {
25+
case let .open(workspace, agent, type):
26+
switch type {
27+
case let .rdp(creds):
28+
handleRDP(workspace: workspace, agent: agent, creds: creds)
29+
}
30+
}
31+
} catch {
32+
throw .routerError(url: url)
33+
}
34+
35+
func handleRDP(workspace _: String, agent _: String, creds _: RDPCredentials) {
36+
// TODO: Handle RDP
37+
}
38+
}
39+
}
40+
41+
struct CoderRouter: ParserPrinter {
42+
public var body: some ParserPrinter<URLRequestData, CoderRoute> {
43+
Route(.case(CoderRoute.open(workspace:agent:route:))) {
44+
// v0/open/ws/<workspace>/agent/<agent>/<openType>
45+
Path { "v0"; "open"; "ws"; Parse(.string); "agent"; Parse(.string) }
46+
openRouter
47+
}
48+
}
49+
50+
var openRouter: some ParserPrinter<URLRequestData, OpenRoute> {
51+
OneOf {
52+
Route(.memberwise(OpenRoute.rdp)) {
53+
Path { "rdp" }
54+
Query {
55+
Parse(.memberwise(RDPCredentials.init)) {
56+
Optionally { Field("username") }
57+
Optionally { Field("password") }
58+
}
59+
}
60+
}
61+
}
62+
}
63+
}
64+
65+
enum URLError: Error {
66+
case invalidAuthority(String)
67+
case routerError(url: URL)
68+
case noSession
69+
70+
var description: String {
71+
switch self {
72+
case let .invalidAuthority(authority):
73+
"Authority '\(authority)' does not match the host of the current Coder deployment."
74+
case let .routerError(url):
75+
"Failed to handle \(url.absoluteString) because the format is unsupported."
76+
case .noSession:
77+
"Not logged in."
78+
}
79+
}
80+
81+
var localizedDescription: String { description }
82+
}
83+
84+
public enum CoderRoute: Equatable, Sendable {
85+
case open(workspace: String, agent: String, route: OpenRoute)
86+
}
87+
88+
public enum OpenRoute: Equatable, Sendable {
89+
case rdp(RDPCredentials)
90+
}
91+
92+
// Due to a Swift Result builder limitation, we can't flatten this out to `case rdp(String?, String?)`
93+
// https://github.com/pointfreeco/swift-url-routing/issues/50
94+
public struct RDPCredentials: Equatable, Sendable {
95+
let username: String?
96+
let password: String?
97+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
@testable import Coder_Desktop
2+
import Foundation
3+
import Testing
4+
import URLRouting
5+
6+
@MainActor
7+
@Suite(.timeLimit(.minutes(1)))
8+
struct CoderRouterTests {
9+
let router: CoderRouter
10+
11+
init() {
12+
router = CoderRouter()
13+
}
14+
15+
struct RouteTestCase: CustomStringConvertible, Sendable {
16+
let urlString: String
17+
let expectedRoute: CoderRoute?
18+
let description: String
19+
}
20+
21+
@Test("RDP routes", arguments: [
22+
// Valid routes
23+
RouteTestCase(
24+
urlString: "https://coder.example.com/v0/open/ws/myworkspace/agent/dev/rdp?username=user&password=pass",
25+
expectedRoute: .open(
26+
workspace: "myworkspace",
27+
agent: "dev",
28+
route: .rdp(RDPCredentials(username: "user", password: "pass"))
29+
),
30+
description: "RDP with username and password"
31+
),
32+
RouteTestCase(
33+
urlString: "https://coder.example.com/v0/open/ws/workspace-123/agent/agent-456/rdp",
34+
expectedRoute: .open(
35+
workspace: "workspace-123",
36+
agent: "agent-456",
37+
route: .rdp(RDPCredentials(username: nil, password: nil))
38+
),
39+
description: "RDP without credentials"
40+
),
41+
RouteTestCase(
42+
urlString: "https://coder.example.com/v0/open/ws/workspace-123/agent/agent-456/rdp?username=user",
43+
expectedRoute: .open(
44+
workspace: "workspace-123",
45+
agent: "agent-456",
46+
route: .rdp(RDPCredentials(username: "user", password: nil))
47+
),
48+
description: "RDP with username only"
49+
),
50+
RouteTestCase(
51+
urlString: "https://coder.example.com/v0/open/ws/workspace-123/agent/agent-456/rdp?password=pass",
52+
expectedRoute: .open(
53+
workspace: "workspace-123",
54+
agent: "agent-456",
55+
route: .rdp(RDPCredentials(username: nil, password: "pass"))
56+
),
57+
description: "RDP with password only"
58+
),
59+
RouteTestCase(
60+
urlString: "https://coder.example.com/v0/open/ws/ws-special-chars/agent/agent-with-dashes/rdp",
61+
expectedRoute: .open(
62+
workspace: "ws-special-chars",
63+
agent: "agent-with-dashes",
64+
route: .rdp(RDPCredentials(username: nil, password: nil))
65+
),
66+
description: "RDP with special characters in workspace and agent IDs"
67+
),
68+
69+
// Invalid routes
70+
RouteTestCase(
71+
urlString: "https://coder.example.com/invalid/path",
72+
expectedRoute: nil,
73+
description: "Completely invalid path"
74+
),
75+
RouteTestCase(
76+
urlString: "https://coder.example.com/v1/open/ws/workspace-123/agent/agent-456/rdp",
77+
expectedRoute: nil,
78+
description: "Invalid version prefix (v1 instead of v0)"
79+
),
80+
RouteTestCase(
81+
urlString: "https://coder.example.com/v0/open/workspace-123/agent/agent-456/rdp",
82+
expectedRoute: nil,
83+
description: "Missing 'ws' segment"
84+
),
85+
RouteTestCase(
86+
urlString: "https://coder.example.com/v0/open/ws/workspace-123/rdp",
87+
expectedRoute: nil,
88+
description: "Missing agent segment"
89+
),
90+
])
91+
func testRdpRoutes(testCase: RouteTestCase) throws {
92+
let url = URL(string: testCase.urlString)!
93+
94+
if let expectedRoute = testCase.expectedRoute {
95+
let route = try router.match(url: url)
96+
#expect(route == expectedRoute)
97+
} else {
98+
#expect(throws: (any Error).self) {
99+
_ = try router.match(url: url)
100+
}
101+
}
102+
}
103+
}

Coder-Desktop/Resources/1024.png

17.7 KB
Loading

Coder-Desktop/project.yml

+5
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ packages:
126126
SDWebImageSVGCoder:
127127
url: https://github.com/SDWebImage/SDWebImageSVGCoder
128128
exactVersion: 1.7.0
129+
URLRouting:
130+
url: https://github.com/pointfreeco/swift-url-routing
131+
revision: 09b155d
132+
129133

130134
targets:
131135
Coder Desktop:
@@ -185,6 +189,7 @@ targets:
185189
- package: LaunchAtLogin
186190
- package: SDWebImageSwiftUI
187191
- package: SDWebImageSVGCoder
192+
- package: URLRouting
188193
scheme:
189194
testPlans:
190195
- path: Coder-Desktop.xctestplan

0 commit comments

Comments
 (0)