Skip to content

feat: use the deployment's hostname suffix in the UI #133

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {

override init() {
vpn = CoderVPNService()
state = AppState(onChange: vpn.configureTunnelProviderProtocol)
let state = AppState(onChange: vpn.configureTunnelProviderProtocol)
vpn.onStart = {
// We don't need this to have finished before the VPN actually starts
Task { await state.refreshDeploymentConfig() }
}
if state.startVPNOnLaunch {
vpn.startWhenReady = true
}
self.state = state
vpn.installSystemExtension()
#if arch(arm64)
let mutagenBinary = "mutagen-darwin-arm64"
Expand Down
37 changes: 34 additions & 3 deletions Coder-Desktop/Coder-Desktop/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class AppState: ObservableObject {
}
}

@Published private(set) var hostnameSuffix: String = defaultHostnameSuffix

static let defaultHostnameSuffix: String = "coder"

// Stored in Keychain
@Published private(set) var sessionToken: String? {
didSet {
Expand All @@ -33,6 +37,8 @@ class AppState: ObservableObject {
}
}

private var client: Client?

@Published var useLiteralHeaders: Bool = UserDefaults.standard.bool(forKey: Keys.useLiteralHeaders) {
didSet {
reconfigure()
Expand Down Expand Up @@ -80,7 +86,7 @@ class AppState: ObservableObject {
private let keychain: Keychain
private let persistent: Bool

let onChange: ((NETunnelProviderProtocol?) -> Void)?
private let onChange: ((NETunnelProviderProtocol?) -> Void)?

// reconfigure must be called when any property used to configure the VPN changes
public func reconfigure() {
Expand All @@ -107,21 +113,35 @@ class AppState: ObservableObject {
if sessionToken == nil || sessionToken!.isEmpty == true {
clearSession()
}
client = Client(
url: baseAccessURL!,
token: sessionToken!,
headers: useLiteralHeaders ? literalHeaders.map { $0.toSDKHeader() } : []
)
Task {
await handleTokenExpiry()
Copy link
Member Author

@ethanndickson ethanndickson Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by fix: we need to check for token expiry on app launch. We were previously only checking when the menu bar window was opened, and Connect was disabled. We need to account for when Connect is configured to start when the app is launched.

await refreshDeploymentConfig()
}
}
}

public func login(baseAccessURL: URL, sessionToken: String) {
hasSession = true
self.baseAccessURL = baseAccessURL
self.sessionToken = sessionToken
client = Client(
url: baseAccessURL,
token: sessionToken,
headers: useLiteralHeaders ? literalHeaders.map { $0.toSDKHeader() } : []
)
Task { await refreshDeploymentConfig() }
reconfigure()
}

public func handleTokenExpiry() async {
if hasSession {
let client = Client(url: baseAccessURL!, token: sessionToken!)
do {
_ = try await client.user("me")
_ = try await client!.user("me")
} catch let SDKError.api(apiErr) {
// Expired token
if apiErr.statusCode == 401 {
Expand All @@ -135,9 +155,20 @@ class AppState: ObservableObject {
}
}

public func refreshDeploymentConfig() async {
if hasSession {
let res = try? await retry(floor: .milliseconds(100), ceil: .seconds(10)) {
let config = try await client!.agentConnectionInfoGeneric()
return config.hostname_suffix
}
hostnameSuffix = res ?? Self.defaultHostnameSuffix
}
}

public func clearSession() {
hasSession = false
sessionToken = nil
client = nil
reconfigure()
}

Expand Down
6 changes: 5 additions & 1 deletion Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ final class CoderVPNService: NSObject, VPNService {

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

// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get
// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework
Expand Down Expand Up @@ -187,8 +188,11 @@ extension CoderVPNService {
xpc.connect()
xpc.ping()
tunnelState = .connecting
// Non-connected -> Connected: Retrieve Peers
// Non-connected -> Connected:
// - Retrieve Peers
// - Run `onStart` closure
case (_, .connected):
onStart?()
xpc.connect()
xpc.getPeerState()
tunnelState = .connected
Expand Down
9 changes: 6 additions & 3 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,23 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
}

struct MenuItemView: View {
@EnvironmentObject var state: AppState

let item: VPNMenuItem
let baseAccessURL: URL
@State private var nameIsSelected: Bool = false
@State private var copyIsSelected: Bool = false

private var itemName: AttributedString {
let name = switch item {
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).coder"
case .offlineWorkspace: "\(item.wsName).coder"
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).\(state.hostnameSuffix)"
case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
}

var formattedName = AttributedString(name)
formattedName.foregroundColor = .primary
if let range = formattedName.range(of: ".coder") {

if let range = formattedName.range(of: ".\(state.hostnameSuffix)", options: .backwards) {
formattedName[range].foregroundColor = .secondary
}
return formattedName
Expand Down
25 changes: 25 additions & 0 deletions Coder-Desktop/CoderSDK/Util.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

public func retry<T>(
floor: Duration,
ceil: Duration,
rate: Double = 1.618,
operation: @Sendable () async throws -> T
) async throws -> T {
var delay = floor

while !Task.isCancelled {
do {
return try await operation()
} catch let error as CancellationError {
throw error
} catch {
try Task.checkCancellation()

delay = min(ceil, delay * rate)
try await Task.sleep(for: delay)
}
}

throw CancellationError()
}
15 changes: 15 additions & 0 deletions Coder-Desktop/CoderSDK/WorkspaceAgents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

public extension Client {
func agentConnectionInfoGeneric() async throws(SDKError) -> AgentConnectionInfo {
let res = try await request("/api/v2/workspaceagents/connection", method: .get)
guard res.resp.statusCode == 200 else {
throw responseAsError(res)
}
return try decode(AgentConnectionInfo.self, from: res.data)
}
}

public struct AgentConnectionInfo: Codable, Sendable {
public let hostname_suffix: String?
}
Loading