Skip to content

Commit f28c9ad

Browse files
committed
chore: add handler and router for coder scheme URIs
1 parent 49fd303 commit f28c9ad

File tree

6 files changed

+236
-3
lines changed

6 files changed

+236
-3
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

Lines changed: 16 additions & 3 deletions
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) {
@@ -134,6 +136,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
134136
return true
135137
}
136138

139+
func application(_: NSApplication, open urls: [URL]) {
140+
guard let url = urls.first else {
141+
// We only accept one at time, for now
142+
return
143+
}
144+
do { try urlHandler.handle(url) } catch {
145+
// TODO: Push notification
146+
print(error.description)
147+
}
148+
}
149+
137150
private func displayIconHiddenAlert() {
138151
let alert = NSAlert()
139152
alert.alertStyle = .informational

Coder-Desktop/Coder-Desktop/Info.plist

Lines changed: 15 additions & 0 deletions
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
<!--
Lines changed: 97 additions & 0 deletions
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+
}
Lines changed: 103 additions & 0 deletions
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

Lines changed: 5 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy