From 918bacd10aae94158ab570f1f1385494ff1a4151 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 10 Apr 2025 14:10:12 +1000
Subject: [PATCH 01/16] refactor(CoderSDK): share code between Client and
 AgentClient (#132)

Refactor to address review feedback that `AgentClient` extending the regular `Client` was confusing.
---
 Coder-Desktop/Coder-Desktop/State.swift       |   2 +-
 .../Views/FileSync/FilePicker.swift           |   8 +-
 .../Coder-Desktop/Views/LoginForm.swift       |   2 +-
 .../Coder-DesktopTests/FilePickerTests.swift  |   4 +-
 .../Coder-DesktopTests/LoginFormTests.swift   |  10 +-
 Coder-Desktop/CoderSDK/AgentClient.swift      |  19 +-
 Coder-Desktop/CoderSDK/AgentLS.swift          |   8 +-
 Coder-Desktop/CoderSDK/Client.swift           | 208 +++++++++++-------
 Coder-Desktop/CoderSDK/Deployment.swift       |   2 +-
 Coder-Desktop/CoderSDK/User.swift             |   2 +-
 .../CoderSDKTests/CoderSDKTests.swift         |   4 +-
 11 files changed, 167 insertions(+), 102 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift
index 39389540..aea2fe99 100644
--- a/Coder-Desktop/Coder-Desktop/State.swift
+++ b/Coder-Desktop/Coder-Desktop/State.swift
@@ -122,7 +122,7 @@ class AppState: ObservableObject {
             let client = Client(url: baseAccessURL!, token: sessionToken!)
             do {
                 _ = try await client.user("me")
-            } catch let ClientError.api(apiErr) {
+            } catch let SDKError.api(apiErr) {
                 // Expired token
                 if apiErr.statusCode == 401 {
                     clearSession()
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
index 4ee31a62..032a0c3b 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
@@ -72,7 +72,7 @@ struct FilePicker: View {
 class FilePickerModel: ObservableObject {
     @Published var rootEntries: [FilePickerEntryModel] = []
     @Published var rootIsLoading: Bool = false
-    @Published var error: ClientError?
+    @Published var error: SDKError?
 
     // It's important that `AgentClient` is a reference type (class)
     // as we were having performance issues with a struct (unless it was a binding).
@@ -87,7 +87,7 @@ class FilePickerModel: ObservableObject {
         rootIsLoading = true
         Task {
             defer { rootIsLoading = false }
-            do throws(ClientError) {
+            do throws(SDKError) {
                 rootEntries = try await client
                     .listAgentDirectory(.init(path: [], relativity: .root))
                     .toModels(client: client)
@@ -149,7 +149,7 @@ class FilePickerEntryModel: Identifiable, Hashable, ObservableObject {
 
     @Published var entries: [FilePickerEntryModel]?
     @Published var isLoading = false
-    @Published var error: ClientError?
+    @Published var error: SDKError?
     @Published private var innerIsExpanded = false
     var isExpanded: Bool {
         get { innerIsExpanded }
@@ -193,7 +193,7 @@ class FilePickerEntryModel: Identifiable, Hashable, ObservableObject {
                     innerIsExpanded = true
                 }
             }
-            do throws(ClientError) {
+            do throws(SDKError) {
                 entries = try await client
                     .listAgentDirectory(.init(path: path, relativity: .root))
                     .toModels(client: client)
diff --git a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
index 8b3d3a48..d2880dda 100644
--- a/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/LoginForm.swift
@@ -207,7 +207,7 @@ enum LoginError: Error {
     case invalidURL
     case outdatedCoderVersion
     case missingServerVersion
-    case failedAuth(ClientError)
+    case failedAuth(SDKError)
 
     var description: String {
         switch self {
diff --git a/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift b/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift
index d361581e..7fde3334 100644
--- a/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/FilePickerTests.swift
@@ -60,7 +60,7 @@ struct FilePickerTests {
         try Mock(
             url: url.appendingPathComponent("/api/v0/list-directory"),
             statusCode: 200,
-            data: [.post: Client.encoder.encode(mockResponse)]
+            data: [.post: CoderSDK.encoder.encode(mockResponse)]
         ).register()
 
         try await ViewHosting.host(view) {
@@ -88,7 +88,7 @@ struct FilePickerTests {
         try Mock(
             url: url.appendingPathComponent("/api/v0/list-directory"),
             statusCode: 200,
-            data: [.post: Client.encoder.encode(mockResponse)]
+            data: [.post: CoderSDK.encoder.encode(mockResponse)]
         ).register()
 
         try await ViewHosting.host(view) {
diff --git a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
index 26f5883d..24ab1f0f 100644
--- a/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/LoginFormTests.swift
@@ -79,7 +79,7 @@ struct LoginTests {
         try Mock(
             url: url.appendingPathComponent("/api/v2/buildinfo"),
             statusCode: 200,
-            data: [.get: Client.encoder.encode(buildInfo)]
+            data: [.get: CoderSDK.encoder.encode(buildInfo)]
         ).register()
         Mock(url: url.appendingPathComponent("/api/v2/users/me"), statusCode: 401, data: [.get: Data()]).register()
 
@@ -104,13 +104,13 @@ struct LoginTests {
         try Mock(
             url: url.appendingPathComponent("/api/v2/buildinfo"),
             statusCode: 200,
-            data: [.get: Client.encoder.encode(buildInfo)]
+            data: [.get: CoderSDK.encoder.encode(buildInfo)]
         ).register()
 
         try Mock(
             url: url.appendingPathComponent("/api/v2/users/me"),
             statusCode: 200,
-            data: [.get: Client.encoder.encode(User(id: UUID(), username: "username"))]
+            data: [.get: CoderSDK.encoder.encode(User(id: UUID(), username: "username"))]
         ).register()
 
         try await ViewHosting.host(view) {
@@ -140,13 +140,13 @@ struct LoginTests {
         try Mock(
             url: url.appendingPathComponent("/api/v2/users/me"),
             statusCode: 200,
-            data: [.get: Client.encoder.encode(user)]
+            data: [.get: CoderSDK.encoder.encode(user)]
         ).register()
 
         try Mock(
             url: url.appendingPathComponent("/api/v2/buildinfo"),
             statusCode: 200,
-            data: [.get: Client.encoder.encode(buildInfo)]
+            data: [.get: CoderSDK.encoder.encode(buildInfo)]
         ).register()
 
         try await ViewHosting.host(view) {
diff --git a/Coder-Desktop/CoderSDK/AgentClient.swift b/Coder-Desktop/CoderSDK/AgentClient.swift
index ecdd3d43..4debe383 100644
--- a/Coder-Desktop/CoderSDK/AgentClient.swift
+++ b/Coder-Desktop/CoderSDK/AgentClient.swift
@@ -1,7 +1,22 @@
 public final class AgentClient: Sendable {
-    let client: Client
+    let agentURL: URL
 
     public init(agentHost: String) {
-        client = Client(url: URL(string: "http://\(agentHost):4")!)
+        agentURL = URL(string: "http://\(agentHost):4")!
+    }
+
+    func request(
+        _ path: String,
+        method: HTTPMethod
+    ) async throws(SDKError) -> HTTPResponse {
+        try await CoderSDK.request(baseURL: agentURL, path: path, method: method)
+    }
+
+    func request(
+        _ path: String,
+        method: HTTPMethod,
+        body: some Encodable & Sendable
+    ) async throws(SDKError) -> HTTPResponse {
+        try await CoderSDK.request(baseURL: agentURL, path: path, method: method, body: body)
     }
 }
diff --git a/Coder-Desktop/CoderSDK/AgentLS.swift b/Coder-Desktop/CoderSDK/AgentLS.swift
index 7110f405..0d9a2bc3 100644
--- a/Coder-Desktop/CoderSDK/AgentLS.swift
+++ b/Coder-Desktop/CoderSDK/AgentLS.swift
@@ -1,10 +1,10 @@
 public extension AgentClient {
-    func listAgentDirectory(_ req: LSRequest) async throws(ClientError) -> LSResponse {
-        let res = try await client.request("/api/v0/list-directory", method: .post, body: req)
+    func listAgentDirectory(_ req: LSRequest) async throws(SDKError) -> LSResponse {
+        let res = try await request("/api/v0/list-directory", method: .post, body: req)
         guard res.resp.statusCode == 200 else {
-            throw client.responseAsError(res)
+            throw responseAsError(res)
         }
-        return try client.decode(LSResponse.self, from: res.data)
+        return try decode(LSResponse.self, from: res.data)
     }
 }
 
diff --git a/Coder-Desktop/CoderSDK/Client.swift b/Coder-Desktop/CoderSDK/Client.swift
index 98e1c8a9..991cdf60 100644
--- a/Coder-Desktop/CoderSDK/Client.swift
+++ b/Coder-Desktop/CoderSDK/Client.swift
@@ -11,95 +11,38 @@ public struct Client: Sendable {
         self.headers = headers
     }
 
-    static let decoder: JSONDecoder = {
-        var dec = JSONDecoder()
-        dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds
-        return dec
-    }()
-
-    static let encoder: JSONEncoder = {
-        var enc = JSONEncoder()
-        enc.dateEncodingStrategy = .iso8601withFractionalSeconds
-        return enc
-    }()
-
-    private func doRequest(
-        path: String,
-        method: HTTPMethod,
-        body: Data? = nil
-    ) async throws(ClientError) -> HTTPResponse {
-        let url = url.appendingPathComponent(path)
-        var req = URLRequest(url: url)
-        if let token { req.addValue(token, forHTTPHeaderField: Headers.sessionToken) }
-        req.httpMethod = method.rawValue
-        for header in headers {
-            req.addValue(header.value, forHTTPHeaderField: header.name)
-        }
-        req.httpBody = body
-        let data: Data
-        let resp: URLResponse
-        do {
-            (data, resp) = try await URLSession.shared.data(for: req)
-        } catch {
-            throw .network(error)
-        }
-        guard let httpResponse = resp as? HTTPURLResponse else {
-            throw .unexpectedResponse(String(data: data, encoding: .utf8) ?? "<non-utf8 data>")
-        }
-        return HTTPResponse(resp: httpResponse, data: data, req: req)
-    }
-
     func request(
         _ path: String,
         method: HTTPMethod,
         body: some Encodable & Sendable
-    ) async throws(ClientError) -> HTTPResponse {
-        let encodedBody: Data?
-        do {
-            encodedBody = try Client.encoder.encode(body)
-        } catch {
-            throw .encodeFailure(error)
+    ) async throws(SDKError) -> HTTPResponse {
+        var headers = headers
+        if let token {
+            headers += [.init(name: Headers.sessionToken, value: token)]
         }
-        return try await doRequest(path: path, method: method, body: encodedBody)
+        return try await CoderSDK.request(
+            baseURL: url,
+            path: path,
+            method: method,
+            headers: headers,
+            body: body
+        )
     }
 
     func request(
         _ path: String,
         method: HTTPMethod
-    ) async throws(ClientError) -> HTTPResponse {
-        try await doRequest(path: path, method: method)
-    }
-
-    func responseAsError(_ resp: HTTPResponse) -> ClientError {
-        do {
-            let body = try decode(Response.self, from: resp.data)
-            let out = APIError(
-                response: body,
-                statusCode: resp.resp.statusCode,
-                method: resp.req.httpMethod!,
-                url: resp.req.url!
-            )
-            return .api(out)
-        } catch {
-            return .unexpectedResponse(String(data: resp.data, encoding: .utf8) ?? "<non-utf8 data>")
-        }
-    }
-
-    // Wrapper around JSONDecoder.decode that displays useful error messages from `DecodingError`.
-    func decode<T>(_: T.Type, from data: Data) throws(ClientError) -> T where T: Decodable {
-        do {
-            return try Client.decoder.decode(T.self, from: data)
-        } catch let DecodingError.keyNotFound(_, context) {
-            throw .unexpectedResponse("Key not found: \(context.debugDescription)")
-        } catch let DecodingError.valueNotFound(_, context) {
-            throw .unexpectedResponse("Value not found: \(context.debugDescription)")
-        } catch let DecodingError.typeMismatch(_, context) {
-            throw .unexpectedResponse("Type mismatch: \(context.debugDescription)")
-        } catch let DecodingError.dataCorrupted(context) {
-            throw .unexpectedResponse("Data corrupted: \(context.debugDescription)")
-        } catch {
-            throw .unexpectedResponse(String(data: data.prefix(1024), encoding: .utf8) ?? "<non-utf8 data>")
+    ) async throws(SDKError) -> HTTPResponse {
+        var headers = headers
+        if let token {
+            headers += [.init(name: Headers.sessionToken, value: token)]
         }
+        return try await CoderSDK.request(
+            baseURL: url,
+            path: path,
+            method: method,
+            headers: headers
+        )
     }
 }
 
@@ -133,7 +76,7 @@ public struct FieldValidation: Decodable, Sendable {
     let detail: String
 }
 
-public enum ClientError: Error {
+public enum SDKError: Error {
     case api(APIError)
     case network(any Error)
     case unexpectedResponse(String)
@@ -154,3 +97,110 @@ public enum ClientError: Error {
 
     public var localizedDescription: String { description }
 }
+
+let decoder: JSONDecoder = {
+    var dec = JSONDecoder()
+    dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds
+    return dec
+}()
+
+let encoder: JSONEncoder = {
+    var enc = JSONEncoder()
+    enc.dateEncodingStrategy = .iso8601withFractionalSeconds
+    return enc
+}()
+
+func doRequest(
+    baseURL: URL,
+    path: String,
+    method: HTTPMethod,
+    headers: [HTTPHeader] = [],
+    body: Data? = nil
+) async throws(SDKError) -> HTTPResponse {
+    let url = baseURL.appendingPathComponent(path)
+    var req = URLRequest(url: url)
+    req.httpMethod = method.rawValue
+    for header in headers {
+        req.addValue(header.value, forHTTPHeaderField: header.name)
+    }
+    req.httpBody = body
+    let data: Data
+    let resp: URLResponse
+    do {
+        (data, resp) = try await URLSession.shared.data(for: req)
+    } catch {
+        throw .network(error)
+    }
+    guard let httpResponse = resp as? HTTPURLResponse else {
+        throw .unexpectedResponse(String(data: data, encoding: .utf8) ?? "<non-utf8 data>")
+    }
+    return HTTPResponse(resp: httpResponse, data: data, req: req)
+}
+
+func request(
+    baseURL: URL,
+    path: String,
+    method: HTTPMethod,
+    headers: [HTTPHeader] = [],
+    body: some Encodable & Sendable
+) async throws(SDKError) -> HTTPResponse {
+    let encodedBody: Data
+    do {
+        encodedBody = try encoder.encode(body)
+    } catch {
+        throw .encodeFailure(error)
+    }
+    return try await doRequest(
+        baseURL: baseURL,
+        path: path,
+        method: method,
+        headers: headers,
+        body: encodedBody
+    )
+}
+
+func request(
+    baseURL: URL,
+    path: String,
+    method: HTTPMethod,
+    headers: [HTTPHeader] = []
+) async throws(SDKError) -> HTTPResponse {
+    try await doRequest(
+        baseURL: baseURL,
+        path: path,
+        method: method,
+        headers: headers
+    )
+}
+
+func responseAsError(_ resp: HTTPResponse) -> SDKError {
+    do {
+        let body = try decode(Response.self, from: resp.data)
+        let out = APIError(
+            response: body,
+            statusCode: resp.resp.statusCode,
+            method: resp.req.httpMethod!,
+            url: resp.req.url!
+        )
+        return .api(out)
+    } catch {
+        return .unexpectedResponse(String(data: resp.data, encoding: .utf8) ?? "<non-utf8 data>")
+    }
+}
+
+// Wrapper around JSONDecoder.decode that displays useful error messages from `DecodingError`.
+func decode<T: Decodable>(_: T.Type, from data: Data) throws(SDKError) -> T {
+    do {
+        return try decoder.decode(T.self, from: data)
+    } catch let DecodingError.keyNotFound(_, context) {
+        throw .unexpectedResponse("Key not found: \(context.debugDescription)")
+    } catch let DecodingError.valueNotFound(_, context) {
+        throw .unexpectedResponse("Value not found: \(context.debugDescription)")
+    } catch let DecodingError.typeMismatch(_, context) {
+        throw .unexpectedResponse("Type mismatch: \(context.debugDescription)")
+    } catch let DecodingError.dataCorrupted(context) {
+        throw .unexpectedResponse("Data corrupted: \(context.debugDescription)")
+    } catch {
+        throw .unexpectedResponse(String(data: data.prefix(1024), encoding: .utf8) ?? "<non-utf8 data>")
+    }
+}
diff --git a/Coder-Desktop/CoderSDK/Deployment.swift b/Coder-Desktop/CoderSDK/Deployment.swift
index 8357a7eb..b88029f1 100644
--- a/Coder-Desktop/CoderSDK/Deployment.swift
+++ b/Coder-Desktop/CoderSDK/Deployment.swift
@@ -1,7 +1,7 @@
 import Foundation
 
 public extension Client {
-    func buildInfo() async throws(ClientError) -> BuildInfoResponse {
+    func buildInfo() async throws(SDKError) -> BuildInfoResponse {
         let res = try await request("/api/v2/buildinfo", method: .get)
         guard res.resp.statusCode == 200 else {
             throw responseAsError(res)
diff --git a/Coder-Desktop/CoderSDK/User.swift b/Coder-Desktop/CoderSDK/User.swift
index ca1bbf7d..5b1efc42 100644
--- a/Coder-Desktop/CoderSDK/User.swift
+++ b/Coder-Desktop/CoderSDK/User.swift
@@ -1,7 +1,7 @@
 import Foundation
 
 public extension Client {
-    func user(_ ident: String) async throws(ClientError) -> User {
+    func user(_ ident: String) async throws(SDKError) -> User {
         let res = try await request("/api/v2/users/\(ident)", method: .get)
         guard res.resp.statusCode == 200 else {
             throw responseAsError(res)
diff --git a/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift b/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift
index e7675b75..ba4194c5 100644
--- a/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift
+++ b/Coder-Desktop/CoderSDKTests/CoderSDKTests.swift
@@ -19,7 +19,7 @@ struct CoderSDKTests {
             url: url.appending(path: "api/v2/users/johndoe"),
             contentType: .json,
             statusCode: 200,
-            data: [.get: Client.encoder.encode(user)]
+            data: [.get: CoderSDK.encoder.encode(user)]
         )
         var correctHeaders = false
         mock.onRequestHandler = OnRequestHandler { req in
@@ -45,7 +45,7 @@ struct CoderSDKTests {
             url: url.appending(path: "api/v2/buildinfo"),
             contentType: .json,
             statusCode: 200,
-            data: [.get: Client.encoder.encode(buildInfo)]
+            data: [.get: CoderSDK.encoder.encode(buildInfo)]
         ).register()
 
         let retBuildInfo = try await client.buildInfo()

From afd9634596905ab89d81d96e94e32f3c544a85db Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Tue, 15 Apr 2025 11:14:12 +1000
Subject: [PATCH 02/16] 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.
---
 .../Coder-Desktop/Coder_DesktopApp.swift      |  7 ++-
 Coder-Desktop/Coder-Desktop/State.swift       | 51 +++++++++++++++++--
 .../Coder-Desktop/VPN/VPNService.swift        |  6 ++-
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift |  9 ++--
 Coder-Desktop/CoderSDK/Util.swift             | 25 +++++++++
 Coder-Desktop/CoderSDK/WorkspaceAgents.swift  | 15 ++++++
 6 files changed, 105 insertions(+), 8 deletions(-)
 create mode 100644 Coder-Desktop/CoderSDK/Util.swift
 create mode 100644 Coder-Desktop/CoderSDK/WorkspaceAgents.swift

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 30ea7e7e..369c48bc 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -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"
diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift
index aea2fe99..3aa8842b 100644
--- a/Coder-Desktop/Coder-Desktop/State.swift
+++ b/Coder-Desktop/Coder-Desktop/State.swift
@@ -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 {
@@ -33,6 +37,8 @@ class AppState: ObservableObject {
         }
     }
 
+    private var client: Client?
+
     @Published var useLiteralHeaders: Bool = UserDefaults.standard.bool(forKey: Keys.useLiteralHeaders) {
         didSet {
             reconfigure()
@@ -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() {
@@ -107,6 +113,15 @@ 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()
+                await refreshDeploymentConfig()
+            }
         }
     }
 
@@ -114,14 +129,19 @@ class AppState: ObservableObject {
         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 {
@@ -135,9 +155,34 @@ class AppState: ObservableObject {
         }
     }
 
+    private var refreshTask: Task<String?, Never>?
+    public func refreshDeploymentConfig() async {
+        // Client is non-nil if there's a sesssion
+        if hasSession, let client {
+            refreshTask?.cancel()
+
+            refreshTask = Task {
+                let res = try? await retry(floor: .milliseconds(100), ceil: .seconds(10)) {
+                    do {
+                        let config = try await client.agentConnectionInfoGeneric()
+                        return config.hostname_suffix
+                    } catch {
+                        logger.error("failed to get agent connection info (retrying): \(error)")
+                        throw error
+                    }
+                }
+                return res
+            }
+
+            hostnameSuffix = await refreshTask?.value ?? Self.defaultHostnameSuffix
+        }
+    }
+
     public func clearSession() {
         hasSession = false
         sessionToken = nil
+        refreshTask?.cancel()
+        client = nil
         reconfigure()
     }
 
diff --git a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
index 50078d5f..c3c17738 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
@@ -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
@@ -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
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index af7e6bb8..0b231de3 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -42,6 +42,8 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
 }
 
 struct MenuItemView: View {
+    @EnvironmentObject var state: AppState
+
     let item: VPNMenuItem
     let baseAccessURL: URL
     @State private var nameIsSelected: Bool = false
@@ -49,13 +51,14 @@ struct MenuItemView: View {
 
     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
diff --git a/Coder-Desktop/CoderSDK/Util.swift b/Coder-Desktop/CoderSDK/Util.swift
new file mode 100644
index 00000000..4eab2db9
--- /dev/null
+++ b/Coder-Desktop/CoderSDK/Util.swift
@@ -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()
+}
diff --git a/Coder-Desktop/CoderSDK/WorkspaceAgents.swift b/Coder-Desktop/CoderSDK/WorkspaceAgents.swift
new file mode 100644
index 00000000..4144a582
--- /dev/null
+++ b/Coder-Desktop/CoderSDK/WorkspaceAgents.swift
@@ -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?
+}

From 33da515b2fbd2474c05f2a89f8ec2a45346528d6 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Tue, 15 Apr 2025 11:29:19 +1000
Subject: [PATCH 03/16] fix: support old VPN config names in post & pre install
 scripts (#134)

One thing I noticed as part of my work on #121 is that our attempted fix introduced in #92 wasn't working as expected if the user had a VPN configuration installed before #86.

This PR fetches the unique name of the VPN service dynamically, as part of the script, such that the service is started and stopped regardless of whether the service is called "Coder" or the older "CoderVPN".

This also ensures we don't break it again if we ever change that name, such as to "Coder Connect" (I don't totally recall why it was set to "Coder", but I don't mind it)
---
 pkgbuild/scripts/postinstall |  8 ++++----
 pkgbuild/scripts/preinstall  | 12 +++++++-----
 2 files changed, 11 insertions(+), 9 deletions(-)

diff --git a/pkgbuild/scripts/postinstall b/pkgbuild/scripts/postinstall
index 8018af9c..cdab83bd 100755
--- a/pkgbuild/scripts/postinstall
+++ b/pkgbuild/scripts/postinstall
@@ -6,9 +6,9 @@ VPN_MARKER_FILE="/tmp/coder_vpn_was_running"
 # Before this script, or the user, opens the app, make sure
 # Gatekeeper has ingested the notarization ticket.
 spctl -avvv "/Applications/Coder Desktop.app"
-# spctl can't assess non-apps, so this will always return a non-zero exit code, 
-# but the error message implies at minimum the signature of the extension was 
-# checked. 
+# spctl can't assess non-apps, so this will always return a non-zero exit code,
+# but the error message implies at minimum the signature of the extension was
+# checked.
 spctl -avvv "/Applications/Coder Desktop.app/Contents/Library/SystemExtensions/com.coder.Coder-Desktop.VPN.systemextension" || true
 
 # Restart Coder Desktop if it was running before
@@ -24,7 +24,7 @@ if [ -f "$VPN_MARKER_FILE" ]; then
   echo "Restarting CoderVPN..."
   echo "Sleeping for 3..."
   sleep 3
-  scutil --nc start "Coder"
+  scutil --nc start "$(scutil --nc list | grep "com.coder.Coder-Desktop" | awk -F'"' '{print $2}')"
   rm "$VPN_MARKER_FILE"
   echo "CoderVPN started."
 fi
diff --git a/pkgbuild/scripts/preinstall b/pkgbuild/scripts/preinstall
index 83271f3c..f4962e9c 100755
--- a/pkgbuild/scripts/preinstall
+++ b/pkgbuild/scripts/preinstall
@@ -9,20 +9,22 @@ if pgrep 'Coder Desktop'; then
   touch $RUNNING_MARKER_FILE
 fi
 
+vpn_name=$(scutil --nc list | grep "com.coder.Coder-Desktop" | awk -F'"' '{print $2}')
+
 echo "Turning off VPN"
-if scutil --nc list | grep -q "Coder"; then
+if [[ -n "$vpn_name" ]]; then
   echo "CoderVPN found. Stopping..."
-  if scutil --nc status "Coder" | grep -q "^Connected$"; then
+  if scutil --nc status "$vpn_name" | grep -q "^Connected$"; then
     touch $VPN_MARKER_FILE
   fi
-  scutil --nc stop "Coder"
+  scutil --nc stop "$vpn_name"
 
   # Wait for VPN to be disconnected
-  while scutil --nc status "Coder" | grep -q "^Connected$"; do
+  while scutil --nc status "$vpn_name" | grep -q "^Connected$"; do
     echo "Waiting for VPN to disconnect..."
     sleep 1
   done
-  while scutil --nc status "Coder" | grep -q "^Disconnecting$"; do
+  while scutil --nc status "$vpn_name" | grep -q "^Disconnecting$"; do
     echo "Waiting for VPN to complete disconnect..."
     sleep 1
   done

From 681a9a6653662a1161c746156f8b0c2653bd99b3 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Wed, 23 Apr 2025 12:45:07 +1000
Subject: [PATCH 04/16] chore: bump mutagen version (#138)

Adds the fix for https://github.com/coder/internal/issues/566
---
 Coder-Desktop/Resources/.mutagenversion                        | 2 +-
 .../FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto   | 2 +-
 .../VPNLib/FileSync/MutagenSDK/selection_selection.proto       | 2 +-
 .../VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto     | 2 +-
 .../FileSync/MutagenSDK/service_prompting_prompting.proto      | 2 +-
 .../MutagenSDK/service_synchronization_synchronization.proto   | 2 +-
 .../MutagenSDK/synchronization_compression_algorithm.proto     | 2 +-
 .../FileSync/MutagenSDK/synchronization_configuration.proto    | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_change.proto      | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_conflict.proto    | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_entry.proto       | 2 +-
 .../synchronization_core_ignore_ignore_vcs_mode.proto          | 2 +-
 .../MutagenSDK/synchronization_core_ignore_syntax.proto        | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto | 2 +-
 .../MutagenSDK/synchronization_core_permissions_mode.proto     | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_problem.proto     | 2 +-
 .../MutagenSDK/synchronization_core_symbolic_link_mode.proto   | 2 +-
 .../MutagenSDK/synchronization_hashing_algorithm.proto         | 2 +-
 .../FileSync/MutagenSDK/synchronization_rsync_receive.proto    | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_session.proto   | 2 +-
 .../FileSync/MutagenSDK/synchronization_stage_mode.proto       | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_state.proto     | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_version.proto   | 2 +-
 .../FileSync/MutagenSDK/synchronization_watch_mode.proto       | 2 +-
 Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto         | 2 +-
 scripts/mutagen-proto.sh                                       | 3 +--
 27 files changed, 27 insertions(+), 28 deletions(-)

diff --git a/Coder-Desktop/Resources/.mutagenversion b/Coder-Desktop/Resources/.mutagenversion
index f3a5a576..69968c92 100644
--- a/Coder-Desktop/Resources/.mutagenversion
+++ b/Coder-Desktop/Resources/.mutagenversion
@@ -1 +1 @@
-v0.18.1
+v0.18.2
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto
index c2fb72a6..e3efbf01 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/filesystem/behavior/probe_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/filesystem/behavior/probe_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto
index 552a013e..a5419e5e 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/selection/selection.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/selection/selection.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto
index c6604cf9..663b27c2 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/daemon/daemon.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/service/daemon/daemon.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
index 337a1544..65a2dedc 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/prompting/prompting.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/service/prompting/prompting.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto
index cb1ab733..413c713d 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/synchronization/synchronization.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/service/synchronization/synchronization.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto
index ac6745e2..90aa4259 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/compression/algorithm.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/compression/algorithm.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto
index ed613bca..348741be 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/configuration.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/configuration.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto
index 9fc24db8..7c34bcf9 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/change.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/change.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto
index 185f6651..78daa03c 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/conflict.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/conflict.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto
index 88e2cada..26bb6bcb 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/entry.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/entry.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto
index 6714c0c9..131e93ed 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/ignore/ignore_vcs_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/ignore/ignore_vcs_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto
index 93468976..89ddc2e2 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/ignore/syntax.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/ignore/syntax.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto
index 212daf70..d0d931e8 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto
index 98caa326..3d4aab45 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/permissions_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/permissions_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto
index 2ff66107..f598e6f2 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/problem.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/problem.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto
index 02292961..b861baa0 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/symbolic_link_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/symbolic_link_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto
index a4837bc2..2b8ebd0c 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/hashing/algorithm.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/hashing/algorithm.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto
index 43bad22e..7f7e3053 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/rsync/receive.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/rsync/receive.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto
index c95f0e33..9c74fa6a 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/scan_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/scan_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto
index 9f3f1659..c133df91 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/session.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/session.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto
index f049b9a5..6b0a4f12 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/stage_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/stage_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto
index 78c918dc..aacd6520 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/state.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/state.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto
index 9c5c2962..bc352585 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/version.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/version.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto
index 1fedd86f..f4e2b8de 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/watch_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/watch_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto
index 27cc4c00..57139831 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/url/url.proto
+ * https://github.com/coder/mutagen/tree/v0.18.2/pkg/url/url.proto
  *
  * MIT License
  * 
diff --git a/scripts/mutagen-proto.sh b/scripts/mutagen-proto.sh
index fb01413b..287083de 100755
--- a/scripts/mutagen-proto.sh
+++ b/scripts/mutagen-proto.sh
@@ -20,8 +20,7 @@ fi
 
 mutagen_tag="$1"
 
-# TODO: Change this to `coder/mutagen` once we add a version tag there
-repo="mutagen-io/mutagen"
+repo="coder/mutagen"
 proto_prefix="pkg"
 # Right now, we only care about the synchronization and daemon management gRPC
 entry_files=("service/synchronization/synchronization.proto" "service/daemon/daemon.proto" "service/prompting/prompting.proto")

From 5f067b69ccb201ec5c0c48fe09f652ca387b7899 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 1 May 2025 12:18:57 +1000
Subject: [PATCH 05/16] feat: add workspace apps (#136)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes #94.

<img width="311" alt="Screenshot 2025-04-22 at 2 10 32 pm" src="https://github.com/user-attachments/assets/36e20e2e-49b4-4cbd-8bcc-e41840fdc45c" />


https://github.com/user-attachments/assets/0777d1c9-6183-487d-b24a-b2ad9639d75b



The cursor does not change to a pointing hand as it should when screen-recording, and the display name of the app is also shown on hover:
<img width="255" alt="image" src="https://github.com/user-attachments/assets/95c1f06b-b14a-457c-85a6-5a514b017def" />


As per the linked issue, this only shows the first five apps. If there's less than 5 apps, they won't be centered (I think this looks a bit better):

<img width="325" alt="image" src="https://github.com/user-attachments/assets/348c1b46-f8d5-4a32-8ba6-eb03d8125344" />



Later designs will likely include a Workspace window where all the apps can be viewed, and potentially reordered to control what is shown on the tray.

EDIT: Web apps have been filtered out of the above examples, as we don't currently have a way to determine whether they will work properly via Coder Connect.
---
 .../Coder-Desktop/Coder_DesktopApp.swift      |   5 +
 Coder-Desktop/Coder-Desktop/State.swift       |   2 +-
 Coder-Desktop/Coder-Desktop/Theme.swift       |   4 +
 .../Coder-Desktop/Views/ResponsiveLink.swift  |   7 +-
 Coder-Desktop/Coder-Desktop/Views/Util.swift  |  13 +
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 128 ++++++---
 .../Views/VPN/WorkspaceAppIcon.swift          | 209 +++++++++++++++
 .../WorkspaceAppTests.swift                   | 243 ++++++++++++++++++
 Coder-Desktop/CoderSDK/Workspace.swift        |  98 +++++++
 Coder-Desktop/project.yml                     |   8 +
 10 files changed, 680 insertions(+), 37 deletions(-)
 create mode 100644 Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
 create mode 100644 Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift
 create mode 100644 Coder-Desktop/CoderSDK/Workspace.swift

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 369c48bc..4ec412fc 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -1,5 +1,7 @@
 import FluidMenuBarExtra
 import NetworkExtension
+import SDWebImageSVGCoder
+import SDWebImageSwiftUI
 import SwiftUI
 import VPNLib
 
@@ -66,6 +68,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     }
 
     func applicationDidFinishLaunching(_: Notification) {
+        // Init SVG loader
+        SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
+
         menuBar = .init(menuBarExtra: FluidMenuBarExtra(
             title: "Coder Desktop",
             image: "MenuBarIcon",
diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift
index 3aa8842b..2247c469 100644
--- a/Coder-Desktop/Coder-Desktop/State.swift
+++ b/Coder-Desktop/Coder-Desktop/State.swift
@@ -37,7 +37,7 @@ class AppState: ObservableObject {
         }
     }
 
-    private var client: Client?
+    public var client: Client?
 
     @Published var useLiteralHeaders: Bool = UserDefaults.standard.bool(forKey: Keys.useLiteralHeaders) {
         didSet {
diff --git a/Coder-Desktop/Coder-Desktop/Theme.swift b/Coder-Desktop/Coder-Desktop/Theme.swift
index 192cc368..1c15b086 100644
--- a/Coder-Desktop/Coder-Desktop/Theme.swift
+++ b/Coder-Desktop/Coder-Desktop/Theme.swift
@@ -7,6 +7,10 @@ enum Theme {
         static let trayInset: CGFloat = trayMargin + trayPadding
 
         static let rectCornerRadius: CGFloat = 4
+
+        static let appIconWidth: CGFloat = 30
+        static let appIconHeight: CGFloat = 30
+        static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
     }
 
     static let defaultVisibleAgents = 5
diff --git a/Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift b/Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift
index fd37881a..54285620 100644
--- a/Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift
@@ -13,13 +13,8 @@ struct ResponsiveLink: View {
             .font(.subheadline)
             .foregroundColor(isPressed ? .red : .blue)
             .underline(isHovered, color: isPressed ? .red : .blue)
-            .onHover { hovering in
+            .onHoverWithPointingHand { hovering in
                 isHovered = hovering
-                if hovering {
-                    NSCursor.pointingHand.push()
-                } else {
-                    NSCursor.pop()
-                }
             }
             .simultaneousGesture(
                 DragGesture(minimumDistance: 0)
diff --git a/Coder-Desktop/Coder-Desktop/Views/Util.swift b/Coder-Desktop/Coder-Desktop/Views/Util.swift
index 693dc935..69981a25 100644
--- a/Coder-Desktop/Coder-Desktop/Views/Util.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/Util.swift
@@ -31,3 +31,16 @@ extension UUID {
         self.init(uuid: uuid)
     }
 }
+
+public extension View {
+    @inlinable nonisolated func onHoverWithPointingHand(perform action: @escaping (Bool) -> Void) -> some View {
+        onHover { hovering in
+            if hovering {
+                NSCursor.pointingHand.push()
+            } else {
+                NSCursor.pop()
+            }
+            action(hovering)
+        }
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index 0b231de3..700cefa3 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -1,3 +1,5 @@
+import CoderSDK
+import os
 import SwiftUI
 
 // Each row in the workspaces list is an agent or an offline workspace
@@ -26,6 +28,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
         }
     }
 
+    var workspaceID: UUID {
+        switch self {
+        case let .agent(agent): agent.wsID
+        case let .offlineWorkspace(workspace): workspace.id
+        }
+    }
+
     static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
         switch (lhs, rhs) {
         case let (.agent(lhsAgent), .agent(rhsAgent)):
@@ -44,11 +53,17 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
 struct MenuItemView: View {
     @EnvironmentObject var state: AppState
 
+    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMenu")
+
     let item: VPNMenuItem
     let baseAccessURL: URL
+
     @State private var nameIsSelected: Bool = false
     @State private var copyIsSelected: Bool = false
 
+    private let defaultVisibleApps = 5
+    @State private var apps: [WorkspaceApp] = []
+
     private var itemName: AttributedString {
         let name = switch item {
         case let .agent(agent): agent.primaryHost ?? "\(item.wsName).\(state.hostnameSuffix)"
@@ -70,37 +85,90 @@ struct MenuItemView: View {
     }
 
     var body: some View {
-        HStack(spacing: 0) {
-            Link(destination: wsURL) {
-                HStack(spacing: Theme.Size.trayPadding) {
-                    StatusDot(color: item.status.color)
-                    Text(itemName).lineLimit(1).truncationMode(.tail)
+        VStack(spacing: 0) {
+            HStack(spacing: 0) {
+                Link(destination: wsURL) {
+                    HStack(spacing: Theme.Size.trayPadding) {
+                        StatusDot(color: item.status.color)
+                        Text(itemName).lineLimit(1).truncationMode(.tail)
+                        Spacer()
+                    }.padding(.horizontal, Theme.Size.trayPadding)
+                        .frame(minHeight: 22)
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                        .foregroundStyle(nameIsSelected ? .white : .primary)
+                        .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
+                        .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
+                        .onHoverWithPointingHand { hovering in
+                            nameIsSelected = hovering
+                        }
                     Spacer()
-                }.padding(.horizontal, Theme.Size.trayPadding)
-                    .frame(minHeight: 22)
-                    .frame(maxWidth: .infinity, alignment: .leading)
-                    .foregroundStyle(nameIsSelected ? .white : .primary)
-                    .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
-                    .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
-                    .onHover { hovering in nameIsSelected = hovering }
-                Spacer()
-            }.buttonStyle(.plain)
-            if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
-                Button {
-                    NSPasteboard.general.clearContents()
-                    NSPasteboard.general.setString(copyableDNS, forType: .string)
-                } label: {
-                    Image(systemName: "doc.on.doc")
-                        .symbolVariant(.fill)
-                        .padding(3)
-                        .contentShape(Rectangle())
-                }.foregroundStyle(copyIsSelected ? .white : .primary)
-                    .imageScale(.small)
-                    .background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
-                    .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
-                    .onHover { hovering in copyIsSelected = hovering }
-                    .buttonStyle(.plain)
-                    .padding(.trailing, Theme.Size.trayMargin)
+                }.buttonStyle(.plain)
+                if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
+                    Button {
+                        NSPasteboard.general.clearContents()
+                        NSPasteboard.general.setString(copyableDNS, forType: .string)
+                    } label: {
+                        Image(systemName: "doc.on.doc")
+                            .symbolVariant(.fill)
+                            .padding(3)
+                            .contentShape(Rectangle())
+                    }.foregroundStyle(copyIsSelected ? .white : .primary)
+                        .imageScale(.small)
+                        .background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
+                        .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
+                        .onHoverWithPointingHand { hovering in copyIsSelected = hovering }
+                        .buttonStyle(.plain)
+                        .padding(.trailing, Theme.Size.trayMargin)
+                }
+            }
+            if !apps.isEmpty {
+                HStack(spacing: 17) {
+                    ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
+                        WorkspaceAppIcon(app: app)
+                            .frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
+                    }
+                    if apps.count < defaultVisibleApps {
+                        Spacer()
+                    }
+                }
+                .padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
+                .padding(.bottom, 5)
+                .padding(.top, 10)
+            }
+        }
+        .task { await loadApps() }
+    }
+
+    func loadApps() async {
+        // If this menu item is an agent, and the user is logged in
+        if case let .agent(agent) = item,
+           let client = state.client,
+           let host = agent.primaryHost,
+           let baseAccessURL = state.baseAccessURL,
+           // Like the CLI, we'll re-use the existing session token to populate the URL
+           let sessionToken = state.sessionToken
+        {
+            let workspace: CoderSDK.Workspace
+            do {
+                workspace = try await retry(floor: .milliseconds(100), ceil: .seconds(10)) {
+                    do {
+                        return try await client.workspace(item.workspaceID)
+                    } catch {
+                        logger.error("Failed to load apps for workspace \(item.wsName): \(error.localizedDescription)")
+                        throw error
+                    }
+                }
+            } catch { return } // Task cancelled
+
+            if let wsAgent = workspace
+                .latest_build.resources
+                .compactMap(\.agents)
+                .flatMap(\.self)
+                .first(where: { $0.id == agent.id })
+            {
+                apps = agentToApps(logger, wsAgent, host, baseAccessURL, sessionToken)
+            } else {
+                logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources")
             }
         }
     }
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
new file mode 100644
index 00000000..70a20d8b
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
@@ -0,0 +1,209 @@
+import CoderSDK
+import os
+import SDWebImageSwiftUI
+import SwiftUI
+
+struct WorkspaceAppIcon: View {
+    let app: WorkspaceApp
+    @Environment(\.openURL) private var openURL
+
+    @State var isHovering: Bool = false
+    @State var isPressed = false
+
+    var body: some View {
+        Group {
+            Group {
+                WebImage(
+                    url: app.icon,
+                    context: [.imageThumbnailPixelSize: Theme.Size.appIconSize]
+                ) { $0 }
+                    placeholder: {
+                        if app.icon != nil {
+                            ProgressView()
+                        } else {
+                            Text(app.displayName).frame(
+                                width: Theme.Size.appIconWidth,
+                                height: Theme.Size.appIconHeight
+                            )
+                        }
+                    }.frame(
+                        width: Theme.Size.appIconWidth,
+                        height: Theme.Size.appIconHeight
+                    )
+            }.padding(4)
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 8))
+        .overlay(
+            RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
+                .stroke(.secondary, lineWidth: 1)
+                .opacity(isHovering && !isPressed ? 0.6 : 0.3)
+        ).onHoverWithPointingHand { hovering in isHovering = hovering }
+        .simultaneousGesture(
+            DragGesture(minimumDistance: 0)
+                .onChanged { _ in
+                    withAnimation(.easeInOut(duration: 0.1)) {
+                        isPressed = true
+                    }
+                }
+                .onEnded { _ in
+                    withAnimation(.easeInOut(duration: 0.1)) {
+                        isPressed = false
+                    }
+                    openURL(app.url)
+                }
+        ).help(app.displayName)
+    }
+}
+
+struct WorkspaceApp {
+    let slug: String
+    let displayName: String
+    let url: URL
+    let icon: URL?
+
+    var id: String { slug }
+
+    private static let magicTokenString = "$SESSION_TOKEN"
+
+    init(slug: String, displayName: String, url: URL, icon: URL?) {
+        self.slug = slug
+        self.displayName = displayName
+        self.url = url
+        self.icon = icon
+    }
+
+    init(
+        _ original: CoderSDK.WorkspaceApp,
+        iconBaseURL: URL,
+        sessionToken: String
+    ) throws(WorkspaceAppError) {
+        slug = original.slug
+        displayName = original.display_name
+
+        guard original.external else {
+            throw .isWebApp
+        }
+
+        guard let originalUrl = original.url else {
+            throw .missingURL
+        }
+
+        if let command = original.command, !command.isEmpty {
+            throw .isCommandApp
+        }
+
+        // We don't want to show buttons for any websites, like internal wikis
+        // or portals. Those *should* have 'external' set, but if they don't:
+        guard originalUrl.scheme != "https", originalUrl.scheme != "http" else {
+            throw .isWebApp
+        }
+
+        let newUrlString = originalUrl.absoluteString.replacingOccurrences(
+            of: Self.magicTokenString,
+            with: sessionToken
+        )
+        guard let newUrl = URL(string: newUrlString) else {
+            throw .invalidURL
+        }
+        url = newUrl
+
+        var icon = original.icon
+        if let originalIcon = original.icon,
+           var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false)
+        {
+            if components.host == nil {
+                components.port = iconBaseURL.port
+                components.scheme = iconBaseURL.scheme
+                components.host = iconBaseURL.host(percentEncoded: false)
+            }
+
+            if let newIconURL = components.url {
+                icon = newIconURL
+            }
+        }
+        self.icon = icon
+    }
+}
+
+enum WorkspaceAppError: Error {
+    case invalidURL
+    case missingURL
+    case isCommandApp
+    case isWebApp
+
+    var description: String {
+        switch self {
+        case .invalidURL:
+            "Invalid URL"
+        case .missingURL:
+            "Missing URL"
+        case .isCommandApp:
+            "is a Command App"
+        case .isWebApp:
+            "is an External App"
+        }
+    }
+
+    var localizedDescription: String { description }
+}
+
+func agentToApps(
+    _ logger: Logger,
+    _ agent: CoderSDK.WorkspaceAgent,
+    _ host: String,
+    _ baseAccessURL: URL,
+    _ sessionToken: String
+) -> [WorkspaceApp] {
+    let workspaceApps = agent.apps.compactMap { app in
+        do throws(WorkspaceAppError) {
+            return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken)
+        } catch {
+            logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)")
+            return nil
+        }
+    }
+
+    let displayApps = agent.display_apps.compactMap { displayApp in
+        switch displayApp {
+        case .vscode:
+            return vscodeDisplayApp(
+                hostname: host,
+                baseIconURL: baseAccessURL,
+                path: agent.expanded_directory
+            )
+        case .vscode_insiders:
+            return vscodeInsidersDisplayApp(
+                hostname: host,
+                baseIconURL: baseAccessURL,
+                path: agent.expanded_directory
+            )
+        default:
+            logger.info("Skipping DisplayApp '\(displayApp.rawValue)' for \(host)")
+            return nil
+        }
+    }
+
+    return displayApps + workspaceApps
+}
+
+func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
+    let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
+    return WorkspaceApp(
+        // Leading hyphen as to not conflict with a real app slug, since we only use
+        // slugs as SwiftUI IDs
+        slug: "-vscode",
+        displayName: "VS Code Desktop",
+        url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!,
+        icon: icon
+    )
+}
+
+func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
+    let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
+    return WorkspaceApp(
+        slug: "-vscode-insiders",
+        displayName: "VS Code Insiders Desktop",
+        url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!,
+        icon: icon
+    )
+}
diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift
new file mode 100644
index 00000000..816c5e04
--- /dev/null
+++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift
@@ -0,0 +1,243 @@
+@testable import Coder_Desktop
+import CoderSDK
+import os
+import Testing
+
+@MainActor
+@Suite
+struct WorkspaceAppTests {
+    let logger = Logger(subsystem: "com.coder.Coder-Desktop-Tests", category: "WorkspaceAppTests")
+    let baseAccessURL = URL(string: "https://coder.example.com")!
+    let sessionToken = "test-session-token"
+    let host = "test-workspace.coder.test"
+
+    @Test
+    func testCreateWorkspaceApp_Success() throws {
+        let sdkApp = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "vscode://myworkspace.coder/foo")!,
+            external: true,
+            slug: "test-app",
+            display_name: "Test App",
+            command: nil,
+            icon: URL(string: "/icon/test-app.svg")!,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        let workspaceApp = try WorkspaceApp(
+            sdkApp,
+            iconBaseURL: baseAccessURL,
+            sessionToken: sessionToken
+        )
+
+        #expect(workspaceApp.slug == "test-app")
+        #expect(workspaceApp.displayName == "Test App")
+        #expect(workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo")
+        #expect(workspaceApp.icon?.absoluteString == "https://coder.example.com/icon/test-app.svg")
+    }
+
+    @Test
+    func testCreateWorkspaceApp_SessionTokenReplacement() throws {
+        let sdkApp = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "vscode://myworkspace.coder/foo?token=$SESSION_TOKEN")!,
+            external: true,
+            slug: "token-app",
+            display_name: "Token App",
+            command: nil,
+            icon: URL(string: "/icon/test-app.svg")!,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        let workspaceApp = try WorkspaceApp(
+            sdkApp,
+            iconBaseURL: baseAccessURL,
+            sessionToken: sessionToken
+        )
+
+        #expect(
+            workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo?token=test-session-token"
+        )
+    }
+
+    @Test
+    func testCreateWorkspaceApp_MissingURL() throws {
+        let sdkApp = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: nil,
+            external: true,
+            slug: "no-url-app",
+            display_name: "No URL App",
+            command: nil,
+            icon: nil,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        #expect(throws: WorkspaceAppError.missingURL) {
+            try WorkspaceApp(
+                sdkApp,
+                iconBaseURL: baseAccessURL,
+                sessionToken: sessionToken
+            )
+        }
+    }
+
+    @Test
+    func testCreateWorkspaceApp_CommandApp() throws {
+        let sdkApp = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "vscode://myworkspace.coder/foo")!,
+            external: true,
+            slug: "command-app",
+            display_name: "Command App",
+            command: "echo 'hello'",
+            icon: nil,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        #expect(throws: WorkspaceAppError.isCommandApp) {
+            try WorkspaceApp(
+                sdkApp,
+                iconBaseURL: baseAccessURL,
+                sessionToken: sessionToken
+            )
+        }
+    }
+
+    @Test
+    func testDisplayApps_VSCode() throws {
+        let agent = createMockAgent(displayApps: [.vscode, .web_terminal, .ssh_helper, .port_forwarding_helper])
+
+        let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
+
+        #expect(apps.count == 1)
+        #expect(apps[0].slug == "-vscode")
+        #expect(apps[0].displayName == "VS Code Desktop")
+        #expect(apps[0].url.absoluteString == "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/user")
+        #expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg")
+    }
+
+    @Test
+    func testDisplayApps_VSCodeInsiders() throws {
+        let agent = createMockAgent(
+            displayApps: [
+                .vscode_insiders,
+                .web_terminal,
+                .ssh_helper,
+                .port_forwarding_helper,
+            ]
+        )
+
+        let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
+
+        #expect(apps.count == 1)
+        #expect(apps[0].slug == "-vscode-insiders")
+        #expect(apps[0].displayName == "VS Code Insiders Desktop")
+        #expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg")
+        #expect(
+            apps[0].url.absoluteString == """
+            vscode-insiders://vscode-remote/ssh-remote+test-workspace.coder.test//home/user
+            """
+        )
+    }
+
+    @Test
+    func testCreateWorkspaceApp_WebAppFilter() throws {
+        let sdkApp = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "https://myworkspace.coder/foo")!,
+            external: false,
+            slug: "web-app",
+            display_name: "Web App",
+            command: nil,
+            icon: URL(string: "/icon/web-app.svg")!,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        #expect(throws: WorkspaceAppError.isWebApp) {
+            try WorkspaceApp(
+                sdkApp,
+                iconBaseURL: baseAccessURL,
+                sessionToken: sessionToken
+            )
+        }
+    }
+
+    @Test
+    func testAgentToApps_MultipleApps() throws {
+        let sdkApp1 = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "vscode://myworkspace.coder/foo1")!,
+            external: true,
+            slug: "app1",
+            display_name: "App 1",
+            command: nil,
+            icon: URL(string: "/icon/foo1.svg")!,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        let sdkApp2 = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "jetbrains://myworkspace.coder/foo2")!,
+            external: true,
+            slug: "app2",
+            display_name: "App 2",
+            command: nil,
+            icon: URL(string: "/icon/foo2.svg")!,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        // Command app; skipped
+        let sdkApp3 = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "vscode://myworkspace.coder/foo3")!,
+            external: true,
+            slug: "app3",
+            display_name: "App 3",
+            command: "echo 'skip me'",
+            icon: nil,
+            subdomain: false,
+            subdomain_name: nil
+        )
+
+        // Web app skipped
+        let sdkApp4 = CoderSDK.WorkspaceApp(
+            id: UUID(),
+            url: URL(string: "https://myworkspace.coder/foo4")!,
+            external: true,
+            slug: "app4",
+            display_name: "App 4",
+            command: nil,
+            icon: URL(string: "/icon/foo4.svg")!,
+            subdomain: false, subdomain_name: nil
+        )
+
+        let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3, sdkApp4], displayApps: [.vscode])
+        let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
+
+        #expect(apps.count == 3)
+        let appSlugs = apps.map(\.slug)
+        #expect(appSlugs.contains("app1"))
+        #expect(appSlugs.contains("app2"))
+        #expect(appSlugs.contains("-vscode"))
+    }
+
+    private func createMockAgent(
+        apps: [CoderSDK.WorkspaceApp] = [],
+        displayApps: [DisplayApp] = []
+    ) -> CoderSDK.WorkspaceAgent {
+        CoderSDK.WorkspaceAgent(
+            id: UUID(),
+            expanded_directory: "/home/user",
+            apps: apps,
+            display_apps: displayApps
+        )
+    }
+}
diff --git a/Coder-Desktop/CoderSDK/Workspace.swift b/Coder-Desktop/CoderSDK/Workspace.swift
new file mode 100644
index 00000000..e8f95df3
--- /dev/null
+++ b/Coder-Desktop/CoderSDK/Workspace.swift
@@ -0,0 +1,98 @@
+public extension Client {
+    func workspace(_ id: UUID) async throws(SDKError) -> Workspace {
+        let res = try await request("/api/v2/workspaces/\(id.uuidString)", method: .get)
+        guard res.resp.statusCode == 200 else {
+            throw responseAsError(res)
+        }
+        return try decode(Workspace.self, from: res.data)
+    }
+}
+
+public struct Workspace: Codable, Identifiable, Sendable {
+    public let id: UUID
+    public let name: String
+    public let latest_build: WorkspaceBuild
+
+    public init(id: UUID, name: String, latest_build: WorkspaceBuild) {
+        self.id = id
+        self.name = name
+        self.latest_build = latest_build
+    }
+}
+
+public struct WorkspaceBuild: Codable, Identifiable, Sendable {
+    public let id: UUID
+    public let resources: [WorkspaceResource]
+
+    public init(id: UUID, resources: [WorkspaceResource]) {
+        self.id = id
+        self.resources = resources
+    }
+}
+
+public struct WorkspaceResource: Codable, Identifiable, Sendable {
+    public let id: UUID
+    public let agents: [WorkspaceAgent]? // `omitempty`
+
+    public init(id: UUID, agents: [WorkspaceAgent]?) {
+        self.id = id
+        self.agents = agents
+    }
+}
+
+public struct WorkspaceAgent: Codable, Identifiable, Sendable {
+    public let id: UUID
+    public let expanded_directory: String? // `omitempty`
+    public let apps: [WorkspaceApp]
+    public let display_apps: [DisplayApp]
+
+    public init(id: UUID, expanded_directory: String?, apps: [WorkspaceApp], display_apps: [DisplayApp]) {
+        self.id = id
+        self.expanded_directory = expanded_directory
+        self.apps = apps
+        self.display_apps = display_apps
+    }
+}
+
+public struct WorkspaceApp: Codable, Identifiable, Sendable {
+    public let id: UUID
+    // Not `omitempty`, but `coderd` sends empty string if `command` is set
+    public var url: URL?
+    public let external: Bool
+    public let slug: String
+    public let display_name: String
+    public let command: String? // `omitempty`
+    public let icon: URL? // `omitempty`
+    public let subdomain: Bool
+    public let subdomain_name: String? // `omitempty`
+
+    public init(
+        id: UUID,
+        url: URL?,
+        external: Bool,
+        slug: String,
+        display_name: String,
+        command: String?,
+        icon: URL?,
+        subdomain: Bool,
+        subdomain_name: String?
+    ) {
+        self.id = id
+        self.url = url
+        self.external = external
+        self.slug = slug
+        self.display_name = display_name
+        self.command = command
+        self.icon = icon
+        self.subdomain = subdomain
+        self.subdomain_name = subdomain_name
+    }
+}
+
+public enum DisplayApp: String, Codable, Sendable {
+    case vscode
+    case vscode_insiders
+    case web_terminal
+    case port_forwarding_helper
+    case ssh_helper
+}
diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml
index d2567673..f557304a 100644
--- a/Coder-Desktop/project.yml
+++ b/Coder-Desktop/project.yml
@@ -120,6 +120,12 @@ packages:
   Semaphore:
     url: https://github.com/groue/Semaphore/
     exactVersion: 0.1.0
+  SDWebImageSwiftUI:
+    url: https://github.com/SDWebImage/SDWebImageSwiftUI
+    exactVersion: 3.1.3
+  SDWebImageSVGCoder:
+    url: https://github.com/SDWebImage/SDWebImageSVGCoder
+    exactVersion: 1.7.0
 
 targets:
   Coder Desktop:
@@ -177,6 +183,8 @@ targets:
       - package: FluidMenuBarExtra
       - package: KeychainAccess
       - package: LaunchAtLogin
+      - package: SDWebImageSwiftUI
+      - package: SDWebImageSVGCoder
     scheme:
       testPlans:
         - path: Coder-Desktop.xctestplan

From 62107753616bc9be7957ec14f9f0cc3193cc7ff0 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 1 May 2025 12:20:51 +1000
Subject: [PATCH 06/16] feat: add progress messages when creating sync sessions
 (#139)

This loading might take a minute on a poor connection, and there's currently no feedback indicating what's going on, so we can display the prompt messages in the meantime.

i.e. setting up a workspace with a fair bit of latency:

https://github.com/user-attachments/assets/4321fbf7-8be6-4d4b-aead-0581c609d668



This PR also contains a small refactor for the `Agent` `primaryHost`, removing all the subsequent nil checks as we know it exists on creation.
---
 .../Preview Content/PreviewFileSync.swift     |  7 ++++++-
 .../Preview Content/PreviewVPN.swift          | 20 +++++++++----------
 .../Coder-Desktop/VPN/MenuState.swift         | 17 ++++++++--------
 .../Views/FileSync/FileSyncSessionModal.swift | 14 +++++++++++--
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift |  9 ++++-----
 .../Coder-DesktopTests/AgentsTests.swift      |  3 ++-
 .../FileSyncDaemonTests.swift                 | 10 +++++++++-
 Coder-Desktop/Coder-DesktopTests/Util.swift   |  7 ++++++-
 .../VPNLib/FileSync/FileSyncDaemon.swift      |  5 ++++-
 .../VPNLib/FileSync/FileSyncManagement.swift  |  7 +++++--
 .../VPNLib/FileSync/FileSyncPrompting.swift   |  7 ++++++-
 11 files changed, 73 insertions(+), 33 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift
index 1253e427..fa644751 100644
--- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift	
+++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift	
@@ -20,7 +20,12 @@ final class PreviewFileSync: FileSyncDaemon {
         state = .stopped
     }
 
-    func createSession(arg _: CreateSyncSessionRequest) async throws(DaemonError) {}
+    func createSession(
+        arg _: CreateSyncSessionRequest,
+        promptCallback _: (
+            @MainActor (String) -> Void
+        )?
+    ) async throws(DaemonError) {}
 
     func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
 
diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
index a3ef51e5..2c6e8d02 100644
--- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift	
+++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift	
@@ -6,25 +6,25 @@ final class PreviewVPN: Coder_Desktop.VPNService {
     @Published var state: Coder_Desktop.VPNServiceState = .connected
     @Published var menuState: VPNMenuState = .init(agents: [
         UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
-                      wsName: "testing-a-very-long-name", wsID: UUID()),
+                      wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
-                      wsName: "testing-a-very-long-name", wsID: UUID()),
+                      wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
         UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
-                      wsID: UUID()),
+                      wsID: UUID(), primaryHost: "asdf.coder"),
     ], workspaces: [:])
     let shouldFail: Bool
     let longError = "This is a long error to test the UI with long error messages"
diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
index 9c15aca3..59dfae08 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
@@ -18,8 +18,7 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
         return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
     }
 
-    // Hosts arrive sorted by length, the shortest looks best in the UI.
-    var primaryHost: String? { hosts.first }
+    let primaryHost: String
 }
 
 enum AgentStatus: Int, Equatable, Comparable {
@@ -69,6 +68,9 @@ struct VPNMenuState {
             invalidAgents.append(agent)
             return
         }
+        // Remove trailing dot if present
+        let nonEmptyHosts = agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 }
+
         // An existing agent with the same name, belonging to the same workspace
         // is from a previous workspace build, and should be removed.
         agents.filter { $0.value.name == agent.name && $0.value.wsID == wsID }
@@ -81,10 +83,11 @@ struct VPNMenuState {
             name: agent.name,
             // If last handshake was not within last five minutes, the agent is unhealthy
             status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
-            // Remove trailing dot if present
-            hosts: agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 },
+            hosts: nonEmptyHosts,
             wsName: workspace.name,
-            wsID: wsID
+            wsID: wsID,
+            // Hosts arrive sorted by length, the shortest looks best in the UI.
+            primaryHost: nonEmptyHosts.first!
         )
     }
 
@@ -135,9 +138,7 @@ struct VPNMenuState {
         return items.sorted()
     }
 
-    var onlineAgents: [Agent] {
-        agents.map(\.value).filter { $0.primaryHost != nil }
-    }
+    var onlineAgents: [Agent] { agents.map(\.value) }
 
     mutating func clear() {
         agents.removeAll()
diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
index 66b20baf..3e48ffd4 100644
--- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift
@@ -15,6 +15,8 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
     @State private var createError: DaemonError?
     @State private var pickingRemote: Bool = false
 
+    @State private var lastPromptMessage: String?
+
     var body: some View {
         let agents = vpn.menuState.onlineAgents
         VStack(spacing: 0) {
@@ -40,7 +42,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
                 Section {
                     Picker("Workspace", selection: $remoteHostname) {
                         ForEach(agents, id: \.id) { agent in
-                            Text(agent.primaryHost!).tag(agent.primaryHost!)
+                            Text(agent.primaryHost).tag(agent.primaryHost)
                         }
                         // HACK: Silence error logs for no-selection.
                         Divider().tag(nil as String?)
@@ -62,6 +64,12 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
             Divider()
             HStack {
                 Spacer()
+                if let msg = lastPromptMessage {
+                    Text(msg).foregroundStyle(.secondary)
+                }
+                if loading {
+                    ProgressView().controlSize(.small)
+                }
                 Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
                 Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }}
                     .keyboardShortcut(.defaultAction)
@@ -103,8 +111,10 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
                 arg: .init(
                     alpha: .init(path: localPath, protocolKind: .local),
                     beta: .init(path: remotePath, protocolKind: .ssh(host: remoteHostname))
-                )
+                ),
+                promptCallback: { lastPromptMessage = $0 }
             )
+            lastPromptMessage = nil
         } catch {
             createError = error
             return
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index 700cefa3..1bc0b98b 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -66,7 +66,7 @@ struct MenuItemView: View {
 
     private var itemName: AttributedString {
         let name = switch item {
-        case let .agent(agent): agent.primaryHost ?? "\(item.wsName).\(state.hostnameSuffix)"
+        case let .agent(agent): agent.primaryHost
         case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
         }
 
@@ -103,10 +103,10 @@ struct MenuItemView: View {
                         }
                     Spacer()
                 }.buttonStyle(.plain)
-                if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
+                if case let .agent(agent) = item {
                     Button {
                         NSPasteboard.general.clearContents()
-                        NSPasteboard.general.setString(copyableDNS, forType: .string)
+                        NSPasteboard.general.setString(agent.primaryHost, forType: .string)
                     } label: {
                         Image(systemName: "doc.on.doc")
                             .symbolVariant(.fill)
@@ -143,7 +143,6 @@ struct MenuItemView: View {
         // If this menu item is an agent, and the user is logged in
         if case let .agent(agent) = item,
            let client = state.client,
-           let host = agent.primaryHost,
            let baseAccessURL = state.baseAccessURL,
            // Like the CLI, we'll re-use the existing session token to populate the URL
            let sessionToken = state.sessionToken
@@ -166,7 +165,7 @@ struct MenuItemView: View {
                 .flatMap(\.self)
                 .first(where: { $0.id == agent.id })
             {
-                apps = agentToApps(logger, wsAgent, host, baseAccessURL, sessionToken)
+                apps = agentToApps(logger, wsAgent, agent.primaryHost, baseAccessURL, sessionToken)
             } else {
                 logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources")
             }
diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
index ac98bd3c..62c1607f 100644
--- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
@@ -27,7 +27,8 @@ struct AgentsTests {
                 status: status,
                 hosts: ["a\($0).coder"],
                 wsName: "ws\($0)",
-                wsID: UUID()
+                wsID: UUID(),
+                primaryHost: "a\($0).coder"
             )
             return (agent.id, agent)
         })
diff --git a/Coder-Desktop/Coder-DesktopTests/FileSyncDaemonTests.swift b/Coder-Desktop/Coder-DesktopTests/FileSyncDaemonTests.swift
index 916faf64..85c0bcfa 100644
--- a/Coder-Desktop/Coder-DesktopTests/FileSyncDaemonTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/FileSyncDaemonTests.swift
@@ -61,6 +61,7 @@ class FileSyncDaemonTests {
         #expect(statesEqual(daemon.state, .stopped))
         #expect(daemon.sessionState.count == 0)
 
+        var promptMessages: [String] = []
         try await daemon.createSession(
             arg: .init(
                 alpha: .init(
@@ -71,9 +72,16 @@ class FileSyncDaemonTests {
                     path: mutagenBetaDirectory.path(),
                     protocolKind: .local
                 )
-            )
+            ),
+            promptCallback: {
+                promptMessages.append($0)
+            }
         )
 
+        // There should be at least one prompt message
+        // Usually "Creating session..."
+        #expect(promptMessages.count > 0)
+
         // Daemon should have started itself
         #expect(statesEqual(daemon.state, .running))
         #expect(daemon.sessionState.count == 1)
diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift
index c5239a92..6c7bc206 100644
--- a/Coder-Desktop/Coder-DesktopTests/Util.swift
+++ b/Coder-Desktop/Coder-DesktopTests/Util.swift
@@ -31,6 +31,8 @@ class MockVPNService: VPNService, ObservableObject {
 class MockFileSyncDaemon: FileSyncDaemon {
     var logFile: URL = .init(filePath: "~/log.txt")
 
+    var lastPromptMessage: String?
+
     var sessionState: [VPNLib.FileSyncSession] = []
 
     func refreshSessions() async {}
@@ -47,7 +49,10 @@ class MockFileSyncDaemon: FileSyncDaemon {
         []
     }
 
-    func createSession(arg _: CreateSyncSessionRequest) async throws(DaemonError) {}
+    func createSession(
+        arg _: CreateSyncSessionRequest,
+        promptCallback _: (@MainActor (String) -> Void)?
+    ) async throws(DaemonError) {}
 
     func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
 
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 7f300fbe..f8f1dc71 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -14,7 +14,10 @@ public protocol FileSyncDaemon: ObservableObject {
     func tryStart() async
     func stop() async
     func refreshSessions() async
-    func createSession(arg: CreateSyncSessionRequest) async throws(DaemonError)
+    func createSession(
+        arg: CreateSyncSessionRequest,
+        promptCallback: (@MainActor (String) -> Void)?
+    ) async throws(DaemonError)
     func deleteSessions(ids: [String]) async throws(DaemonError)
     func pauseSessions(ids: [String]) async throws(DaemonError)
     func resumeSessions(ids: [String]) async throws(DaemonError)
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
index aaf86b18..80fa76ff 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
@@ -17,7 +17,10 @@ public extension MutagenDaemon {
         sessionState = sessions.sessionStates.map { FileSyncSession(state: $0) }
     }
 
-    func createSession(arg: CreateSyncSessionRequest) async throws(DaemonError) {
+    func createSession(
+        arg: CreateSyncSessionRequest,
+        promptCallback: (@MainActor (String) -> Void)? = nil
+    ) async throws(DaemonError) {
         if case .stopped = state {
             do throws(DaemonError) {
                 try await start()
@@ -26,7 +29,7 @@ public extension MutagenDaemon {
                 throw error
             }
         }
-        let (stream, promptID) = try await host()
+        let (stream, promptID) = try await host(promptCallback: promptCallback)
         defer { stream.cancel() }
         let req = Synchronization_CreateRequest.with { req in
             req.prompter = promptID
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift
index d5a49b42..7b8307a2 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncPrompting.swift
@@ -3,7 +3,10 @@ import GRPC
 extension MutagenDaemon {
     typealias PromptStream = GRPCAsyncBidirectionalStreamingCall<Prompting_HostRequest, Prompting_HostResponse>
 
-    func host(allowPrompts: Bool = true) async throws(DaemonError) -> (PromptStream, identifier: String) {
+    func host(
+        allowPrompts: Bool = true,
+        promptCallback: (@MainActor (String) -> Void)? = nil
+    ) async throws(DaemonError) -> (PromptStream, identifier: String) {
         let stream = client!.prompt.makeHostCall()
 
         do {
@@ -39,6 +42,8 @@ extension MutagenDaemon {
                         }
                         // Any other messages that require a non-empty response will
                         // cause the create op to fail, showing an error. This is ok for now.
+                    } else {
+                        Task { @MainActor in promptCallback?(msg.message) }
                     }
                     try await stream.requestStream.send(reply)
                 }

From 25ad797af93231152a10cea8715366bf28a1892f Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 1 May 2025 12:22:31 +1000
Subject: [PATCH 07/16] feat: make workspace apps collapsible (#143)

https://github.com/user-attachments/assets/3503bc17-1cfe-4747-97b4-0883e2763e74
---
 Coder-Desktop/Coder-Desktop/Theme.swift       |   4 +
 .../Coder-Desktop/Views/VPN/Agents.swift      |  22 ++-
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 166 +++++++++++++-----
 .../Views/VPN/WorkspaceAppIcon.swift          |   2 +-
 .../Coder-DesktopTests/AgentsTests.swift      |   4 +-
 5 files changed, 153 insertions(+), 45 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Theme.swift b/Coder-Desktop/Coder-Desktop/Theme.swift
index 1c15b086..546242c2 100644
--- a/Coder-Desktop/Coder-Desktop/Theme.swift
+++ b/Coder-Desktop/Coder-Desktop/Theme.swift
@@ -13,5 +13,9 @@ enum Theme {
         static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
     }
 
+    enum Animation {
+        static let collapsibleDuration = 0.2
+    }
+
     static let defaultVisibleAgents = 5
 }
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
index 0ca65759..fb3928f6 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/Agents.swift
@@ -4,6 +4,8 @@ struct Agents<VPN: VPNService>: View {
     @EnvironmentObject var vpn: VPN
     @EnvironmentObject var state: AppState
     @State private var viewAll = false
+    @State private var expandedItem: VPNMenuItem.ID?
+    @State private var hasToggledExpansion: Bool = false
     private let defaultVisibleRows = 5
 
     let inspection = Inspection<Self>()
@@ -15,8 +17,24 @@ struct Agents<VPN: VPNService>: View {
                 let items = vpn.menuState.sorted
                 let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
                 ForEach(visibleItems, id: \.id) { agent in
-                    MenuItemView(item: agent, baseAccessURL: state.baseAccessURL!)
-                        .padding(.horizontal, Theme.Size.trayMargin)
+                    MenuItemView(
+                        item: agent,
+                        baseAccessURL: state.baseAccessURL!,
+                        expandedItem: $expandedItem,
+                        userInteracted: $hasToggledExpansion
+                    )
+                    .padding(.horizontal, Theme.Size.trayMargin)
+                }.onChange(of: visibleItems) {
+                    // If no workspaces are online, we should expand the first one to come online
+                    if visibleItems.filter({ $0.status != .off }).isEmpty {
+                        hasToggledExpansion = false
+                        return
+                    }
+                    if hasToggledExpansion {
+                        return
+                    }
+                    expandedItem = visibleItems.first?.id
+                    hasToggledExpansion = true
                 }
                 if items.count == 0 {
                     Text("No workspaces!")
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index 1bc0b98b..d67e34ff 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -35,6 +35,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
         }
     }
 
+    func primaryHost(hostnameSuffix: String) -> String {
+        switch self {
+        case let .agent(agent): agent.primaryHost
+        case .offlineWorkspace: "\(wsName).\(hostnameSuffix)"
+        }
+    }
+
     static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
         switch (lhs, rhs) {
         case let (.agent(lhsAgent), .agent(rhsAgent)):
@@ -52,23 +59,23 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
 
 struct MenuItemView: View {
     @EnvironmentObject var state: AppState
+    @Environment(\.openURL) private var openURL
 
     private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMenu")
 
     let item: VPNMenuItem
     let baseAccessURL: URL
+    @Binding var expandedItem: VPNMenuItem.ID?
+    @Binding var userInteracted: Bool
 
     @State private var nameIsSelected: Bool = false
-    @State private var copyIsSelected: Bool = false
 
-    private let defaultVisibleApps = 5
     @State private var apps: [WorkspaceApp] = []
 
+    var hasApps: Bool { !apps.isEmpty }
+
     private var itemName: AttributedString {
-        let name = switch item {
-        case let .agent(agent): agent.primaryHost
-        case .offlineWorkspace: "\(item.wsName).\(state.hostnameSuffix)"
-        }
+        let name = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
 
         var formattedName = AttributedString(name)
         formattedName.foregroundColor = .primary
@@ -79,17 +86,34 @@ struct MenuItemView: View {
         return formattedName
     }
 
+    private var isExpanded: Bool {
+        expandedItem == item.id
+    }
+
     private var wsURL: URL {
         // TODO: CoderVPN currently only supports owned workspaces
         baseAccessURL.appending(path: "@me").appending(path: item.wsName)
     }
 
+    private func toggleExpanded() {
+        userInteracted = true
+        if isExpanded {
+            withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
+                expandedItem = nil
+            }
+        } else {
+            withAnimation(.snappy(duration: Theme.Animation.collapsibleDuration)) {
+                expandedItem = item.id
+            }
+        }
+    }
+
     var body: some View {
         VStack(spacing: 0) {
-            HStack(spacing: 0) {
-                Link(destination: wsURL) {
+            HStack(spacing: 3) {
+                Button(action: toggleExpanded) {
                     HStack(spacing: Theme.Size.trayPadding) {
-                        StatusDot(color: item.status.color)
+                        AnimatedChevron(isExpanded: isExpanded, color: .secondary)
                         Text(itemName).lineLimit(1).truncationMode(.tail)
                         Spacer()
                     }.padding(.horizontal, Theme.Size.trayPadding)
@@ -98,42 +122,24 @@ struct MenuItemView: View {
                         .foregroundStyle(nameIsSelected ? .white : .primary)
                         .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
                         .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
-                        .onHoverWithPointingHand { hovering in
+                        .onHover { hovering in
                             nameIsSelected = hovering
                         }
-                    Spacer()
-                }.buttonStyle(.plain)
-                if case let .agent(agent) = item {
-                    Button {
-                        NSPasteboard.general.clearContents()
-                        NSPasteboard.general.setString(agent.primaryHost, forType: .string)
-                    } label: {
-                        Image(systemName: "doc.on.doc")
-                            .symbolVariant(.fill)
-                            .padding(3)
-                            .contentShape(Rectangle())
-                    }.foregroundStyle(copyIsSelected ? .white : .primary)
-                        .imageScale(.small)
-                        .background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
-                        .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
-                        .onHoverWithPointingHand { hovering in copyIsSelected = hovering }
-                        .buttonStyle(.plain)
-                        .padding(.trailing, Theme.Size.trayMargin)
-                }
+                }.buttonStyle(.plain).padding(.trailing, 3)
+                MenuItemIcons(item: item, wsURL: wsURL)
             }
-            if !apps.isEmpty {
-                HStack(spacing: 17) {
-                    ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
-                        WorkspaceAppIcon(app: app)
-                            .frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
-                    }
-                    if apps.count < defaultVisibleApps {
-                        Spacer()
+            if isExpanded {
+                if hasApps {
+                    MenuItemCollapsibleView(apps: apps)
+                } else {
+                    HStack {
+                        Text(item.status == .off ? "Workspace is offline." : "No apps available.")
+                            .font(.body)
+                            .foregroundColor(.secondary)
+                            .padding(.horizontal, Theme.Size.trayInset)
+                            .padding(.top, 7)
                     }
                 }
-                .padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
-                .padding(.bottom, 5)
-                .padding(.top, 10)
             }
         }
         .task { await loadApps() }
@@ -172,3 +178,83 @@ struct MenuItemView: View {
         }
     }
 }
+
+struct MenuItemCollapsibleView: View {
+    private let defaultVisibleApps = 5
+    let apps: [WorkspaceApp]
+
+    var body: some View {
+        HStack(spacing: 17) {
+            ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
+                WorkspaceAppIcon(app: app)
+                    .frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
+            }
+            if apps.count < defaultVisibleApps {
+                Spacer()
+            }
+        }
+        .padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
+        .padding(.bottom, 5)
+        .padding(.top, 10)
+    }
+}
+
+struct MenuItemIcons: View {
+    @EnvironmentObject var state: AppState
+    @Environment(\.openURL) private var openURL
+
+    let item: VPNMenuItem
+    let wsURL: URL
+
+    @State private var copyIsSelected: Bool = false
+    @State private var webIsSelected: Bool = false
+
+    func copyToClipboard() {
+        let primaryHost = item.primaryHost(hostnameSuffix: state.hostnameSuffix)
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(primaryHost, forType: .string)
+    }
+
+    var body: some View {
+        StatusDot(color: item.status.color)
+            .padding(.trailing, 3)
+            .padding(.top, 1)
+        MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
+            .font(.system(size: 9))
+            .symbolVariant(.fill)
+        MenuItemIconButton(systemName: "globe", action: { openURL(wsURL) })
+            .contentShape(Rectangle())
+            .font(.system(size: 12))
+            .padding(.trailing, Theme.Size.trayMargin)
+    }
+}
+
+struct MenuItemIconButton: View {
+    let systemName: String
+    @State var isSelected: Bool = false
+    let action: @MainActor () -> Void
+
+    var body: some View {
+        Button(action: action) {
+            Image(systemName: systemName)
+                .padding(3)
+                .contentShape(Rectangle())
+        }.foregroundStyle(isSelected ? .white : .primary)
+            .background(isSelected ? Color.accentColor.opacity(0.8) : .clear)
+            .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
+            .onHover { hovering in isSelected = hovering }
+            .buttonStyle(.plain)
+    }
+}
+
+struct AnimatedChevron: View {
+    let isExpanded: Bool
+    let color: Color
+
+    var body: some View {
+        Image(systemName: "chevron.right")
+            .font(.system(size: 12, weight: .semibold))
+            .foregroundColor(color)
+            .rotationEffect(.degrees(isExpanded ? 90 : 0))
+    }
+}
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
index 70a20d8b..14a4bd0f 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
@@ -37,7 +37,7 @@ struct WorkspaceAppIcon: View {
             RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
                 .stroke(.secondary, lineWidth: 1)
                 .opacity(isHovering && !isPressed ? 0.6 : 0.3)
-        ).onHoverWithPointingHand { hovering in isHovering = hovering }
+        ).onHover { hovering in isHovering = hovering }
         .simultaneousGesture(
             DragGesture(minimumDistance: 0)
                 .onChanged { _ in
diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
index 62c1607f..741b32e5 100644
--- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
@@ -62,7 +62,7 @@ struct AgentsTests {
         let forEach = try view.inspect().find(ViewType.ForEach.self)
         #expect(forEach.count == Theme.defaultVisibleAgents)
         // Agents are sorted by status, and then by name in alphabetical order
-        #expect(throws: Never.self) { try view.inspect().find(link: "a1.coder") }
+        #expect(throws: Never.self) { try view.inspect().find(text: "a1.coder") }
     }
 
     @Test
@@ -115,7 +115,7 @@ struct AgentsTests {
             try await sut.inspection.inspect { view in
                 let forEach = try view.find(ViewType.ForEach.self)
                 #expect(forEach.count == Theme.defaultVisibleAgents)
-                #expect(throws: Never.self) { try view.find(link: "offline.coder") }
+                #expect(throws: Never.self) { try view.find(text: "offline.coder") }
             }
         }
     }

From 6417d161d1a976c603857f9c0f7f6ef1096dc1d5 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 1 May 2025 12:55:06 +1000
Subject: [PATCH 08/16] chore: bump mutagen version (#144)

Closes https://github.com/coder/internal/issues/590
---
 Coder-Desktop/Resources/.mutagenversion                        | 2 +-
 Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift             | 3 +++
 .../MutagenSDK/filesystem_behavior_probe_mode.pb.swift         | 2 +-
 .../FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto   | 2 +-
 .../VPNLib/FileSync/MutagenSDK/selection_selection.pb.swift    | 2 +-
 .../VPNLib/FileSync/MutagenSDK/selection_selection.proto       | 2 +-
 .../VPNLib/FileSync/MutagenSDK/service_daemon_daemon.pb.swift  | 2 +-
 .../VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto     | 2 +-
 .../FileSync/MutagenSDK/service_prompting_prompting.pb.swift   | 2 +-
 .../FileSync/MutagenSDK/service_prompting_prompting.proto      | 2 +-
 .../service_synchronization_synchronization.pb.swift           | 2 +-
 .../MutagenSDK/service_synchronization_synchronization.proto   | 2 +-
 .../MutagenSDK/synchronization_compression_algorithm.pb.swift  | 2 +-
 .../MutagenSDK/synchronization_compression_algorithm.proto     | 2 +-
 .../FileSync/MutagenSDK/synchronization_configuration.pb.swift | 2 +-
 .../FileSync/MutagenSDK/synchronization_configuration.proto    | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_change.pb.swift   | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_change.proto      | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_conflict.pb.swift | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_conflict.proto    | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_entry.pb.swift    | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_entry.proto       | 2 +-
 .../synchronization_core_ignore_ignore_vcs_mode.pb.swift       | 2 +-
 .../synchronization_core_ignore_ignore_vcs_mode.proto          | 2 +-
 .../MutagenSDK/synchronization_core_ignore_syntax.pb.swift     | 2 +-
 .../MutagenSDK/synchronization_core_ignore_syntax.proto        | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_mode.pb.swift     | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto | 2 +-
 .../MutagenSDK/synchronization_core_permissions_mode.pb.swift  | 2 +-
 .../MutagenSDK/synchronization_core_permissions_mode.proto     | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_problem.pb.swift  | 2 +-
 .../FileSync/MutagenSDK/synchronization_core_problem.proto     | 2 +-
 .../synchronization_core_symbolic_link_mode.pb.swift           | 2 +-
 .../MutagenSDK/synchronization_core_symbolic_link_mode.proto   | 2 +-
 .../MutagenSDK/synchronization_hashing_algorithm.pb.swift      | 2 +-
 .../MutagenSDK/synchronization_hashing_algorithm.proto         | 2 +-
 .../FileSync/MutagenSDK/synchronization_rsync_receive.pb.swift | 2 +-
 .../FileSync/MutagenSDK/synchronization_rsync_receive.proto    | 2 +-
 .../FileSync/MutagenSDK/synchronization_scan_mode.pb.swift     | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto | 2 +-
 .../FileSync/MutagenSDK/synchronization_session.pb.swift       | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_session.proto   | 2 +-
 .../FileSync/MutagenSDK/synchronization_stage_mode.pb.swift    | 2 +-
 .../FileSync/MutagenSDK/synchronization_stage_mode.proto       | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_state.pb.swift  | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_state.proto     | 2 +-
 .../FileSync/MutagenSDK/synchronization_version.pb.swift       | 2 +-
 .../VPNLib/FileSync/MutagenSDK/synchronization_version.proto   | 2 +-
 .../FileSync/MutagenSDK/synchronization_watch_mode.pb.swift    | 2 +-
 .../FileSync/MutagenSDK/synchronization_watch_mode.proto       | 2 +-
 Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.pb.swift      | 2 +-
 Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto         | 2 +-
 52 files changed, 54 insertions(+), 51 deletions(-)

diff --git a/Coder-Desktop/Resources/.mutagenversion b/Coder-Desktop/Resources/.mutagenversion
index 69968c92..2b91414a 100644
--- a/Coder-Desktop/Resources/.mutagenversion
+++ b/Coder-Desktop/Resources/.mutagenversion
@@ -1 +1 @@
-v0.18.2
+v0.18.3
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index f8f1dc71..01e1d6ba 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -241,6 +241,9 @@ public class MutagenDaemon: FileSyncDaemon {
         process.environment = [
             "MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path,
             "MUTAGEN_SSH_PATH": "/usr/bin",
+            // Do not use `~/.ssh/config`, as it may contain an entry for
+            // '*.<hostnameSuffix' that uses `coder ssh`.
+            "MUTAGEN_SSH_CONFIG_PATH": "none",
         ]
         logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)")
         return process
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.pb.swift
index d82f9055..568903db 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/filesystem/behavior/probe_mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/filesystem/behavior/probe_mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto
index e3efbf01..fc71d3d2 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/filesystem_behavior_probe_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/filesystem/behavior/probe_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/filesystem/behavior/probe_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.pb.swift
index 9ea8215d..c61b035c 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/selection/selection.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/selection/selection.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto
index a5419e5e..64e5b501 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/selection_selection.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/selection/selection.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/selection/selection.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.pb.swift
index f00093a2..ecd12a42 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/daemon/daemon.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/daemon/daemon.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto
index 663b27c2..3c498a01 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_daemon_daemon.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/service/daemon/daemon.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/daemon/daemon.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift
index 74afe922..3564336f 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/prompting/prompting.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/prompting/prompting.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
index 65a2dedc..c928d0df 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_prompting_prompting.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/service/prompting/prompting.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/prompting/prompting.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.pb.swift
index ccb4100a..f15a5cba 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/service/synchronization/synchronization.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/synchronization/synchronization.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto
index 413c713d..9df6a69b 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/service_synchronization_synchronization.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/service/synchronization/synchronization.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/service/synchronization/synchronization.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.pb.swift
index af5a42df..0ccc766b 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/compression/algorithm.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/compression/algorithm.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto
index 90aa4259..fb7998ad 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_compression_algorithm.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/compression/algorithm.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/compression/algorithm.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.pb.swift
index 8ce62c70..5b630026 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/configuration.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/configuration.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto
index 348741be..4bea4cbe 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_configuration.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/configuration.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/configuration.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.pb.swift
index 5e53a588..e24e33dc 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/change.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/change.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto
index 7c34bcf9..e416992b 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_change.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/change.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/change.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.pb.swift
index 3607a6cb..d3bc3f47 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/conflict.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/conflict.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto
index 78daa03c..67a6bcbf 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_conflict.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/conflict.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/conflict.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.pb.swift
index d3cb6c58..9b40020f 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/entry.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/entry.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto
index 26bb6bcb..5605be45 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_entry.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/entry.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/entry.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.pb.swift
index 396bbc5c..d91cd128 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/ignore/ignore_vcs_mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/ignore_vcs_mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto
index 131e93ed..aab9da2a 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_ignore_vcs_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/ignore/ignore_vcs_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/ignore_vcs_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.pb.swift
index aa516b64..773c2015 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/ignore/syntax.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/syntax.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto
index 89ddc2e2..d682a873 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_ignore_syntax.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/ignore/syntax.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/ignore/syntax.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.pb.swift
index 4bca523e..f192c992 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto
index d0d931e8..53a5a91f 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.pb.swift
index e6d95973..6a4d9cfa 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/permissions_mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/permissions_mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto
index 3d4aab45..c6b1db6b 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_permissions_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/permissions_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/permissions_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.pb.swift
index 8c2ba6bb..5edcf9e5 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/problem.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/problem.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto
index f598e6f2..44c727de 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_problem.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/problem.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/problem.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.pb.swift
index d379c68e..55763f5a 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/core/symbolic_link_mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/symbolic_link_mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto
index b861baa0..1b8e3df2 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_core_symbolic_link_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/core/symbolic_link_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/core/symbolic_link_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.pb.swift
index 5a9c295f..247beffb 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/hashing/algorithm.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/hashing/algorithm.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto
index 2b8ebd0c..7ee73150 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_hashing_algorithm.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/hashing/algorithm.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/hashing/algorithm.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.pb.swift
index 324659c6..efb63b96 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/rsync/receive.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/rsync/receive.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto
index 7f7e3053..87baf48c 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_rsync_receive.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/rsync/receive.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/rsync/receive.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.pb.swift
index 4d0ad6f7..3502a746 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/scan_mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/scan_mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto
index 9c74fa6a..af6b153c 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_scan_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/scan_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/scan_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.pb.swift
index 652166f2..24218aa7 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/session.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/session.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto
index c133df91..8d9ad95f 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_session.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/session.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/session.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.pb.swift
index 61769ace..b365ab94 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/stage_mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/stage_mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto
index 6b0a4f12..9a037299 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_stage_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/stage_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/stage_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.pb.swift
index 0d7ef6cf..25d0d77c 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/state.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/state.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto
index aacd6520..a4e829c2 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_state.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/state.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/state.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.pb.swift
index d62b116e..c50d984b 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/version.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/version.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto
index bc352585..2681fd9e 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_version.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/version.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/version.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.pb.swift
index 7836b35d..4f5e3aef 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/synchronization/watch_mode.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/watch_mode.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto
index f4e2b8de..9ba22dd6 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/synchronization_watch_mode.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/synchronization/watch_mode.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/synchronization/watch_mode.proto
  *
  * MIT License
  * 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.pb.swift b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.pb.swift
index 32a305e0..a7893494 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.pb.swift
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.pb.swift
@@ -10,7 +10,7 @@
 
 //
 // This file was taken from
-// https://github.com/mutagen-io/mutagen/tree/v0.18.1/pkg/url/url.proto
+// https://github.com/coder/mutagen/tree/v0.18.3/pkg/url/url.proto
 //
 // MIT License
 // 
diff --git a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto
index 57139831..287c887a 100644
--- a/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto
+++ b/Coder-Desktop/VPNLib/FileSync/MutagenSDK/url_url.proto
@@ -1,6 +1,6 @@
 /*
  * This file was taken from
- * https://github.com/coder/mutagen/tree/v0.18.2/pkg/url/url.proto
+ * https://github.com/coder/mutagen/tree/v0.18.3/pkg/url/url.proto
  *
  * MIT License
  * 

From 79b0f7cb037e912de22f00c50762d374175a34ee Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Wed, 7 May 2025 16:29:23 +1000
Subject: [PATCH 09/16] feat: make workspace app icons smaller, neater (#152)

https://github.com/user-attachments/assets/77436fc1-dcab-4246-8307-f0e69081ca06
---
 Coder-Desktop/Coder-Desktop/Theme.swift             |  4 ++--
 .../Coder-Desktop/Views/VPN/VPNMenuItem.swift       | 10 ++++------
 .../Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift  | 13 +++++--------
 3 files changed, 11 insertions(+), 16 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Theme.swift b/Coder-Desktop/Coder-Desktop/Theme.swift
index 546242c2..c697f1e3 100644
--- a/Coder-Desktop/Coder-Desktop/Theme.swift
+++ b/Coder-Desktop/Coder-Desktop/Theme.swift
@@ -8,8 +8,8 @@ enum Theme {
 
         static let rectCornerRadius: CGFloat = 4
 
-        static let appIconWidth: CGFloat = 30
-        static let appIconHeight: CGFloat = 30
+        static let appIconWidth: CGFloat = 17
+        static let appIconHeight: CGFloat = 17
         static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
     }
 
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
index d67e34ff..c10b9322 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
@@ -180,20 +180,18 @@ struct MenuItemView: View {
 }
 
 struct MenuItemCollapsibleView: View {
-    private let defaultVisibleApps = 5
+    private let defaultVisibleApps = 6
     let apps: [WorkspaceApp]
 
     var body: some View {
-        HStack(spacing: 17) {
+        HStack(spacing: 16) {
             ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
                 WorkspaceAppIcon(app: app)
                     .frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
             }
-            if apps.count < defaultVisibleApps {
-                Spacer()
-            }
+            Spacer()
         }
-        .padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
+        .padding(.leading, 32)
         .padding(.bottom, 5)
         .padding(.top, 10)
     }
diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
index 14a4bd0f..8ac79c43 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
@@ -19,9 +19,9 @@ struct WorkspaceAppIcon: View {
                 ) { $0 }
                     placeholder: {
                         if app.icon != nil {
-                            ProgressView()
+                            ProgressView().controlSize(.small)
                         } else {
-                            Text(app.displayName).frame(
+                            Image(systemName: "questionmark").frame(
                                 width: Theme.Size.appIconWidth,
                                 height: Theme.Size.appIconHeight
                             )
@@ -30,14 +30,11 @@ struct WorkspaceAppIcon: View {
                         width: Theme.Size.appIconWidth,
                         height: Theme.Size.appIconHeight
                     )
-            }.padding(4)
+            }.padding(6)
         }
+        .background(isHovering ? Color.accentColor.opacity(0.8) : .clear)
         .clipShape(RoundedRectangle(cornerRadius: 8))
-        .overlay(
-            RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
-                .stroke(.secondary, lineWidth: 1)
-                .opacity(isHovering && !isPressed ? 0.6 : 0.3)
-        ).onHover { hovering in isHovering = hovering }
+        .onHover { hovering in isHovering = hovering }
         .simultaneousGesture(
             DragGesture(minimumDistance: 0)
                 .onChanged { _ in

From 31bdfa5c31c2bee46bc41e95deb92eb358fbb3d1 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 8 May 2025 13:31:29 +1000
Subject: [PATCH 10/16] fix(pkgbuild): dont start coder connect after upgrade
 (#150)

As part of my work on #121 and trying to reproduce the behaviour in a VM, I discovered the issue consistently occurs if the VPN is running when the app is launched (as the network extension is replaced on upgrade).

The existing pkgbuild scripts don't account for the VPN running with the app closed.
In this scenario the `preinstall` script creates a marker file saying the VPN should be started in the `postinstall`. A marker file saying the app is running is not created.
Then, in the `postinstall` the VPN is started, but not the app. When the user does launch the app, the network extension is upgraded whilst the VPN is running, and the linked issue occurs.

It's not possible to write a bash script that waits for not only the app to launch, but for the network extension replacement request to be sent and handled.
However, we already do this in  Swift code. If `Start Coder Connect on launch` is toggled on, the VPN won't be started until it is absolutely safe to do so.
<img width="604" alt="image" src="https://github.com/user-attachments/assets/010446b6-be33-47f0-9e59-4131f89d46a0" />

Therefore, we'll remove the portion of the pkgbuild scripts responsible for turning the VPN on after upgrade. If users want this behaviour, they can just toggle it on in settings.
---
 pkgbuild/scripts/postinstall | 11 -----------
 pkgbuild/scripts/preinstall  |  6 +-----
 2 files changed, 1 insertion(+), 16 deletions(-)

diff --git a/pkgbuild/scripts/postinstall b/pkgbuild/scripts/postinstall
index cdab83bd..758776f6 100755
--- a/pkgbuild/scripts/postinstall
+++ b/pkgbuild/scripts/postinstall
@@ -1,7 +1,6 @@
 #!/usr/bin/env bash
 
 RUNNING_MARKER_FILE="/tmp/coder_desktop_running"
-VPN_MARKER_FILE="/tmp/coder_vpn_was_running"
 
 # Before this script, or the user, opens the app, make sure
 # Gatekeeper has ingested the notarization ticket.
@@ -19,14 +18,4 @@ if [ -f "$RUNNING_MARKER_FILE" ]; then
   echo "Coder Desktop started."
 fi
 
-# Restart VPN if it was running before
-if [ -f "$VPN_MARKER_FILE" ]; then
-  echo "Restarting CoderVPN..."
-  echo "Sleeping for 3..."
-  sleep 3
-  scutil --nc start "$(scutil --nc list | grep "com.coder.Coder-Desktop" | awk -F'"' '{print $2}')"
-  rm "$VPN_MARKER_FILE"
-  echo "CoderVPN started."
-fi
-
 exit 0
diff --git a/pkgbuild/scripts/preinstall b/pkgbuild/scripts/preinstall
index f4962e9c..d52c1330 100755
--- a/pkgbuild/scripts/preinstall
+++ b/pkgbuild/scripts/preinstall
@@ -1,9 +1,8 @@
 #!/usr/bin/env bash
 
 RUNNING_MARKER_FILE="/tmp/coder_desktop_running"
-VPN_MARKER_FILE="/tmp/coder_vpn_was_running"
 
-rm $VPN_MARKER_FILE $RUNNING_MARKER_FILE || true
+rm $RUNNING_MARKER_FILE || true
 
 if pgrep 'Coder Desktop'; then
   touch $RUNNING_MARKER_FILE
@@ -14,9 +13,6 @@ vpn_name=$(scutil --nc list | grep "com.coder.Coder-Desktop" | awk -F'"' '{print
 echo "Turning off VPN"
 if [[ -n "$vpn_name" ]]; then
   echo "CoderVPN found. Stopping..."
-  if scutil --nc status "$vpn_name" | grep -q "^Connected$"; then
-    touch $VPN_MARKER_FILE
-  fi
   scutil --nc stop "$vpn_name"
 
   # Wait for VPN to be disconnected

From f03952665be3387d1480b50c5238124015801097 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 8 May 2025 13:35:39 +1000
Subject: [PATCH 11/16] chore: dont stop coder connect on device sleep (#151)

Closes #88.

With https://github.com/coder/internal/issues/563 resolved, there's no need to stop the VPN on sleep, as when the device wakes the tunnel will have the correct workspace & agent state.

However, if the Coder deployment doesn't include the patch in https://github.com/coder/coder/pull/17598 (presumably v2.23 or later), the UI state will go out of sync when the device is slept or internet connection is lost. I think this is fine honestly, and I don't think it's worth doing a server version check to determine whether we should stop the VPN on sleep.
---
 Coder-Desktop/VPN/Manager.swift              |  7 ++---
 Coder-Desktop/VPN/PacketTunnelProvider.swift | 32 ++------------------
 2 files changed, 5 insertions(+), 34 deletions(-)

diff --git a/Coder-Desktop/VPN/Manager.swift b/Coder-Desktop/VPN/Manager.swift
index adff1434..b9573810 100644
--- a/Coder-Desktop/VPN/Manager.swift
+++ b/Coder-Desktop/VPN/Manager.swift
@@ -32,10 +32,9 @@ actor Manager {
             let sessionConfig = URLSessionConfiguration.default
             // The tunnel might be asked to start before the network interfaces have woken up from sleep
             sessionConfig.waitsForConnectivity = true
-            // URLSession's waiting for connectivity sometimes hangs even when
-            // the network is up so this is deliberately short (30s) to avoid a
-            // poor UX where it appears stuck.
-            sessionConfig.timeoutIntervalForResource = 30
+            // Timeout after 5 minutes, or if there's no data for 60 seconds
+            sessionConfig.timeoutIntervalForRequest = 60
+            sessionConfig.timeoutIntervalForResource = 300
             try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
         } catch {
             throw .download(error)
diff --git a/Coder-Desktop/VPN/PacketTunnelProvider.swift b/Coder-Desktop/VPN/PacketTunnelProvider.swift
index a5bfb15c..140cb5cc 100644
--- a/Coder-Desktop/VPN/PacketTunnelProvider.swift
+++ b/Coder-Desktop/VPN/PacketTunnelProvider.swift
@@ -57,7 +57,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
         start(completionHandler)
     }
 
-    // called by `startTunnel` and on `wake`
+    // called by `startTunnel`
     func start(_ completionHandler: @escaping (Error?) -> Void) {
         guard let proto = protocolConfiguration as? NETunnelProviderProtocol,
               let baseAccessURL = proto.serverAddress
@@ -108,7 +108,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
         teardown(completionHandler)
     }
 
-    // called by `stopTunnel` and `sleep`
+    // called by `stopTunnel`
     func teardown(_ completionHandler: @escaping () -> Void) {
         guard let manager else {
             logger.error("teardown called with nil Manager")
@@ -138,34 +138,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
         }
     }
 
-    // sleep and wake reference: https://developer.apple.com/forums/thread/95988
-    override func sleep(completionHandler: @escaping () -> Void) {
-        logger.debug("sleep called")
-        teardown(completionHandler)
-    }
-
-    override func wake() {
-        // It's possible the tunnel is still starting up, if it is, wake should
-        // be a no-op.
-        guard !reasserting else { return }
-        guard manager == nil else {
-            logger.error("wake called with non-nil Manager")
-            return
-        }
-        logger.debug("wake called")
-        reasserting = true
-        currentSettings = .init(tunnelRemoteAddress: "127.0.0.1")
-        setTunnelNetworkSettings(nil)
-        start { error in
-            if let error {
-                self.logger.error("error starting tunnel after wake: \(error.localizedDescription)")
-                self.cancelTunnelWithError(error)
-            } else {
-                self.reasserting = false
-            }
-        }
-    }
-
     // Wrapper around `setTunnelNetworkSettings` that supports merging updates
     func applyTunnelNetworkSettings(_ diff: Vpn_NetworkSettingsRequest) async throws {
         logger.debug("applying settings diff: \(diff.debugDescription, privacy: .public)")

From 565665fa888b5088b89558cca53e2cdf9368ff49 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Thu, 8 May 2025 13:36:36 +1000
Subject: [PATCH 12/16] feat: show an alert when the menu bar icon is hidden
 (#153)

Relates to #148.

If the menu bar icon is hidden (such as when behind the notch, or otherwise), reopening the app will display an alert that the icon is hidden.
There's also a button to not show the alert again.

I've also tested that this, and the 'Do not show again' button, work in a fresh VM.

This is the same as what Tailscale does:

https://github.com/user-attachments/assets/dae6d9ed-eab2-404f-8522-314042bdd1d8
---
 .../Coder-Desktop/Coder_DesktopApp.swift      | 23 +++++++++++++++++++
 Coder-Desktop/Coder-Desktop/State.swift       |  9 ++++++++
 Coder-Desktop/project.yml                     |  2 +-
 3 files changed, 33 insertions(+), 1 deletion(-)

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 4ec412fc..d9cd6493 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -126,6 +126,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
         false
     }
+
+    func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
+        if !state.skipHiddenIconAlert, let menuBar, !menuBar.menuBarExtra.isVisible {
+            displayIconHiddenAlert()
+        }
+        return true
+    }
+
+    private func displayIconHiddenAlert() {
+        let alert = NSAlert()
+        alert.alertStyle = .informational
+        alert.messageText = "Coder Desktop is hidden!"
+        alert.informativeText = """
+        Coder Desktop is running, but there's no space in the menu bar for it's icon.
+        You can rearrange icons by holding command.
+        """
+        alert.addButton(withTitle: "OK")
+        alert.addButton(withTitle: "Don't show again")
+        let resp = alert.runModal()
+        if resp == .alertSecondButtonReturn {
+            state.skipHiddenIconAlert = true
+        }
+    }
 }
 
 extension AppDelegate {
diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift
index 2247c469..e9a02488 100644
--- a/Coder-Desktop/Coder-Desktop/State.swift
+++ b/Coder-Desktop/Coder-Desktop/State.swift
@@ -69,6 +69,13 @@ class AppState: ObservableObject {
         }
     }
 
+    @Published var skipHiddenIconAlert: Bool = UserDefaults.standard.bool(forKey: Keys.skipHiddenIconAlert) {
+        didSet {
+            guard persistent else { return }
+            UserDefaults.standard.set(skipHiddenIconAlert, forKey: Keys.skipHiddenIconAlert)
+        }
+    }
+
     func tunnelProviderProtocol() -> NETunnelProviderProtocol? {
         if !hasSession { return nil }
         let proto = NETunnelProviderProtocol()
@@ -209,6 +216,8 @@ class AppState: ObservableObject {
         static let literalHeaders = "LiteralHeaders"
         static let stopVPNOnQuit = "StopVPNOnQuit"
         static let startVPNOnLaunch = "StartVPNOnLaunch"
+
+        static let skipHiddenIconAlert = "SkipHiddenIconAlert"
     }
 }
 
diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml
index f557304a..701d6483 100644
--- a/Coder-Desktop/project.yml
+++ b/Coder-Desktop/project.yml
@@ -97,7 +97,7 @@ packages:
     # - Set onAppear/disappear handlers.
     # The upstream repo has a purposefully limited API
     url: https://github.com/coder/fluid-menu-bar-extra
-    revision: 96a861a
+    revision: 8e1d8b8
   KeychainAccess:
     url: https://github.com/kishikawakatsumi/KeychainAccess
     branch: e0c7eebc5a4465a3c4680764f26b7a61f567cdaf

From 49fd303a6c3d9a4830027fdc10292dbc8d3197e6 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Fri, 9 May 2025 11:56:33 +1000
Subject: [PATCH 13/16] fix: handle missing workspace app `display_name` (#154)

also uses `code-insiders.svg` for the VS Code insiders icon.

Context: https://github.com/coder/coder/pull/17700
---
 Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift | 5 +++--
 Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift     | 2 +-
 Coder-Desktop/CoderSDK/Workspace.swift                       | 5 ++---
 3 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
index 8ac79c43..2eb45cc5 100644
--- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
+++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
@@ -75,7 +75,8 @@ struct WorkspaceApp {
         sessionToken: String
     ) throws(WorkspaceAppError) {
         slug = original.slug
-        displayName = original.display_name
+        // Same behaviour as the web UI
+        displayName = original.display_name ?? original.slug
 
         guard original.external else {
             throw .isWebApp
@@ -196,7 +197,7 @@ func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -
 }
 
 func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
-    let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
+    let icon = baseIconURL.appendingPathComponent("/icon/code-insiders.svg")
     return WorkspaceApp(
         slug: "-vscode-insiders",
         displayName: "VS Code Insiders Desktop",
diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift
index 816c5e04..d0aead16 100644
--- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift
+++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift
@@ -137,7 +137,7 @@ struct WorkspaceAppTests {
         #expect(apps.count == 1)
         #expect(apps[0].slug == "-vscode-insiders")
         #expect(apps[0].displayName == "VS Code Insiders Desktop")
-        #expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg")
+        #expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code-insiders.svg")
         #expect(
             apps[0].url.absoluteString == """
             vscode-insiders://vscode-remote/ssh-remote+test-workspace.coder.test//home/user
diff --git a/Coder-Desktop/CoderSDK/Workspace.swift b/Coder-Desktop/CoderSDK/Workspace.swift
index e8f95df3..e70820da 100644
--- a/Coder-Desktop/CoderSDK/Workspace.swift
+++ b/Coder-Desktop/CoderSDK/Workspace.swift
@@ -56,11 +56,10 @@ public struct WorkspaceAgent: Codable, Identifiable, Sendable {
 
 public struct WorkspaceApp: Codable, Identifiable, Sendable {
     public let id: UUID
-    // Not `omitempty`, but `coderd` sends empty string if `command` is set
-    public var url: URL?
+    public var url: URL? // `omitempty`
     public let external: Bool
     public let slug: String
-    public let display_name: String
+    public let display_name: String? // `omitempty`
     public let command: String? // `omitempty`
     public let icon: URL? // `omitempty`
     public let subdomain: Bool

From 0cf2f28f142bfc84380d70a02928d2e022abcae7 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Mon, 12 May 2025 12:46:09 +1000
Subject: [PATCH 14/16] chore: add handler and router for `coder` scheme URIs
 (#145)

Relates to #96.
Closes https://github.com/coder/coder-desktop-macos/issues/95
---
 .../Coder-Desktop/Coder_DesktopApp.swift      |  19 ++-
 Coder-Desktop/Coder-Desktop/Info.plist        |  15 +++
 Coder-Desktop/Coder-Desktop/URLHandler.swift  |  39 +++++++
 Coder-Desktop/Resources/1024Icon.png          | Bin 0 -> 18138 bytes
 Coder-Desktop/VPNLib/CoderRouter.swift        |  64 +++++++++++
 .../VPNLibTests/CoderRouterTests.swift        | 108 ++++++++++++++++++
 Coder-Desktop/project.yml                     |   5 +
 Makefile                                      |   1 +
 scripts/build.sh                              |   1 +
 9 files changed, 249 insertions(+), 3 deletions(-)
 create mode 100644 Coder-Desktop/Coder-Desktop/URLHandler.swift
 create mode 100644 Coder-Desktop/Resources/1024Icon.png
 create mode 100644 Coder-Desktop/VPNLib/CoderRouter.swift
 create mode 100644 Coder-Desktop/VPNLibTests/CoderRouterTests.swift

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index d9cd6493..4d787355 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -17,8 +17,8 @@ struct DesktopApp: App {
         Window("Sign In", id: Windows.login.rawValue) {
             LoginForm()
                 .environmentObject(appDelegate.state)
-        }
-        .windowResizability(.contentSize)
+        }.handlesExternalEvents(matching: Set()) // Don't handle deep links
+            .windowResizability(.contentSize)
         SwiftUI.Settings {
             SettingsView<CoderVPNService>()
                 .environmentObject(appDelegate.vpn)
@@ -30,7 +30,7 @@ struct DesktopApp: App {
                 .environmentObject(appDelegate.state)
                 .environmentObject(appDelegate.fileSyncDaemon)
                 .environmentObject(appDelegate.vpn)
-        }
+        }.handlesExternalEvents(matching: Set()) // Don't handle deep links
     }
 }
 
@@ -40,6 +40,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     let vpn: CoderVPNService
     let state: AppState
     let fileSyncDaemon: MutagenDaemon
+    let urlHandler: URLHandler
 
     override init() {
         vpn = CoderVPNService()
@@ -65,6 +66,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
             await fileSyncDaemon.tryStart()
         }
         self.fileSyncDaemon = fileSyncDaemon
+        urlHandler = URLHandler(state: state, vpn: vpn)
     }
 
     func applicationDidFinishLaunching(_: Notification) {
@@ -134,6 +136,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         return true
     }
 
+    func application(_: NSApplication, open urls: [URL]) {
+        guard let url = urls.first else {
+            // We only accept one at time, for now
+            return
+        }
+        do { try urlHandler.handle(url) } catch {
+            // TODO: Push notification
+            print(error.description)
+        }
+    }
+
     private func displayIconHiddenAlert() {
         let alert = NSAlert()
         alert.alertStyle = .informational
diff --git a/Coder-Desktop/Coder-Desktop/Info.plist b/Coder-Desktop/Coder-Desktop/Info.plist
index 5e59b253..4712604f 100644
--- a/Coder-Desktop/Coder-Desktop/Info.plist
+++ b/Coder-Desktop/Coder-Desktop/Info.plist
@@ -2,6 +2,21 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleTypeRole</key>
+			<string>Editor</string>
+			<key>CFBundleURLIconFile</key>
+			<string>1024Icon</string>
+			<key>CFBundleURLName</key>
+			<string>com.coder.Coder-Desktop</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>coder</string>
+			</array>
+		</dict>
+	</array>
 	<key>NSAppTransportSecurity</key>
 	<dict>
 		<!--
diff --git a/Coder-Desktop/Coder-Desktop/URLHandler.swift b/Coder-Desktop/Coder-Desktop/URLHandler.swift
new file mode 100644
index 00000000..191c19d9
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/URLHandler.swift
@@ -0,0 +1,39 @@
+import Foundation
+import VPNLib
+
+@MainActor
+class URLHandler {
+    let state: AppState
+    let vpn: any VPNService
+    let router: CoderRouter
+
+    init(state: AppState, vpn: any VPNService) {
+        self.state = state
+        self.vpn = vpn
+        router = CoderRouter()
+    }
+
+    func handle(_ url: URL) throws(RouterError) {
+        guard state.hasSession, let deployment = state.baseAccessURL else {
+            throw .noSession
+        }
+        guard deployment.host() == url.host else {
+            throw .invalidAuthority(url.host() ?? "<none>")
+        }
+        do {
+            switch try router.match(url: url) {
+            case let .open(workspace, agent, type):
+                switch type {
+                case let .rdp(creds):
+                    handleRDP(workspace: workspace, agent: agent, creds: creds)
+                }
+            }
+        } catch {
+            throw .matchError(url: url)
+        }
+
+        func handleRDP(workspace _: String, agent _: String, creds _: RDPCredentials) {
+            // TODO: Handle RDP
+        }
+    }
+}
diff --git a/Coder-Desktop/Resources/1024Icon.png b/Coder-Desktop/Resources/1024Icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..cc20c781e94495f6ea18cc885b2b13f7edbe7982
GIT binary patch
diff --git a/Coder-Desktop/VPNLib/CoderRouter.swift b/Coder-Desktop/VPNLib/CoderRouter.swift
new file mode 100644
index 00000000..de849e7c
--- /dev/null
+++ b/Coder-Desktop/VPNLib/CoderRouter.swift
@@ -0,0 +1,64 @@
+import Foundation
+import URLRouting
+
+// This is in VPNLib to avoid depending on `swift-collections` in both the app & extension.
+public struct CoderRouter: ParserPrinter {
+    public init() {}
+
+    public var body: some ParserPrinter<URLRequestData, CoderRoute> {
+        Route(.case(CoderRoute.open(workspace:agent:route:))) {
+            Scheme("coder")
+            // v0/open/ws/<workspace>/agent/<agent>/<openType>
+            Path { "v0"; "open"; "ws"; Parse(.string); "agent"; Parse(.string) }
+            openRouter
+        }
+    }
+
+    var openRouter: some ParserPrinter<URLRequestData, OpenRoute> {
+        OneOf {
+            Route(.memberwise(OpenRoute.rdp)) {
+                Path { "rdp" }
+                Query {
+                    Parse(.memberwise(RDPCredentials.init)) {
+                        Optionally { Field("username") }
+                        Optionally { Field("password") }
+                    }
+                }
+            }
+        }
+    }
+}
+
+public enum RouterError: Error {
+    case invalidAuthority(String)
+    case matchError(url: URL)
+    case noSession
+
+    public var description: String {
+        switch self {
+        case let .invalidAuthority(authority):
+            "Authority '\(authority)' does not match the host of the current Coder deployment."
+        case let .matchError(url):
+            "Failed to handle \(url.absoluteString) because the format is unsupported."
+        case .noSession:
+            "Not logged in."
+        }
+    }
+
+    public var localizedDescription: String { description }
+}
+
+public enum CoderRoute: Equatable, Sendable {
+    case open(workspace: String, agent: String, route: OpenRoute)
+}
+
+public enum OpenRoute: Equatable, Sendable {
+    case rdp(RDPCredentials)
+}
+
+// Due to a Swift Result builder limitation, we can't flatten this out to `case rdp(String?, String?)`
+// https://github.com/pointfreeco/swift-url-routing/issues/50
+public struct RDPCredentials: Equatable, Sendable {
+    public let username: String?
+    public let password: String?
+}
diff --git a/Coder-Desktop/VPNLibTests/CoderRouterTests.swift b/Coder-Desktop/VPNLibTests/CoderRouterTests.swift
new file mode 100644
index 00000000..2d47dd6f
--- /dev/null
+++ b/Coder-Desktop/VPNLibTests/CoderRouterTests.swift
@@ -0,0 +1,108 @@
+import Foundation
+import Testing
+import URLRouting
+@testable import VPNLib
+
+@MainActor
+@Suite(.timeLimit(.minutes(1)))
+struct CoderRouterTests {
+    let router: CoderRouter
+
+    init() {
+        router = CoderRouter()
+    }
+
+    struct RouteTestCase: CustomStringConvertible, Sendable {
+        let urlString: String
+        let expectedRoute: CoderRoute?
+        let description: String
+    }
+
+    @Test("RDP routes", arguments: [
+        // Valid routes
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v0/open/ws/myworkspace/agent/dev/rdp?username=user&password=pass",
+            expectedRoute: .open(
+                workspace: "myworkspace",
+                agent: "dev",
+                route: .rdp(RDPCredentials(username: "user", password: "pass"))
+            ),
+            description: "RDP with username and password"
+        ),
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v0/open/ws/workspace-123/agent/agent-456/rdp",
+            expectedRoute: .open(
+                workspace: "workspace-123",
+                agent: "agent-456",
+                route: .rdp(RDPCredentials(username: nil, password: nil))
+            ),
+            description: "RDP without credentials"
+        ),
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v0/open/ws/workspace-123/agent/agent-456/rdp?username=user",
+            expectedRoute: .open(
+                workspace: "workspace-123",
+                agent: "agent-456",
+                route: .rdp(RDPCredentials(username: "user", password: nil))
+            ),
+            description: "RDP with username only"
+        ),
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v0/open/ws/workspace-123/agent/agent-456/rdp?password=pass",
+            expectedRoute: .open(
+                workspace: "workspace-123",
+                agent: "agent-456",
+                route: .rdp(RDPCredentials(username: nil, password: "pass"))
+            ),
+            description: "RDP with password only"
+        ),
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v0/open/ws/ws-special-chars/agent/agent-with-dashes/rdp",
+            expectedRoute: .open(
+                workspace: "ws-special-chars",
+                agent: "agent-with-dashes",
+                route: .rdp(RDPCredentials(username: nil, password: nil))
+            ),
+            description: "RDP with special characters in workspace and agent IDs"
+        ),
+
+        // Invalid routes
+        RouteTestCase(
+            urlString: "coder://coder.example.com/invalid/path",
+            expectedRoute: nil,
+            description: "Completely invalid path"
+        ),
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v1/open/ws/workspace-123/agent/agent-456/rdp",
+            expectedRoute: nil,
+            description: "Invalid version prefix (v1 instead of v0)"
+        ),
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v0/open/workspace-123/agent/agent-456/rdp",
+            expectedRoute: nil,
+            description: "Missing 'ws' segment"
+        ),
+        RouteTestCase(
+            urlString: "coder://coder.example.com/v0/open/ws/workspace-123/rdp",
+            expectedRoute: nil,
+            description: "Missing agent segment"
+        ),
+        RouteTestCase(
+            urlString: "http://coder.example.com/v0/open/ws/workspace-123/agent/agent-456",
+            expectedRoute: nil,
+            description: "Wrong scheme"
+        ),
+    ])
+    func testRdpRoutes(testCase: RouteTestCase) throws {
+        let url = URL(string: testCase.urlString)!
+
+        if let expectedRoute = testCase.expectedRoute {
+            let route = try router.match(url: url)
+            #expect(route == expectedRoute)
+        } else {
+            #expect(throws: (any Error).self) {
+                _ = try router.match(url: url)
+            }
+        }
+    }
+}
diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml
index 701d6483..9ec3ba44 100644
--- a/Coder-Desktop/project.yml
+++ b/Coder-Desktop/project.yml
@@ -126,6 +126,10 @@ packages:
   SDWebImageSVGCoder:
     url: https://github.com/SDWebImage/SDWebImageSVGCoder
     exactVersion: 1.7.0
+  URLRouting:
+    url: https://github.com/pointfreeco/swift-url-routing
+    revision: 09b155d
+
 
 targets:
   Coder Desktop:
@@ -290,6 +294,7 @@ targets:
       - package: GRPC
       - package: Subprocess
       - package: Semaphore
+      - package: URLRouting
       - target: CoderSDK
         embed: false
 
diff --git a/Makefile b/Makefile
index 115f6e89..a21b756b 100644
--- a/Makefile
+++ b/Makefile
@@ -121,6 +121,7 @@ test: $(addprefix $(PROJECT)/Resources/,$(MUTAGEN_RESOURCES)) $(XCPROJECT) ## Ru
 		-project $(XCPROJECT) \
 		-scheme $(SCHEME) \
 		-testPlan $(TEST_PLAN) \
+		-skipMacroValidation \
 		-skipPackagePluginValidation \
 		CODE_SIGNING_REQUIRED=NO \
 		CODE_SIGNING_ALLOWED=NO | xcbeautify
diff --git a/scripts/build.sh b/scripts/build.sh
index b1351da1..227f04ba 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -125,6 +125,7 @@ xcodebuild \
   -configuration "Release" \
   -archivePath "$ARCHIVE_PATH" \
   archive \
+  -skipMacroValidation \
   -skipPackagePluginValidation \
   CODE_SIGN_STYLE=Manual \
   CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" \

From 2198d3e4a0b56a76abcc8e182cd99641920aa4c5 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Mon, 12 May 2025 12:48:24 +1000
Subject: [PATCH 15/16] feat: send push notifications for invalid `coder`
 scheme URIs (#146)

Relates to #96.

<img width="380" alt="image" src="https://github.com/user-attachments/assets/36316e23-ba6e-40f0-ba4d-228dd9c31006" />
<img width="369" alt="image" src="https://github.com/user-attachments/assets/d9ff0c21-c4c7-485a-8767-ca1772ebecbf" />

I've had to replace the  **app** provisioning profile for this to build.
---
 .../Coder-Desktop/Coder_DesktopApp.swift      | 18 ++++++++++--
 .../Coder-Desktop/Notifications.swift         | 28 +++++++++++++++++++
 Coder-Desktop/project.yml                     |  1 +
 3 files changed, 44 insertions(+), 3 deletions(-)
 create mode 100644 Coder-Desktop/Coder-Desktop/Notifications.swift

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index 4d787355..e2fe3abb 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -1,8 +1,10 @@
 import FluidMenuBarExtra
 import NetworkExtension
+import os
 import SDWebImageSVGCoder
 import SDWebImageSwiftUI
 import SwiftUI
+import UserNotifications
 import VPNLib
 
 @main
@@ -36,13 +38,16 @@ struct DesktopApp: App {
 
 @MainActor
 class AppDelegate: NSObject, NSApplicationDelegate {
+    private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "app-delegate")
     private var menuBar: MenuBarController?
     let vpn: CoderVPNService
     let state: AppState
     let fileSyncDaemon: MutagenDaemon
     let urlHandler: URLHandler
+    let notifDelegate: NotifDelegate
 
     override init() {
+        notifDelegate = NotifDelegate()
         vpn = CoderVPNService()
         let state = AppState(onChange: vpn.configureTunnelProviderProtocol)
         vpn.onStart = {
@@ -67,6 +72,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         }
         self.fileSyncDaemon = fileSyncDaemon
         urlHandler = URLHandler(state: state, vpn: vpn)
+        // `delegate` is weak
+        UNUserNotificationCenter.current().delegate = notifDelegate
     }
 
     func applicationDidFinishLaunching(_: Notification) {
@@ -141,9 +148,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
             // We only accept one at time, for now
             return
         }
-        do { try urlHandler.handle(url) } catch {
-            // TODO: Push notification
-            print(error.description)
+        do { try urlHandler.handle(url) } catch let handleError {
+            Task {
+                do {
+                    try await sendNotification(title: "Failed to handle link", body: handleError.description)
+                } catch let notifError {
+                    logger.error("Failed to send notification (\(handleError.description)): \(notifError)")
+                }
+            }
         }
     }
 
diff --git a/Coder-Desktop/Coder-Desktop/Notifications.swift b/Coder-Desktop/Coder-Desktop/Notifications.swift
new file mode 100644
index 00000000..44a2afb8
--- /dev/null
+++ b/Coder-Desktop/Coder-Desktop/Notifications.swift
@@ -0,0 +1,28 @@
+import UserNotifications
+
+class NotifDelegate: NSObject, UNUserNotificationCenterDelegate {
+    override init() {
+        super.init()
+    }
+
+    // This function is required for notifications to appear as banners whilst the app is running.
+    // We're effectively forwarding the notification back to the OS
+    nonisolated func userNotificationCenter(
+        _: UNUserNotificationCenter,
+        willPresent _: UNNotification
+    ) async -> UNNotificationPresentationOptions {
+        [.banner]
+    }
+}
+
+func sendNotification(title: String, body: String) async throws {
+    let nc = UNUserNotificationCenter.current()
+    let granted = try await nc.requestAuthorization(options: [.alert, .badge])
+    guard granted else {
+        return
+    }
+    let content = UNMutableNotificationContent()
+    content.title = title
+    content.body = body
+    try await nc.add(.init(identifier: UUID().uuidString, content: content, trigger: nil))
+}
diff --git a/Coder-Desktop/project.yml b/Coder-Desktop/project.yml
index 9ec3ba44..f2c96fac 100644
--- a/Coder-Desktop/project.yml
+++ b/Coder-Desktop/project.yml
@@ -147,6 +147,7 @@ targets:
         com.apple.developer.system-extension.install: true
         com.apple.security.application-groups:
           - $(TeamIdentifierPrefix)com.coder.Coder-Desktop
+        aps-environment: development
     settings:
       base:
         ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # Sets the app icon to "AppIcon".

From 314edbe1d014aa1c6d904c8fb9efa1dee1055189 Mon Sep 17 00:00:00 2001
From: Ethan <39577870+ethanndickson@users.noreply.github.com>
Date: Mon, 12 May 2025 12:50:54 +1000
Subject: [PATCH 16/16] feat: support RDP-specific deep links (#147)

Closes #96.

If a `coder_app` exists on the workspace, where the URL is of the form:
```coder://dev.coder.com/v0/open/ws/<workspace>/agent/<agent>/rdp?username=administrator&password=password``` the URL will be parsed, validated, and an alert opened.
If `Open` is clicked on the alert, the password will be written to the clipboard, where it can be pasted when prompted.

https://github.com/user-attachments/assets/da8410c7-d656-4bf7-936a-8d465953e195

We're unable to avoid the entering of the password, as the `password` field in an `.rdp` file, even if encrypted properly, is ignored by the macOS Windows RDP app.

The app supports reading credentials from the macOS keychain, and whilst we could create keychain entries, they have to be associated with an RDP config in the app, and there's no way to automate the creation of that config, and then run that config.

Further reading:
https://stackoverflow.com/questions/48713606/how-to-create-rdp-file-on-mac-os-that-allows-auto-login
https://techcommunity.microsoft.com/discussions/azurevirtualdesktopforum/macos-remote-desktop-client-app---automatic-logon-no-credential-prompt/2596451

The above demo was done by adding this app to the template:

```
resource "coder_app" "connectrdp" {
  agent_id = coder_agent.main.id
  slug = "connectrdp"
  display_name = "Coder Connect RDP"
  url = "coder://dev.coder.com/v0/open/ws/${data.coder_workspace.me.name}/agent/main/rdp?username=Administrator&password=coderRDP!"
  icon = "/icon/terminal.svg"
  external = true
}
```
---
 .../Coder-Desktop/Coder_DesktopApp.swift      |  2 +-
 Coder-Desktop/Coder-Desktop/URLHandler.swift  | 64 +++++++++++++++++--
 .../Coder-Desktop/VPN/MenuState.swift         |  9 +++
 Coder-Desktop/VPNLib/CoderRouter.swift        | 26 ++++++++
 .../VPNLib/FileSync/FileSyncDaemon.swift      |  2 +
 scripts/build.sh                              |  2 +-
 6 files changed, 96 insertions(+), 9 deletions(-)

diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
index e2fe3abb..307e0797 100644
--- a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
+++ b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
@@ -85,7 +85,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
             image: "MenuBarIcon",
             onAppear: {
                 // If the VPN is enabled, it's likely the token isn't expired
-                guard case .disabled = self.vpn.state, self.state.hasSession else { return }
+                guard self.vpn.state != .connected, self.state.hasSession else { return }
                 Task { @MainActor in
                     await self.state.handleTokenExpiry()
                 }
diff --git a/Coder-Desktop/Coder-Desktop/URLHandler.swift b/Coder-Desktop/Coder-Desktop/URLHandler.swift
index 191c19d9..0dbc9248 100644
--- a/Coder-Desktop/Coder-Desktop/URLHandler.swift
+++ b/Coder-Desktop/Coder-Desktop/URLHandler.swift
@@ -1,4 +1,5 @@
 import Foundation
+import SwiftUI
 import VPNLib
 
 @MainActor
@@ -20,20 +21,69 @@ class URLHandler {
         guard deployment.host() == url.host else {
             throw .invalidAuthority(url.host() ?? "<none>")
         }
+        let route: CoderRoute
         do {
-            switch try router.match(url: url) {
-            case let .open(workspace, agent, type):
+            route = try router.match(url: url)
+        } catch {
+            throw .matchError(url: url)
+        }
+
+        switch route {
+        case let .open(workspace, agent, type):
+            do {
                 switch type {
                 case let .rdp(creds):
-                    handleRDP(workspace: workspace, agent: agent, creds: creds)
+                    try handleRDP(workspace: workspace, agent: agent, creds: creds)
                 }
+            } catch {
+                throw .openError(error)
             }
-        } catch {
-            throw .matchError(url: url)
+        }
+    }
+
+    private func handleRDP(workspace: String, agent: String, creds: RDPCredentials) throws(OpenError) {
+        guard vpn.state == .connected else {
+            throw .coderConnectOffline
+        }
+
+        guard let workspace = vpn.menuState.findWorkspace(name: workspace) else {
+            throw .invalidWorkspace(workspace: workspace)
+        }
+
+        guard let agent = vpn.menuState.findAgent(workspaceID: workspace.id, name: agent) else {
+            throw .invalidAgent(workspace: workspace.name, agent: agent)
+        }
+
+        var rdpString = "rdp:full address=s:\(agent.primaryHost):3389"
+        if let username = creds.username {
+            rdpString += "&username=s:\(username)"
+        }
+        guard let url = URL(string: rdpString) else {
+            throw .couldNotCreateRDPURL(rdpString)
         }
 
-        func handleRDP(workspace _: String, agent _: String, creds _: RDPCredentials) {
-            // TODO: Handle RDP
+        let alert = NSAlert()
+        alert.messageText = "Opening RDP"
+        alert.informativeText = "Connecting to \(agent.primaryHost)."
+        if let username = creds.username {
+            alert.informativeText += "\nUsername: \(username)"
+        }
+        if creds.password != nil {
+            alert.informativeText += "\nThe password will be copied to your clipboard."
+        }
+
+        alert.alertStyle = .informational
+        alert.addButton(withTitle: "Open")
+        alert.addButton(withTitle: "Cancel")
+        let response = alert.runModal()
+        if response == .alertFirstButtonReturn {
+            if let password = creds.password {
+                NSPasteboard.general.clearContents()
+                NSPasteboard.general.setString(password, forType: .string)
+            }
+            NSWorkspace.shared.open(url)
+        } else {
+            // User cancelled
         }
     }
 }
diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
index 59dfae08..c989c1d7 100644
--- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
+++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
@@ -58,6 +58,15 @@ struct VPNMenuState {
     // or have any invalid UUIDs.
     var invalidAgents: [Vpn_Agent] = []
 
+    public func findAgent(workspaceID: UUID, name: String) -> Agent? {
+        agents.first(where: { $0.value.wsID == workspaceID && $0.value.name == name })?.value
+    }
+
+    public func findWorkspace(name: String) -> Workspace? {
+        workspaces
+            .first(where: { $0.value.name == name })?.value
+    }
+
     mutating func upsertAgent(_ agent: Vpn_Agent) {
         guard
             let id = UUID(uuidData: agent.id),
diff --git a/Coder-Desktop/VPNLib/CoderRouter.swift b/Coder-Desktop/VPNLib/CoderRouter.swift
index de849e7c..d562e39e 100644
--- a/Coder-Desktop/VPNLib/CoderRouter.swift
+++ b/Coder-Desktop/VPNLib/CoderRouter.swift
@@ -2,6 +2,7 @@ import Foundation
 import URLRouting
 
 // This is in VPNLib to avoid depending on `swift-collections` in both the app & extension.
+// https://github.com/coder/coder-desktop-macos/issues/149
 public struct CoderRouter: ParserPrinter {
     public init() {}
 
@@ -33,6 +34,7 @@ public enum RouterError: Error {
     case invalidAuthority(String)
     case matchError(url: URL)
     case noSession
+    case openError(OpenError)
 
     public var description: String {
         switch self {
@@ -42,6 +44,30 @@ public enum RouterError: Error {
             "Failed to handle \(url.absoluteString) because the format is unsupported."
         case .noSession:
             "Not logged in."
+        case let .openError(error):
+            error.description
+        }
+    }
+
+    public var localizedDescription: String { description }
+}
+
+public enum OpenError: Error {
+    case invalidWorkspace(workspace: String)
+    case invalidAgent(workspace: String, agent: String)
+    case coderConnectOffline
+    case couldNotCreateRDPURL(String)
+
+    public var description: String {
+        switch self {
+        case let .invalidWorkspace(ws):
+            "Could not find workspace '\(ws)'. Does it exist?"
+        case .coderConnectOffline:
+            "Coder Connect must be running."
+        case let .invalidAgent(workspace: workspace, agent: agent):
+            "Could not find agent '\(agent)' in workspace '\(workspace)'. Is the workspace running?"
+        case let .couldNotCreateRDPURL(rdpString):
+            "Could not construct RDP URL from '\(rdpString)'."
         }
     }
 
diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
index 01e1d6ba..98807e3a 100644
--- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
+++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
@@ -24,6 +24,8 @@ public protocol FileSyncDaemon: ObservableObject {
     func resetSessions(ids: [String]) async throws(DaemonError)
 }
 
+// File Sync related code is in VPNLib to workaround a linking issue
+// https://github.com/coder/coder-desktop-macos/issues/149
 @MainActor
 public class MutagenDaemon: FileSyncDaemon {
     let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen")
diff --git a/scripts/build.sh b/scripts/build.sh
index 227f04ba..de6f34aa 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -131,7 +131,7 @@ xcodebuild \
   CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" \
   CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO \
   CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION=YES \
-  OTHER_CODE_SIGN_FLAGS='--timestamp' | LC_ALL="en_US.UTF-8" xcpretty
+  OTHER_CODE_SIGN_FLAGS='--timestamp' | xcbeautify
 
 # Create exportOptions.plist
 EXPORT_OPTIONS_PATH="./build/exportOptions.plist"
