From d63377481fb1ae7fb85a665e4fb46aa9f83cf41f Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:21:22 +0000 Subject: [PATCH 1/3] fix: match workspace names case-insensitively when handling deep links --- Coder-Desktop/Coder-Desktop/VPN/MenuState.swift | 5 ++++- .../Coder-DesktopTests/VPNMenuStateTests.swift | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index d13be3c6..da567a2e 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -160,7 +160,10 @@ struct VPNMenuState { } public func findWorkspace(name: String) -> Workspace? { - workspaces + // Workspace names are stored lowercased (see `upsertWorkspace`), but + // callers like deep links may pass the name with its original casing. + let name = name.lowercased() + return workspaces .first(where: { $0.value.name == name })?.value } diff --git a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift index dbd61a93..48d7ef38 100644 --- a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift @@ -218,6 +218,20 @@ struct VPNMenuStateTests { #expect(storedAgentFromSort.statusString.contains("Last handshake: 3 minutes ago")) } + @Test + mutating func testFindWorkspace_caseInsensitive() async throws { + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "My-Workspace" }) + + let lower = try #require(state.findWorkspace(name: "my-workspace")) + #expect(lower.id == workspaceID) + let original = try #require(state.findWorkspace(name: "My-Workspace")) + #expect(original.id == workspaceID) + let upper = try #require(state.findWorkspace(name: "MY-WORKSPACE")) + #expect(upper.id == workspaceID) + #expect(state.findWorkspace(name: "other-workspace") == nil) + } + @Test mutating func testUpsertAgent_invalidAgent_noUUID() async throws { let agent = Vpn_Agent.with { From 6a2dd2c7692b95dc6ea05b3151066d1c48d6775e Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 21 Oct 2026 02:16:31 +0000 Subject: [PATCH 2/3] fix: compare workspace and agent names case-insensitively Store workspace names as the server sends them and lowercase only where the offline-workspace hostname is built. Workspace and agent lookups, and the previous-build agent dedupe, now ignore case. --- .../Coder-Desktop/VPN/MenuState.swift | 14 +++----- .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 2 +- .../VPNMenuStateTests.swift | 32 +++++++++++-------- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index da567a2e..26d7efc3 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -156,15 +156,11 @@ struct VPNMenuState { var invalidAgents: [Vpn_Agent] = [] public func findAgent(workspaceID: UUID, name: String) -> Agent? { - agents.first(where: { $0.value.wsID == workspaceID && $0.value.name == name })?.value + agents.values.first { $0.wsID == workspaceID && $0.name.caseInsensitiveCompare(name) == .orderedSame } } public func findWorkspace(name: String) -> Workspace? { - // Workspace names are stored lowercased (see `upsertWorkspace`), but - // callers like deep links may pass the name with its original casing. - let name = name.lowercased() - return workspaces - .first(where: { $0.value.name == name })?.value + workspaces.values.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } } mutating func upsertAgent(_ agent: Vpn_Agent) { @@ -182,7 +178,7 @@ struct VPNMenuState { // 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 } + agents.filter { $0.value.name.caseInsensitiveCompare(agent.name) == .orderedSame && $0.value.wsID == wsID } .forEach { agents[$0.key] = nil } workspace.agents.insert(id) workspaces[wsID] = workspace @@ -229,9 +225,7 @@ struct VPNMenuState { mutating func upsertWorkspace(_ workspace: Vpn_Workspace) { guard let wsID = UUID(uuidData: workspace.id) else { return } - // Workspace names are unique & case-insensitive, and we want to show offline workspaces - // with a valid hostname (lowercase). - workspaces[wsID] = Workspace(id: wsID, name: workspace.name.lowercased(), agents: []) + workspaces[wsID] = Workspace(id: wsID, name: workspace.name, agents: []) // Check if we can associate any invalid agents with this workspace invalidAgents.filter { agent in agent.workspaceID == workspace.id diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index 71e753d2..c598889d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -45,7 +45,7 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable { func primaryHost(hostnameSuffix: String) -> String { switch self { case let .agent(agent): agent.primaryHost - case .offlineWorkspace: "\(wsName).\(hostnameSuffix)" + case .offlineWorkspace: "\(wsName.lowercased()).\(hostnameSuffix)" } } diff --git a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift index 48d7ef38..7f4053a9 100644 --- a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift @@ -162,7 +162,7 @@ struct VPNMenuStateTests { let newAgent = Vpn_Agent.with { $0.id = newAgentID.uuidData $0.workspaceID = workspaceID.uuidData - $0.name = "agent1" // Same name as old agent + $0.name = "Agent1" // Same name as old agent, different case $0.lastHandshake = .init(date: Date.now) $0.lastPing = .with { $0.latency = .init(floatLiteral: 0.05) @@ -174,7 +174,7 @@ struct VPNMenuStateTests { #expect(state.agents[oldAgentID] == nil) let storedAgent = try #require(state.agents[newAgentID]) - #expect(storedAgent.name == "agent1") + #expect(storedAgent.name == "Agent1") #expect(storedAgent.wsID == workspaceID) #expect(storedAgent.primaryHost == "foo.coder") #expect(storedAgent.status == .okay) @@ -183,15 +183,16 @@ struct VPNMenuStateTests { @Test mutating func testUpsertWorkspace_addsOfflineWorkspace() async throws { let workspaceID = UUID() - state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "Foo" }) let storedWorkspace = try #require(state.workspaces[workspaceID]) - #expect(storedWorkspace.name == "foo") + #expect(storedWorkspace.name == "Foo") var output = state.sorted #expect(output.count == 1) #expect(output[0].id == workspaceID) - #expect(output[0].wsName == "foo") + #expect(output[0].wsName == "Foo") + #expect(output[0].primaryHost(hostnameSuffix: "coder") == "foo.coder") let agentID = UUID() let agent = Vpn_Agent.with { @@ -210,7 +211,7 @@ struct VPNMenuStateTests { output = state.sorted #expect(output.count == 1) #expect(output[0].id == agentID) - #expect(output[0].wsName == "foo") + #expect(output[0].wsName == "Foo") #expect(output[0].status == .okay) let storedAgentFromSort = try #require(state.agents[agentID]) #expect(storedAgentFromSort.statusString.contains("You're connected through a DERP relay.")) @@ -219,17 +220,20 @@ struct VPNMenuStateTests { } @Test - mutating func testFindWorkspace_caseInsensitive() async throws { + mutating func testFind_ignoresNameCase() async throws { let workspaceID = UUID() + let agentID = UUID() state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "My-Workspace" }) + state.upsertAgent(Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "Main" + $0.fqdn = ["main.my-workspace.me.coder"] + }) - let lower = try #require(state.findWorkspace(name: "my-workspace")) - #expect(lower.id == workspaceID) - let original = try #require(state.findWorkspace(name: "My-Workspace")) - #expect(original.id == workspaceID) - let upper = try #require(state.findWorkspace(name: "MY-WORKSPACE")) - #expect(upper.id == workspaceID) - #expect(state.findWorkspace(name: "other-workspace") == nil) + #expect(state.findWorkspace(name: "MY-WORKSPACE")?.id == workspaceID) + #expect(state.findAgent(workspaceID: workspaceID, name: "MAIN")?.id == agentID) + #expect(state.findWorkspace(name: "other") == nil) } @Test From 605630092effdeb577897b633e8c4192a533af8d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 21 Oct 2026 02:40:15 +0000 Subject: [PATCH 3/3] fix: derive offline workspaces from the agents map Workspace.agents duplicated the workspace IDs in the agents map and could go stale. The previous-build cleanup in upsertAgent never removed the old agent's ID, so deleting the replacement agent left the workspace with neither an agent row nor an offline row. upsertWorkspace also reset the set whilst agents were still connected, which showed an offline row next to the agent row. sorted now shows a workspace as offline when no agent in the agents map belongs to it. --- .../Coder-Desktop/VPN/MenuState.swift | 19 ++++++------------- .../Coder-DesktopTests/AgentsTests.swift | 2 +- .../VPNMenuStateTests.swift | 6 ++++++ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift index 26d7efc3..83ff709a 100644 --- a/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift +++ b/Coder-Desktop/Coder-Desktop/VPN/MenuState.swift @@ -141,7 +141,6 @@ enum AgentStatus: Int, Equatable, Comparable { struct Workspace: Identifiable, Equatable, Comparable { let id: UUID let name: String - var agents: Set static func < (lhs: Workspace, rhs: Workspace) -> Bool { lhs.name.localizedCompare(rhs.name) == .orderedAscending @@ -167,7 +166,7 @@ struct VPNMenuState { guard let id = UUID(uuidData: agent.id), let wsID = UUID(uuidData: agent.workspaceID), - var workspace = workspaces[wsID], + let workspace = workspaces[wsID], !agent.fqdn.isEmpty else { invalidAgents.append(agent) @@ -180,8 +179,6 @@ struct VPNMenuState { // is from a previous workspace build, and should be removed. agents.filter { $0.value.name.caseInsensitiveCompare(agent.name) == .orderedSame && $0.value.wsID == wsID } .forEach { agents[$0.key] = nil } - workspace.agents.insert(id) - workspaces[wsID] = workspace var lastPing: LastPing? if agent.hasLastPing { @@ -211,11 +208,6 @@ struct VPNMenuState { mutating func deleteAgent(withId id: Data) { guard let agentUUID = UUID(uuidData: id) else { return } - // Update Workspaces - if let agent = agents[agentUUID], var ws = workspaces[agent.wsID] { - ws.agents.remove(agentUUID) - workspaces[agent.wsID] = ws - } agents[agentUUID] = nil // Remove from invalid agents if present invalidAgents.removeAll { invalidAgent in @@ -225,7 +217,7 @@ struct VPNMenuState { mutating func upsertWorkspace(_ workspace: Vpn_Workspace) { guard let wsID = UUID(uuidData: workspace.id) else { return } - workspaces[wsID] = Workspace(id: wsID, name: workspace.name, agents: []) + workspaces[wsID] = Workspace(id: wsID, name: workspace.name) // Check if we can associate any invalid agents with this workspace invalidAgents.filter { agent in agent.workspaceID == workspace.id @@ -248,9 +240,10 @@ struct VPNMenuState { var sorted: [VPNMenuItem] { var items = agents.values.map { VPNMenuItem.agent($0) } // Workspaces with no agents are shown as offline - items += workspaces.filter { _, value in - value.agents.isEmpty - }.map { VPNMenuItem.offlineWorkspace(Workspace(id: $0.key, name: $0.value.name, agents: $0.value.agents)) } + let workspacesWithAgents = Set(agents.values.map(\.wsID)) + items += workspaces.values + .filter { !workspacesWithAgents.contains($0.id) } + .map { VPNMenuItem.offlineWorkspace($0) } return items.sorted() } diff --git a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift index 8f84ab3d..fae433ca 100644 --- a/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/AgentsTests.swift @@ -109,7 +109,7 @@ struct AgentsTests { vpn.state = .connected vpn.menuState = .init( agents: createMockAgents(count: Theme.defaultVisibleAgents - 1), - workspaces: [UUID(): Workspace(id: UUID(), name: "offline", agents: .init())] + workspaces: [UUID(): Workspace(id: UUID(), name: "offline")] ) try await ViewHosting.host(view) { diff --git a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift index 7f4053a9..dd9acf52 100644 --- a/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift @@ -178,6 +178,9 @@ struct VPNMenuStateTests { #expect(storedAgent.wsID == workspaceID) #expect(storedAgent.primaryHost == "foo.coder") #expect(storedAgent.status == .okay) + + state.deleteAgent(withId: newAgentID.uuidData) + #expect(state.sorted.map(\.id) == [workspaceID]) } @Test @@ -217,6 +220,9 @@ struct VPNMenuStateTests { #expect(storedAgentFromSort.statusString.contains("You're connected through a DERP relay.")) #expect(storedAgentFromSort.statusString.contains("Total latency: 50.00 ms")) #expect(storedAgentFromSort.statusString.contains("Last handshake: 3 minutes ago")) + + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "Foo" }) + #expect(state.sorted.map(\.id) == [agentID]) } @Test