Skip to content

Commit afd9634

Browse files
feat: use the deployment's hostname suffix in the UI (#133)
Closes #93. <img width="272" alt="image" src="https://github.com/user-attachments/assets/54786bea-9d32-432f-9869-5abd42a86516" /> The only time the hostname suffix is used by the desktop app is when an offline workspace needs to be shown in the list, where we naively append `.coder`. This PR sets this appended value to whatever `--workspace-hostname-suffix` is configured to deployment-side. We read the config value from the deployment when: - The app is launched, if the user is signed in. - The user signs in. - The VPN is started.
1 parent 918bacd commit afd9634

File tree

6 files changed

+105
-8
lines changed

6 files changed

+105
-8
lines changed

Diff for: Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4141

4242
override init() {
4343
vpn = CoderVPNService()
44-
state = AppState(onChange: vpn.configureTunnelProviderProtocol)
44+
let state = AppState(onChange: vpn.configureTunnelProviderProtocol)
45+
vpn.onStart = {
46+
// We don't need this to have finished before the VPN actually starts
47+
Task { await state.refreshDeploymentConfig() }
48+
}
4549
if state.startVPNOnLaunch {
4650
vpn.startWhenReady = true
4751
}
52+
self.state = state
4853
vpn.installSystemExtension()
4954
#if arch(arm64)
5055
let mutagenBinary = "mutagen-darwin-arm64"

Diff for: Coder-Desktop/Coder-Desktop/State.swift

+48-3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class AppState: ObservableObject {
2525
}
2626
}
2727

28+
@Published private(set) var hostnameSuffix: String = defaultHostnameSuffix
29+
30+
static let defaultHostnameSuffix: String = "coder"
31+
2832
// Stored in Keychain
2933
@Published private(set) var sessionToken: String? {
3034
didSet {
@@ -33,6 +37,8 @@ class AppState: ObservableObject {
3337
}
3438
}
3539

40+
private var client: Client?
41+
3642
@Published var useLiteralHeaders: Bool = UserDefaults.standard.bool(forKey: Keys.useLiteralHeaders) {
3743
didSet {
3844
reconfigure()
@@ -80,7 +86,7 @@ class AppState: ObservableObject {
8086
private let keychain: Keychain
8187
private let persistent: Bool
8288

83-
let onChange: ((NETunnelProviderProtocol?) -> Void)?
89+
private let onChange: ((NETunnelProviderProtocol?) -> Void)?
8490

8591
// reconfigure must be called when any property used to configure the VPN changes
8692
public func reconfigure() {
@@ -107,21 +113,35 @@ class AppState: ObservableObject {
107113
if sessionToken == nil || sessionToken!.isEmpty == true {
108114
clearSession()
109115
}
116+
client = Client(
117+
url: baseAccessURL!,
118+
token: sessionToken!,
119+
headers: useLiteralHeaders ? literalHeaders.map { $0.toSDKHeader() } : []
120+
)
121+
Task {
122+
await handleTokenExpiry()
123+
await refreshDeploymentConfig()
124+
}
110125
}
111126
}
112127

113128
public func login(baseAccessURL: URL, sessionToken: String) {
114129
hasSession = true
115130
self.baseAccessURL = baseAccessURL
116131
self.sessionToken = sessionToken
132+
client = Client(
133+
url: baseAccessURL,
134+
token: sessionToken,
135+
headers: useLiteralHeaders ? literalHeaders.map { $0.toSDKHeader() } : []
136+
)
137+
Task { await refreshDeploymentConfig() }
117138
reconfigure()
118139
}
119140

120141
public func handleTokenExpiry() async {
121142
if hasSession {
122-
let client = Client(url: baseAccessURL!, token: sessionToken!)
123143
do {
124-
_ = try await client.user("me")
144+
_ = try await client!.user("me")
125145
} catch let SDKError.api(apiErr) {
126146
// Expired token
127147
if apiErr.statusCode == 401 {
@@ -135,9 +155,34 @@ class AppState: ObservableObject {
135155
}
136156
}
137157

158+
private var refreshTask: Task<String?, Never>?
159+
public func refreshDeploymentConfig() async {
160+
// Client is non-nil if there's a sesssion
161+
if hasSession, let client {
162+
refreshTask?.cancel()
163+
164+
refreshTask = Task {
165+
let res = try? await retry(floor: .milliseconds(100), ceil: .seconds(10)) {
166+
do {
167+
let config = try await client.agentConnectionInfoGeneric()
168+
return config.hostname_suffix
169+
} catch {
170+
logger.error("failed to get agent connection info (retrying): \(error)")
171+
throw error
172+
}
173+
}
174+
return res
175+
}
176+
177+
hostnameSuffix = await refreshTask?.value ?? Self.defaultHostnameSuffix
178+
}
179+
}
180+
138181
public func clearSession() {
139182
hasSession = false
140183
sessionToken = nil
184+
refreshTask?.cancel()
185+
client = nil
141186
reconfigure()
142187
}
143188

Diff for: Coder-Desktop/Coder-Desktop/VPN/VPNService.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ final class CoderVPNService: NSObject, VPNService {
7676

7777
// Whether the VPN should start as soon as possible
7878
var startWhenReady: Bool = false
79+
var onStart: (() -> Void)?
7980

8081
// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get
8182
// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework
@@ -187,8 +188,11 @@ extension CoderVPNService {
187188
xpc.connect()
188189
xpc.ping()
189190
tunnelState = .connecting
190-
// Non-connected -> Connected: Retrieve Peers
191+
// Non-connected -> Connected:
192+
// - Retrieve Peers
193+
// - Run `onStart` closure
191194
case (_, .connected):
195+
onStart?()
192196
xpc.connect()
193197
xpc.getPeerState()
194198
tunnelState = .connected

Diff for: Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift

+6-3
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,23 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
4242
}
4343

4444
struct MenuItemView: View {
45+
@EnvironmentObject var state: AppState
46+
4547
let item: VPNMenuItem
4648
let baseAccessURL: URL
4749
@State private var nameIsSelected: Bool = false
4850
@State private var copyIsSelected: Bool = false
4951

5052
private var itemName: AttributedString {
5153
let name = switch item {
52-
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).coder"
53-
case .offlineWorkspace: "\(item.wsName).coder"
54+
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).\(state.hostnameSuffix)"
55+
case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
5456
}
5557

5658
var formattedName = AttributedString(name)
5759
formattedName.foregroundColor = .primary
58-
if let range = formattedName.range(of: ".coder") {
60+
61+
if let range = formattedName.range(of: ".\(state.hostnameSuffix)", options: .backwards) {
5962
formattedName[range].foregroundColor = .secondary
6063
}
6164
return formattedName

Diff for: Coder-Desktop/CoderSDK/Util.swift

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
3+
public func retry<T>(
4+
floor: Duration,
5+
ceil: Duration,
6+
rate: Double = 1.618,
7+
operation: @Sendable () async throws -> T
8+
) async throws -> T {
9+
var delay = floor
10+
11+
while !Task.isCancelled {
12+
do {
13+
return try await operation()
14+
} catch let error as CancellationError {
15+
throw error
16+
} catch {
17+
try Task.checkCancellation()
18+
19+
delay = min(ceil, delay * rate)
20+
try await Task.sleep(for: delay)
21+
}
22+
}
23+
24+
throw CancellationError()
25+
}

Diff for: Coder-Desktop/CoderSDK/WorkspaceAgents.swift

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
3+
public extension Client {
4+
func agentConnectionInfoGeneric() async throws(SDKError) -> AgentConnectionInfo {
5+
let res = try await request("/api/v2/workspaceagents/connection", method: .get)
6+
guard res.resp.statusCode == 200 else {
7+
throw responseAsError(res)
8+
}
9+
return try decode(AgentConnectionInfo.self, from: res.data)
10+
}
11+
}
12+
13+
public struct AgentConnectionInfo: Codable, Sendable {
14+
public let hostname_suffix: String?
15+
}

0 commit comments

Comments
 (0)