Skip to content

Commit 0cf2f28

Browse files
chore: add handler and router for coder scheme URIs (#145)
Relates to #96. Closes #95
1 parent 49fd303 commit 0cf2f28

File tree

9 files changed

+249
-3
lines changed

9 files changed

+249
-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>1024Icon</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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
import VPNLib
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(RouterError) {
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 .matchError(url: url)
33+
}
34+
35+
func handleRDP(workspace _: String, agent _: String, creds _: RDPCredentials) {
36+
// TODO: Handle RDP
37+
}
38+
}
39+
}

Coder-Desktop/Resources/1024Icon.png

17.7 KB
Loading
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Foundation
2+
import URLRouting
3+
4+
// This is in VPNLib to avoid depending on `swift-collections` in both the app & extension.
5+
public struct CoderRouter: ParserPrinter {
6+
public init() {}
7+
8+
public var body: some ParserPrinter<URLRequestData, CoderRoute> {
9+
Route(.case(CoderRoute.open(workspace:agent:route:))) {
10+
Scheme("coder")
11+
// v0/open/ws/<workspace>/agent/<agent>/<openType>
12+
Path { "v0"; "open"; "ws"; Parse(.string); "agent"; Parse(.string) }
13+
openRouter
14+
}
15+
}
16+
17+
var openRouter: some ParserPrinter<URLRequestData, OpenRoute> {
18+
OneOf {
19+
Route(.memberwise(OpenRoute.rdp)) {
20+
Path { "rdp" }
21+
Query {
22+
Parse(.memberwise(RDPCredentials.init)) {
23+
Optionally { Field("username") }
24+
Optionally { Field("password") }
25+
}
26+
}
27+
}
28+
}
29+
}
30+
}
31+
32+
public enum RouterError: Error {
33+
case invalidAuthority(String)
34+
case matchError(url: URL)
35+
case noSession
36+
37+
public var description: String {
38+
switch self {
39+
case let .invalidAuthority(authority):
40+
"Authority '\(authority)' does not match the host of the current Coder deployment."
41+
case let .matchError(url):
42+
"Failed to handle \(url.absoluteString) because the format is unsupported."
43+
case .noSession:
44+
"Not logged in."
45+
}
46+
}
47+
48+
public var localizedDescription: String { description }
49+
}
50+
51+
public enum CoderRoute: Equatable, Sendable {
52+
case open(workspace: String, agent: String, route: OpenRoute)
53+
}
54+
55+
public enum OpenRoute: Equatable, Sendable {
56+
case rdp(RDPCredentials)
57+
}
58+
59+
// Due to a Swift Result builder limitation, we can't flatten this out to `case rdp(String?, String?)`
60+
// https://github.com/pointfreeco/swift-url-routing/issues/50
61+
public struct RDPCredentials: Equatable, Sendable {
62+
public let username: String?
63+
public let password: String?
64+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import Foundation
2+
import Testing
3+
import URLRouting
4+
@testable import VPNLib
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: "coder://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: "coder://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: "coder://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: "coder://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: "coder://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: "coder://coder.example.com/invalid/path",
72+
expectedRoute: nil,
73+
description: "Completely invalid path"
74+
),
75+
RouteTestCase(
76+
urlString: "coder://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: "coder://coder.example.com/v0/open/workspace-123/agent/agent-456/rdp",
82+
expectedRoute: nil,
83+
description: "Missing 'ws' segment"
84+
),
85+
RouteTestCase(
86+
urlString: "coder://coder.example.com/v0/open/ws/workspace-123/rdp",
87+
expectedRoute: nil,
88+
description: "Missing agent segment"
89+
),
90+
RouteTestCase(
91+
urlString: "http://coder.example.com/v0/open/ws/workspace-123/agent/agent-456",
92+
expectedRoute: nil,
93+
description: "Wrong scheme"
94+
),
95+
])
96+
func testRdpRoutes(testCase: RouteTestCase) throws {
97+
let url = URL(string: testCase.urlString)!
98+
99+
if let expectedRoute = testCase.expectedRoute {
100+
let route = try router.match(url: url)
101+
#expect(route == expectedRoute)
102+
} else {
103+
#expect(throws: (any Error).self) {
104+
_ = try router.match(url: url)
105+
}
106+
}
107+
}
108+
}

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:
@@ -290,6 +294,7 @@ targets:
290294
- package: GRPC
291295
- package: Subprocess
292296
- package: Semaphore
297+
- package: URLRouting
293298
- target: CoderSDK
294299
embed: false
295300

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ test: $(addprefix $(PROJECT)/Resources/,$(MUTAGEN_RESOURCES)) $(XCPROJECT) ## Ru
121121
-project $(XCPROJECT) \
122122
-scheme $(SCHEME) \
123123
-testPlan $(TEST_PLAN) \
124+
-skipMacroValidation \
124125
-skipPackagePluginValidation \
125126
CODE_SIGNING_REQUIRED=NO \
126127
CODE_SIGNING_ALLOWED=NO | xcbeautify

scripts/build.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ xcodebuild \
125125
-configuration "Release" \
126126
-archivePath "$ARCHIVE_PATH" \
127127
archive \
128+
-skipMacroValidation \
128129
-skipPackagePluginValidation \
129130
CODE_SIGN_STYLE=Manual \
130131
CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" \

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