diff --git a/Coder Desktop/Coder Desktop/About.swift b/Coder Desktop/Coder Desktop/About.swift index 3771175..8849c9b 100644 --- a/Coder Desktop/Coder Desktop/About.swift +++ b/Coder Desktop/Coder Desktop/About.swift @@ -1,6 +1,7 @@ import SwiftUI enum About { + public static let repo: String = "https://github.com/coder/coder-desktop-macos" private static var credits: NSAttributedString { let coder = NSMutableAttributedString( string: "Coder.com", @@ -21,7 +22,7 @@ enum About { string: "GitHub", attributes: [ .foregroundColor: NSColor.labelColor, - .link: NSURL(string: "https://github.com/coder/coder-desktop-macos")!, + .link: NSURL(string: About.repo)!, .font: NSFont.systemFont(ofSize: NSFont.systemFontSize), ] ) diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift index 5e66eb7..4faa10f 100644 --- a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift @@ -3,29 +3,29 @@ import SwiftUI @MainActor final class PreviewVPN: Coder_Desktop.VPNService { - @Published var state: Coder_Desktop.VPNServiceState = .disabled - @Published var agents: [UUID: Coder_Desktop.Agent] = [ - UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2", + @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()), - UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder", + UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"], wsName: "testing-a-very-long-name", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc", + UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor", + UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example", + UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2", + UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder", + UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"], wsName: "testing-a-very-long-name", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc", + UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor", + UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor", wsID: UUID()), - UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example", + UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example", wsID: UUID()), - ] + ], 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/VPNMenuState.swift b/Coder Desktop/Coder Desktop/VPNMenuState.swift new file mode 100644 index 0000000..e1a91a0 --- /dev/null +++ b/Coder Desktop/Coder Desktop/VPNMenuState.swift @@ -0,0 +1,140 @@ +import Foundation +import SwiftUI +import VPNLib + +struct Agent: Identifiable, Equatable, Comparable { + let id: UUID + let name: String + let status: AgentStatus + let hosts: [String] + let wsName: String + let wsID: UUID + + // Agents are sorted by status, and then by name + static func < (lhs: Agent, rhs: Agent) -> Bool { + if lhs.status != rhs.status { + return lhs.status < rhs.status + } + return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending + } + + // Hosts arrive sorted by length, the shortest looks best in the UI. + var primaryHost: String? { hosts.first } +} + +enum AgentStatus: Int, Equatable, Comparable { + case okay = 0 + case warn = 1 + case error = 2 + case off = 3 + + public var color: Color { + switch self { + case .okay: .green + case .warn: .yellow + case .error: .red + case .off: .gray + } + } + + static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +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 + } +} + +struct VPNMenuState { + var agents: [UUID: Agent] = [:] + var workspaces: [UUID: Workspace] = [:] + // Upserted agents that don't belong to any known workspace, have no FQDNs, + // or have any invalid UUIDs. + var invalidAgents: [Vpn_Agent] = [] + + mutating func upsertAgent(_ agent: Vpn_Agent) { + guard + let id = UUID(uuidData: agent.id), + let wsID = UUID(uuidData: agent.workspaceID), + var workspace = workspaces[wsID], + !agent.fqdn.isEmpty + else { + invalidAgents.append(agent) + return + } + // 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 } + .forEach { agents[$0.key] = nil } + workspace.agents.insert(id) + workspaces[wsID] = workspace + + agents[id] = Agent( + id: id, + 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 }, + wsName: workspace.name, + wsID: wsID + ) + } + + 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 + invalidAgent.id == id + } + } + + mutating func upsertWorkspace(_ workspace: Vpn_Workspace) { + guard let wsID = UUID(uuidData: workspace.id) else { return } + 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 + }.forEach { agent in + invalidAgents.removeAll { $0 == agent } + upsertAgent(agent) + } + } + + mutating func deleteWorkspace(withId id: Data) { + guard let wsID = UUID(uuidData: id) else { return } + agents.filter { _, value in + value.wsID == wsID + }.forEach { key, _ in + agents[key] = nil + } + workspaces[wsID] = nil + } + + 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)) } + return items.sorted() + } + + mutating func clear() { + agents.removeAll() + workspaces.removeAll() + } +} diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index 9d8abb8..95aff95 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -6,7 +6,7 @@ import VPNLib @MainActor protocol VPNService: ObservableObject { var state: VPNServiceState { get } - var agents: [UUID: Agent] { get } + var menuState: VPNMenuState { get } func start() async func stop() async func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?) @@ -41,7 +41,6 @@ enum VPNServiceError: Error, Equatable { final class CoderVPNService: NSObject, VPNService { var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn") lazy var xpc: VPNXPCInterface = .init(vpn: self) - var workspaces: [UUID: String] = [:] @Published var tunnelState: VPNServiceState = .disabled @Published var sysExtnState: SystemExtensionState = .uninstalled @@ -56,7 +55,7 @@ final class CoderVPNService: NSObject, VPNService { return tunnelState } - @Published var agents: [UUID: Agent] = [:] + @Published var menuState: VPNMenuState = .init() // 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 @@ -85,11 +84,6 @@ final class CoderVPNService: NSObject, VPNService { NotificationCenter.default.removeObserver(self) } - func clearPeers() { - agents = [:] - workspaces = [:] - } - func start() async { switch tunnelState { case .disabled, .failed: @@ -150,7 +144,7 @@ final class CoderVPNService: NSObject, VPNService { do { let msg = try Vpn_PeerUpdate(serializedBytes: data) debugPrint(msg) - clearPeers() + menuState.clear() applyPeerUpdate(with: msg) } catch { logger.error("failed to decode peer update \(error)") @@ -159,53 +153,11 @@ final class CoderVPNService: NSObject, VPNService { func applyPeerUpdate(with update: Vpn_PeerUpdate) { // Delete agents - update.deletedAgents - .compactMap { UUID(uuidData: $0.id) } - .forEach { agentID in - agents[agentID] = nil - } - update.deletedWorkspaces - .compactMap { UUID(uuidData: $0.id) } - .forEach { workspaceID in - workspaces[workspaceID] = nil - for (id, agent) in agents where agent.wsID == workspaceID { - agents[id] = nil - } - } - - // Update workspaces - for workspaceProto in update.upsertedWorkspaces { - if let workspaceID = UUID(uuidData: workspaceProto.id) { - workspaces[workspaceID] = workspaceProto.name - } - } - - for agentProto in update.upsertedAgents { - guard let agentID = UUID(uuidData: agentProto.id) else { - continue - } - guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else { - continue - } - let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace" - let newAgent = Agent( - id: agentID, - name: agentProto.name, - // If last handshake was not within last five minutes, the agent is unhealthy - status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off, - copyableDNS: agentProto.fqdn.first ?? "UNKNOWN", - wsName: workspaceName, - wsID: workspaceID - ) - - // 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 == agentProto.name && $0.value.wsID == workspaceID } - .forEach { agents[$0.key] = nil } - - agents[agentID] = newAgent - } + update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) } + update.deletedWorkspaces.forEach { menuState.deleteWorkspace(withId: $0.id) } + // Upsert workspaces before agents to populate agent workspace names + update.upsertedWorkspaces.forEach { menuState.upsertWorkspace($0) } + update.upsertedAgents.forEach { menuState.upsertAgent($0) } } } diff --git a/Coder Desktop/Coder Desktop/Views/Agent.swift b/Coder Desktop/Coder Desktop/Views/Agent.swift deleted file mode 100644 index a24a5f7..0000000 --- a/Coder Desktop/Coder Desktop/Views/Agent.swift +++ /dev/null @@ -1,99 +0,0 @@ -import SwiftUI - -struct Agent: Identifiable, Equatable, Comparable { - let id: UUID - let name: String - let status: AgentStatus - let copyableDNS: String - let wsName: String - let wsID: UUID - - // Agents are sorted by status, and then by name - static func < (lhs: Agent, rhs: Agent) -> Bool { - if lhs.status != rhs.status { - return lhs.status < rhs.status - } - return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending - } -} - -enum AgentStatus: Int, Equatable, Comparable { - case okay = 0 - case warn = 1 - case error = 2 - case off = 3 - - public var color: Color { - switch self { - case .okay: .green - case .warn: .yellow - case .error: .red - case .off: .gray - } - } - - static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool { - lhs.rawValue < rhs.rawValue - } -} - -struct AgentRowView: View { - let agent: Agent - let baseAccessURL: URL - @State private var nameIsSelected: Bool = false - @State private var copyIsSelected: Bool = false - - private var fmtWsName: AttributedString { - var formattedName = AttributedString(agent.wsName) - formattedName.foregroundColor = .primary - var coderPart = AttributedString(".coder") - coderPart.foregroundColor = .gray - formattedName.append(coderPart) - return formattedName - } - - private var wsURL: URL { - // TODO: CoderVPN currently only supports owned workspaces - baseAccessURL.appending(path: "@me").appending(path: agent.wsName) - } - - var body: some View { - HStack(spacing: 0) { - Link(destination: wsURL) { - HStack(spacing: Theme.Size.trayPadding) { - ZStack { - Circle() - .fill(agent.status.color.opacity(0.4)) - .frame(width: 12, height: 12) - Circle() - .fill(agent.status.color.opacity(1.0)) - .frame(width: 7, height: 7) - } - Text(fmtWsName).lineLimit(1).truncationMode(.tail) - Spacer() - }.padding(.horizontal, Theme.Size.trayPadding) - .frame(minHeight: 22) - .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(nameIsSelected ? Color.white : .primary) - .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear) - .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) - .onHover { hovering in nameIsSelected = hovering } - Spacer() - }.buttonStyle(.plain) - Button { - // TODO: Proper clipboard abstraction - NSPasteboard.general.setString(agent.copyableDNS, forType: .string) - } label: { - Image(systemName: "doc.on.doc") - .symbolVariant(.fill) - .padding(3) - }.foregroundStyle(copyIsSelected ? Color.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) - } - } -} diff --git a/Coder Desktop/Coder Desktop/Views/Agents.swift b/Coder Desktop/Coder Desktop/Views/Agents.swift index 949ab10..53c0441 100644 --- a/Coder Desktop/Coder Desktop/Views/Agents.swift +++ b/Coder Desktop/Coder Desktop/Views/Agents.swift @@ -12,15 +12,23 @@ struct Agents: View { Group { // Agents List if vpn.state == .connected { - let sortedAgents = vpn.agents.values.sorted() - let visibleData = viewAll ? sortedAgents[...] : sortedAgents.prefix(defaultVisibleRows) - ForEach(visibleData, id: \.id) { agent in - AgentRowView(agent: agent, baseAccessURL: session.baseAccessURL!) + let items = vpn.menuState.sorted + let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows) + ForEach(visibleItems, id: \.id) { agent in + MenuItemView(item: agent, baseAccessURL: session.baseAccessURL!) .padding(.horizontal, Theme.Size.trayMargin) } - if vpn.agents.count > defaultVisibleRows { + if items.count == 0 { + Text("No workspaces!") + .font(.body) + .foregroundColor(.gray) + .padding(.horizontal, Theme.Size.trayInset) + .padding(.top, 2) + } + // Only show the toggle if there are more items to show + if items.count > defaultVisibleRows { Toggle(isOn: $viewAll) { - Text(viewAll ? "Show Less" : "Show All") + Text(viewAll ? "Show less" : "Show all") .font(.headline) .foregroundColor(.gray) .padding(.horizontal, Theme.Size.trayInset) diff --git a/Coder Desktop/Coder Desktop/Views/ButtonRow.swift b/Coder Desktop/Coder Desktop/Views/ButtonRow.swift index 088eb13..9dbb916 100644 --- a/Coder Desktop/Coder Desktop/Views/ButtonRow.swift +++ b/Coder Desktop/Coder Desktop/Views/ButtonRow.swift @@ -1,6 +1,13 @@ import SwiftUI struct ButtonRowView: View { + init(highlightColor: Color = .accentColor, isSelected: Bool = false, label: @escaping () -> Label) { + self.highlightColor = highlightColor + self.isSelected = isSelected + self.label = label + } + + let highlightColor: Color @State private var isSelected: Bool = false @ViewBuilder var label: () -> Label @@ -12,8 +19,8 @@ struct ButtonRowView: View { .padding(.horizontal, Theme.Size.trayPadding) .frame(minHeight: 22) .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(isSelected ? Color.white : .primary) - .background(isSelected ? Color.accentColor.opacity(0.8) : .clear) + .foregroundStyle(isSelected ? .white : .primary) + .background(isSelected ? highlightColor.opacity(0.8) : .clear) .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) .onHover { hovering in isSelected = hovering } } diff --git a/Coder Desktop/Coder Desktop/Views/InvalidAgents.swift b/Coder Desktop/Coder Desktop/Views/InvalidAgents.swift new file mode 100644 index 0000000..9e27fa5 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/InvalidAgents.swift @@ -0,0 +1,56 @@ +import SwiftUI +import VPNLib + +struct InvalidAgentsButton: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject var vpn: VPN + var msg: String { + "\(vpn.menuState.invalidAgents.count) invalid \(vpn.menuState.invalidAgents.count > 1 ? "agents" : "agent").." + } + + var body: some View { + Button { + showAlert() + } label: { + ButtonRowView(highlightColor: .red) { Text(msg) } + }.buttonStyle(.plain) + } + + // `.alert` from SwiftUI doesn't play nice when the calling view is in the + // menu bar. + private func showAlert() { + let formattedAgents = vpn.menuState.invalidAgents.map { agent in + let agent_id = if let agent_id = UUID(uuidData: agent.id) { + agent_id.uuidString + } else { + "Invalid ID: \(agent.id.base64EncodedString())" + } + let wsID = if let wsID = UUID(uuidData: agent.workspaceID) { + wsID.uuidString + } else { + "Invalid ID: \(agent.workspaceID.base64EncodedString())" + } + let lastHandshake = agent.hasLastHandshake ? "\(agent.lastHandshake)" : "Never" + return """ + Agent Name: \(agent.name) + ID: \(agent_id) + Workspace ID: \(wsID) + Last Handshake: \(lastHandshake) + FQDNs: \(agent.fqdn) + Addresses: \(agent.ipAddrs) + """ + }.joined(separator: "\n\n") + + let alert = NSAlert() + alert.messageText = "Invalid Agents" + alert.informativeText = """ + Coder Desktop received invalid agents from the VPN. This should + never happen. Please open an issue on \(About.repo). + + \(formattedAgents) + """ + alert.alertStyle = .warning + dismiss() + alert.runModal() + } +} diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift index 3f253e1..99a4802 100644 --- a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift +++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift @@ -28,7 +28,7 @@ struct VPNMenu: View { .disabled(vpnDisabled) } Divider() - Text("Workspace Agents") + Text("Workspaces") .font(.headline) .foregroundColor(.gray) VPNState() @@ -37,11 +37,13 @@ struct VPNMenu: View { // Trailing stack VStack(alignment: .leading, spacing: 3) { TrayDivider() + if vpn.state == .connected, !vpn.menuState.invalidAgents.isEmpty { + InvalidAgentsButton() + } if session.hasSession { Link(destination: session.baseAccessURL!.appending(path: "templates")) { ButtonRowView { Text("Create workspace") - EmptyView() } }.buttonStyle(.plain) TrayDivider() diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenuItem.swift b/Coder Desktop/Coder Desktop/Views/VPNMenuItem.swift new file mode 100644 index 0000000..43aac47 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/VPNMenuItem.swift @@ -0,0 +1,110 @@ +import SwiftUI + +// Each row in the workspaces list is an agent or an offline workspace +enum VPNMenuItem: Equatable, Comparable, Identifiable { + case agent(Agent) + case offlineWorkspace(Workspace) + + var wsName: String { + switch self { + case let .agent(agent): agent.wsName + case let .offlineWorkspace(workspace): workspace.name + } + } + + var status: AgentStatus { + switch self { + case let .agent(agent): agent.status + case .offlineWorkspace: .off + } + } + + var id: UUID { + switch self { + case let .agent(agent): agent.id + case let .offlineWorkspace(workspace): workspace.id + } + } + + static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool { + switch (lhs, rhs) { + case let (.agent(lhsAgent), .agent(rhsAgent)): + lhsAgent < rhsAgent + case let (.offlineWorkspace(lhsWorkspace), .offlineWorkspace(rhsWorkspace)): + lhsWorkspace < rhsWorkspace + // Agents always appear before offline workspaces + case (.offlineWorkspace, .agent): + false + case (.agent, .offlineWorkspace): + true + } + } +} + +struct MenuItemView: View { + let item: VPNMenuItem + let baseAccessURL: URL + @State private var nameIsSelected: Bool = false + @State private var copyIsSelected: Bool = false + + private var itemName: AttributedString { + let name = switch item { + case let .agent(agent): agent.primaryHost ?? "\(item.wsName).coder" + case .offlineWorkspace: "\(item.wsName).coder" + } + + var formattedName = AttributedString(name) + formattedName.foregroundColor = .primary + if let range = formattedName.range(of: ".coder") { + formattedName[range].foregroundColor = .gray + } + return formattedName + } + + private var wsURL: URL { + // TODO: CoderVPN currently only supports owned workspaces + baseAccessURL.appending(path: "@me").appending(path: item.wsName) + } + + var body: some View { + HStack(spacing: 0) { + Link(destination: wsURL) { + HStack(spacing: Theme.Size.trayPadding) { + ZStack { + Circle() + .fill(item.status.color.opacity(0.4)) + .frame(width: 12, height: 12) + Circle() + .fill(item.status.color.opacity(1.0)) + .frame(width: 7, height: 7) + } + 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)) + .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) + }.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) + } + } + } +} diff --git a/Coder Desktop/Coder Desktop/Views/VPNState.swift b/Coder Desktop/Coder Desktop/Views/VPNState.swift index 4afc6c2..b7a090b 100644 --- a/Coder Desktop/Coder Desktop/Views/VPNState.swift +++ b/Coder Desktop/Coder Desktop/Views/VPNState.swift @@ -18,7 +18,7 @@ struct VPNState: View { .font(.body) .foregroundColor(.gray) case (.disabled, _): - Text("Enable CoderVPN to see agents") + Text("Enable CoderVPN to see workspaces") .font(.body) .foregroundStyle(.gray) case (.connecting, _), (.disconnecting, _): diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift index 8e06c8d..b460b1f 100644 --- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -18,14 +18,14 @@ struct AgentsTests { view = sut.environmentObject(vpn).environmentObject(session) } - private func createMockAgents(count: Int) -> [UUID: Agent] { + private func createMockAgents(count: Int, status: AgentStatus = .okay) -> [UUID: Agent] { Dictionary(uniqueKeysWithValues: (1 ... count).map { let agent = Agent( id: UUID(), name: "dev", - status: .okay, - copyableDNS: "a\($0).example.com", - wsName: "a\($0)", + status: status, + hosts: ["a\($0).coder"], + wsName: "ws\($0)", wsID: UUID() ) return (agent.id, agent) @@ -41,10 +41,21 @@ struct AgentsTests { } } + @Test func noAgents() async throws { + vpn.state = .connected + vpn.menuState = .init(agents: [:]) + + try await ViewHosting.host(view) { + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.find(text: "No workspaces!") } + } + } + } + @Test func agentsWhenVPNOn() throws { vpn.state = .connected - vpn.agents = createMockAgents(count: Theme.defaultVisibleAgents + 2) + vpn.menuState = .init(agents: createMockAgents(count: Theme.defaultVisibleAgents + 2)) let forEach = try view.inspect().find(ViewType.ForEach.self) #expect(forEach.count == Theme.defaultVisibleAgents) @@ -55,24 +66,26 @@ struct AgentsTests { @Test func showAllToggle() async throws { vpn.state = .connected - vpn.agents = createMockAgents(count: 7) + vpn.menuState = .init(agents: createMockAgents(count: 7)) try await ViewHosting.host(view) { try await sut.inspection.inspect { view in var toggle = try view.find(ViewType.Toggle.self) - #expect(try toggle.labelView().text().string() == "Show All") + var forEach = try view.find(ViewType.ForEach.self) + #expect(forEach.count == Theme.defaultVisibleAgents) + #expect(try toggle.labelView().text().string() == "Show all") #expect(try !toggle.isOn()) try toggle.tap() toggle = try view.find(ViewType.Toggle.self) - var forEach = try view.find(ViewType.ForEach.self) + forEach = try view.find(ViewType.ForEach.self) #expect(forEach.count == Theme.defaultVisibleAgents + 2) - #expect(try toggle.labelView().text().string() == "Show Less") + #expect(try toggle.labelView().text().string() == "Show less") try toggle.tap() toggle = try view.find(ViewType.Toggle.self) forEach = try view.find(ViewType.ForEach.self) - #expect(try toggle.labelView().text().string() == "Show All") + #expect(try toggle.labelView().text().string() == "Show all") #expect(forEach.count == Theme.defaultVisibleAgents) } } @@ -81,10 +94,27 @@ struct AgentsTests { @Test func noToggleFewAgents() throws { vpn.state = .connected - vpn.agents = createMockAgents(count: 3) + vpn.menuState = .init(agents: createMockAgents(count: 3)) #expect(throws: (any Error).self) { _ = try view.inspect().find(ViewType.Toggle.self) } } + + @Test + func showOfflineWorkspace() async throws { + vpn.state = .connected + vpn.menuState = .init( + agents: createMockAgents(count: Theme.defaultVisibleAgents - 1), + workspaces: [UUID(): Workspace(id: UUID(), name: "offline", agents: .init())] + ) + + try await ViewHosting.host(view) { + 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") } + } + } + } } diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift index d224615..84f8821 100644 --- a/Coder Desktop/Coder DesktopTests/Util.swift +++ b/Coder Desktop/Coder DesktopTests/Util.swift @@ -8,7 +8,7 @@ import ViewInspector class MockVPNService: VPNService, ObservableObject { @Published var state: Coder_Desktop.VPNServiceState = .disabled @Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")! - @Published var agents: [UUID: Coder_Desktop.Agent] = [:] + @Published var menuState: VPNMenuState = .init() var onStart: (() async -> Void)? var onStop: (() async -> Void)? diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuStateTests.swift new file mode 100644 index 0000000..d82aff8 --- /dev/null +++ b/Coder Desktop/Coder DesktopTests/VPNMenuStateTests.swift @@ -0,0 +1,211 @@ +@testable import Coder_Desktop +import Testing +@testable import VPNLib + +@MainActor +@Suite +struct VPNMenuStateTests { + var state = VPNMenuState() + + @Test + mutating func testUpsertAgent_addsAgent() async throws { + let agentID = UUID() + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "dev" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(agent) + + let storedAgent = try #require(state.agents[agentID]) + #expect(storedAgent.name == "dev") + #expect(storedAgent.wsID == workspaceID) + #expect(storedAgent.wsName == "foo") + #expect(storedAgent.primaryHost == "foo.coder") + #expect(storedAgent.status == .okay) + } + + @Test + mutating func testDeleteAgent_removesAgent() async throws { + let agentID = UUID() + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(agent) + state.deleteAgent(withId: agent.id) + + #expect(state.agents[agentID] == nil) + } + + @Test + mutating func testDeleteWorkspace_removesWorkspaceAndAgents() async throws { + let agentID = UUID() + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(agent) + state.deleteWorkspace(withId: workspaceID.uuidData) + + #expect(state.agents[agentID] == nil) + #expect(state.workspaces[workspaceID] == nil) + } + + @Test + mutating func testUpsertAgent_unhealthyAgent() async throws { + let agentID = UUID() + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600)) + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(agent) + + let storedAgent = try #require(state.agents[agentID]) + #expect(storedAgent.status == .warn) + } + + @Test + mutating func testUpsertAgent_replacesOldAgent() async throws { + let workspaceID = UUID() + let oldAgentID = UUID() + let newAgentID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let oldAgent = Vpn_Agent.with { + $0.id = oldAgentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600)) + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(oldAgent) + + let newAgent = Vpn_Agent.with { + $0.id = newAgentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" // Same name as old agent + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["foo.coder"] + } + + state.upsertAgent(newAgent) + + #expect(state.agents[oldAgentID] == nil) + let storedAgent = try #require(state.agents[newAgentID]) + #expect(storedAgent.name == "agent1") + #expect(storedAgent.wsID == workspaceID) + #expect(storedAgent.primaryHost == "foo.coder") + #expect(storedAgent.status == .okay) + } + + @Test + mutating func testUpsertWorkspace_addsOfflineWorkspace() async throws { + let workspaceID = UUID() + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" }) + + let storedWorkspace = try #require(state.workspaces[workspaceID]) + #expect(storedWorkspace.name == "foo") + + var output = state.sorted + #expect(output.count == 1) + #expect(output[0].id == workspaceID) + #expect(output[0].wsName == "foo") + + let agentID = UUID() + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "agent1" + $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-200)) + $0.fqdn = ["foo.coder"] + } + state.upsertAgent(agent) + + output = state.sorted + #expect(output.count == 1) + #expect(output[0].id == agentID) + #expect(output[0].wsName == "foo") + #expect(output[0].status == .okay) + } + + @Test + mutating func testUpsertAgent_invalidAgent_noUUID() async throws { + let agent = Vpn_Agent.with { + $0.name = "invalidAgent" + $0.fqdn = ["invalid.coder"] + } + + state.upsertAgent(agent) + + #expect(state.agents.isEmpty) + #expect(state.invalidAgents.count == 1) + } + + @Test + mutating func testUpsertAgent_outOfOrder() async throws { + let agentID = UUID() + let workspaceID = UUID() + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "orphanAgent" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["orphan.coder"] + } + + state.upsertAgent(agent) + #expect(state.agents.isEmpty) + state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "validWorkspace" }) + #expect(state.agents.count == 1) + } + + @Test + mutating func testDeleteInvalidAgent_removesInvalid() async throws { + let agentID = UUID() + let workspaceID = UUID() + + let agent = Vpn_Agent.with { + $0.id = agentID.uuidData + $0.workspaceID = workspaceID.uuidData + $0.name = "invalidAgent" + $0.lastHandshake = .init(date: Date.now) + $0.fqdn = ["invalid.coder"] + } + + state.upsertAgent(agent) + #expect(state.agents.isEmpty) + state.deleteAgent(withId: agentID.uuidData) + #expect(state.agents.isEmpty) + #expect(state.invalidAgents.isEmpty) + } +} diff --git a/Coder Desktop/Coder DesktopTests/VPNServiceTests.swift b/Coder Desktop/Coder DesktopTests/VPNServiceTests.swift deleted file mode 100644 index 9d1370a..0000000 --- a/Coder Desktop/Coder DesktopTests/VPNServiceTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -@testable import Coder_Desktop -import Testing -@testable import VPNLib - -@MainActor -@Suite -struct CoderVPNServiceTests { - let service = CoderVPNService() - - init() { - service.workspaces = [:] - service.agents = [:] - } - - @Test - func testApplyPeerUpdate_upsertsAgents() async throws { - let agentID = UUID() - let workspaceID = UUID() - service.workspaces[workspaceID] = "foo" - - let update = Vpn_PeerUpdate.with { - $0.upsertedAgents = [Vpn_Agent.with { - $0.id = agentID.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = "dev" - $0.lastHandshake = .init(date: Date.now) - $0.fqdn = ["foo.coder"] - }] - } - - service.applyPeerUpdate(with: update) - - let agent = try #require(service.agents[agentID]) - #expect(agent.name == "dev") - #expect(agent.wsID == workspaceID) - #expect(agent.wsName == "foo") - #expect(agent.copyableDNS == "foo.coder") - #expect(agent.status == .okay) - } - - @Test - func testApplyPeerUpdate_deletesAgentsAndWorkspaces() async throws { - let agentID = UUID() - let workspaceID = UUID() - - service.agents[agentID] = Agent( - id: agentID, name: "agent1", status: .okay, - copyableDNS: "foo.coder", wsName: "foo", wsID: workspaceID - ) - service.workspaces[workspaceID] = "foo" - - let update = Vpn_PeerUpdate.with { - $0.deletedAgents = [Vpn_Agent.with { $0.id = agentID.uuidData }] - $0.deletedWorkspaces = [Vpn_Workspace.with { $0.id = workspaceID.uuidData }] - } - - service.applyPeerUpdate(with: update) - - #expect(service.agents[agentID] == nil) - #expect(service.workspaces[workspaceID] == nil) - } - - @Test - func testApplyPeerUpdate_unhealthyAgent() async throws { - let agentID = UUID() - let workspaceID = UUID() - service.workspaces[workspaceID] = "foo" - - let update = Vpn_PeerUpdate.with { - $0.upsertedAgents = [Vpn_Agent.with { - $0.id = agentID.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = "agent1" - $0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600)) - $0.fqdn = ["foo.coder"] - }] - } - - service.applyPeerUpdate(with: update) - - let agent = try #require(service.agents[agentID]) - #expect(agent.status == .off) - } - - @Test - func testApplyPeerUpdate_replaceOldAgent() async throws { - let workspaceID = UUID() - let oldAgentID = UUID() - let newAgentID = UUID() - service.workspaces[workspaceID] = "foo" - - service.agents[oldAgentID] = Agent( - id: oldAgentID, name: "agent1", status: .off, - copyableDNS: "foo.coder", wsName: "foo", wsID: workspaceID - ) - - let update = Vpn_PeerUpdate.with { - $0.upsertedAgents = [Vpn_Agent.with { - $0.id = newAgentID.uuidData - $0.workspaceID = workspaceID.uuidData - $0.name = "agent1" // Same name as old agent - $0.lastHandshake = .init(date: Date.now) - $0.fqdn = ["foo.coder"] - }] - } - - service.applyPeerUpdate(with: update) - - #expect(service.agents[oldAgentID] == nil) - let newAgent = try #require(service.agents[newAgentID]) - #expect(newAgent.name == "agent1") - #expect(newAgent.wsID == workspaceID) - #expect(newAgent.copyableDNS == "foo.coder") - #expect(newAgent.status == .okay) - } -} diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift index 298bacd..1330f06 100644 --- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift +++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift @@ -26,7 +26,7 @@ struct VPNStateTests { try await ViewHosting.host(view) { try await sut.inspection.inspect { view in #expect(throws: Never.self) { - try view.find(text: "Enable CoderVPN to see agents") + try view.find(text: "Enable CoderVPN to see workspaces") } } }