Thanks to visit codestin.com
Credit goes to github.com

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

+16-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) {
@@ -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

+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>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
<!--
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
+64
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+
}
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

+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:
@@ -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

+1
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

+1
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)