From a00e365925c28c8d5fbb5d79e0df4d75a181631f Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 22 Apr 2025 14:17:34 +1000 Subject: [PATCH 1/7] feat: add workspace apps --- .../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 | 208 +++++++++++++++++ .../WorkspaceAppTests.swift | 213 ++++++++++++++++++ Coder-Desktop/CoderSDK/Workspace.swift | 98 ++++++++ Coder-Desktop/project.yml | 8 + 10 files changed, 649 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..3e790015 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -0,0 +1,208 @@ +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 + ) + } + } + }.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, + newAppHost: String + ) throws(WorkspaceAppError) { + slug = original.slug + displayName = original.display_name + + guard let originalUrl = original.url else { + throw .missingURL + } + + if let command = original.command, !command.isEmpty { + throw .isCommandApp + } + + guard var urlComponents = URLComponents(url: originalUrl, resolvingAgainstBaseURL: false) else { + throw .invalidURL + } + + var url: URL + if urlComponents.host == "localhost" { + urlComponents.host = newAppHost + guard let newUrl = urlComponents.url else { + throw .invalidURL + } + url = newUrl + } else { + url = originalUrl + } + + let newUrlString = url.absoluteString.replacingOccurrences(of: Self.magicTokenString, with: sessionToken) + guard let newUrl = URL(string: newUrlString) else { + throw .invalidURL + } + url = newUrl + + self.url = url + + 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 + + var description: String { + switch self { + case .invalidURL: + "Invalid URL" + case .missingURL: + "Missing URL" + case .isCommandApp: + "is a Command 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, newAppHost: host) + } 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", + 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", + 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..73b9f014 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -0,0 +1,213 @@ +@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: "https://localhost:3000/app")!, + external: false, + 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, + newAppHost: host + ) + + #expect(workspaceApp.slug == "test-app") + #expect(workspaceApp.displayName == "Test App") + #expect(workspaceApp.url.absoluteString == "https://test-workspace.coder.test:3000/app") + #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: "https://localhost:3000/app?token=$SESSION_TOKEN")!, + external: false, + 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, + newAppHost: host + ) + + #expect( + workspaceApp.url.absoluteString == "https://test-workspace.coder.test:3000/app?token=test-session-token" + ) + } + + @Test + func testCreateWorkspaceApp_MissingURL() throws { + let sdkApp = CoderSDK.WorkspaceApp( + id: UUID(), + url: nil, + external: false, + 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, + newAppHost: host + ) + } + } + + @Test + func testCreateWorkspaceApp_CommandApp() throws { + let sdkApp = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "https://localhost:3000/app")!, + external: false, + 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, + newAppHost: host + ) + } + } + + @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") + #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") + #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 testAgentToApps_MultipleApps() throws { + let sdkApp1 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "https://localhost:3000/app1")!, + external: false, + slug: "app1", + display_name: "App 1", + command: nil, + icon: URL(string: "/icon/app1.svg")!, + subdomain: false, + subdomain_name: nil + ) + + let sdkApp2 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "https://localhost:3000/app2")!, + external: false, + slug: "app2", + display_name: "App 2", + command: nil, + icon: URL(string: "/icon/app2.svg")!, + subdomain: false, + subdomain_name: nil + ) + + // Command app; skipped + let sdkApp3 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "https://localhost:3000/app3")!, + external: false, + slug: "app3", + display_name: "App 3", + command: "echo 'skip me'", + icon: nil, + subdomain: false, + subdomain_name: nil + ) + + let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3], 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("app3")) + #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 b6b5bcf0e0e5532507cf5b4389995a0ccb38911d Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 22 Apr 2025 14:25:18 +1000 Subject: [PATCH 2/7] vscode desktop display name --- Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 3e790015..0623e04f 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -191,7 +191,7 @@ func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) - // Leading hyphen as to not conflict with a real app slug, since we only use // slugs as SwiftUI IDs slug: "-vscode", - displayName: "VS Code", + displayName: "VS Code Desktop", url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)\(path ?? "")")!, icon: icon ) @@ -201,7 +201,7 @@ func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? let icon = baseIconURL.appendingPathComponent("/icon/code.svg") return WorkspaceApp( slug: "-vscode-insiders", - displayName: "VS Code Insiders", + displayName: "VS Code Insiders Desktop", url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)\(path ?? "")")!, icon: icon ) From d385cca2fb27e2469c24cb93d62b4fbc421517a8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 22 Apr 2025 14:34:57 +1000 Subject: [PATCH 3/7] fixup --- Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index 73b9f014..51aba091 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -120,7 +120,7 @@ struct WorkspaceAppTests { #expect(apps.count == 1) #expect(apps[0].slug == "-vscode") - #expect(apps[0].displayName == "VS Code") + #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") } @@ -140,7 +140,7 @@ struct WorkspaceAppTests { #expect(apps.count == 1) #expect(apps[0].slug == "-vscode-insiders") - #expect(apps[0].displayName == "VS Code 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 == """ From f11e168678899b3f1f7c3220abc5346405265239 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 22 Apr 2025 17:25:52 +1000 Subject: [PATCH 4/7] filter out web apps --- .../Views/VPN/WorkspaceAppIcon.swift | 7 +++++ .../WorkspaceAppTests.swift | 29 +++++++++++++------ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 0623e04f..364ff96a 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -78,6 +78,10 @@ struct WorkspaceApp { slug = original.slug displayName = original.display_name + guard original.external else { + throw .isWebApp + } + guard let originalUrl = original.url else { throw .missingURL } @@ -131,6 +135,7 @@ enum WorkspaceAppError: Error { case invalidURL case missingURL case isCommandApp + case isWebApp var description: String { switch self { @@ -140,6 +145,8 @@ enum WorkspaceAppError: Error { "Missing URL" case .isCommandApp: "is a Command App" + case .isWebApp: + "is an External App" } } diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index 51aba091..52f85112 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -16,7 +16,7 @@ struct WorkspaceAppTests { let sdkApp = CoderSDK.WorkspaceApp( id: UUID(), url: URL(string: "https://localhost:3000/app")!, - external: false, + external: true, slug: "test-app", display_name: "Test App", command: nil, @@ -43,7 +43,7 @@ struct WorkspaceAppTests { let sdkApp = CoderSDK.WorkspaceApp( id: UUID(), url: URL(string: "https://localhost:3000/app?token=$SESSION_TOKEN")!, - external: false, + external: true, slug: "token-app", display_name: "Token App", command: nil, @@ -69,7 +69,7 @@ struct WorkspaceAppTests { let sdkApp = CoderSDK.WorkspaceApp( id: UUID(), url: nil, - external: false, + external: true, slug: "no-url-app", display_name: "No URL App", command: nil, @@ -93,7 +93,7 @@ struct WorkspaceAppTests { let sdkApp = CoderSDK.WorkspaceApp( id: UUID(), url: URL(string: "https://localhost:3000/app")!, - external: false, + external: true, slug: "command-app", display_name: "Command App", command: "echo 'hello'", @@ -154,7 +154,7 @@ struct WorkspaceAppTests { let sdkApp1 = CoderSDK.WorkspaceApp( id: UUID(), url: URL(string: "https://localhost:3000/app1")!, - external: false, + external: true, slug: "app1", display_name: "App 1", command: nil, @@ -166,7 +166,7 @@ struct WorkspaceAppTests { let sdkApp2 = CoderSDK.WorkspaceApp( id: UUID(), url: URL(string: "https://localhost:3000/app2")!, - external: false, + external: true, slug: "app2", display_name: "App 2", command: nil, @@ -179,7 +179,7 @@ struct WorkspaceAppTests { let sdkApp3 = CoderSDK.WorkspaceApp( id: UUID(), url: URL(string: "https://localhost:3000/app3")!, - external: false, + external: true, slug: "app3", display_name: "App 3", command: "echo 'skip me'", @@ -188,14 +188,25 @@ struct WorkspaceAppTests { subdomain_name: nil ) - let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3], displayApps: [.vscode]) + // Web app skipped + let sdkApp4 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "https://localhost:3000/app4")!, + external: false, + slug: "app4", + display_name: "App 4", + command: nil, + icon: URL(string: "/icon/app4.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("app3")) #expect(appSlugs.contains("-vscode")) } From 06d21e2331a3713f6134b3ef7f2e1cb28ffb3018 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 22 Apr 2025 20:24:47 +1000 Subject: [PATCH 5/7] filter more web apps --- .../Views/VPN/WorkspaceAppIcon.swift | 27 +++----- .../WorkspaceAppTests.swift | 61 ++++++++++++------- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 364ff96a..8f8376d2 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -73,7 +73,6 @@ struct WorkspaceApp { _ original: CoderSDK.WorkspaceApp, iconBaseURL: URL, sessionToken: String, - newAppHost: String ) throws(WorkspaceAppError) { slug = original.slug displayName = original.display_name @@ -90,29 +89,21 @@ struct WorkspaceApp { throw .isCommandApp } - guard var urlComponents = URLComponents(url: originalUrl, resolvingAgainstBaseURL: false) else { - throw .invalidURL - } - - var url: URL - if urlComponents.host == "localhost" { - urlComponents.host = newAppHost - guard let newUrl = urlComponents.url else { - throw .invalidURL - } - url = newUrl - } else { - url = originalUrl + // We don't want to show buttons for any websites, like internal wikies + // or portals. Those *should* have 'external' set, but if they don't: + guard originalUrl.scheme != "https", originalUrl.scheme != "http" else { + throw .isWebApp } - let newUrlString = url.absoluteString.replacingOccurrences(of: Self.magicTokenString, with: sessionToken) + let newUrlString = originalUrl.absoluteString.replacingOccurrences( + of: Self.magicTokenString, + with: sessionToken + ) guard let newUrl = URL(string: newUrlString) else { throw .invalidURL } url = newUrl - self.url = url - var icon = original.icon if let originalIcon = original.icon, var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false) @@ -162,7 +153,7 @@ func agentToApps( ) -> [WorkspaceApp] { let workspaceApps = agent.apps.compactMap { app in do throws(WorkspaceAppError) { - return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken, newAppHost: host) + return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken) } catch { logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)") return nil diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index 52f85112..17c67220 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -15,7 +15,7 @@ struct WorkspaceAppTests { func testCreateWorkspaceApp_Success() throws { let sdkApp = CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "https://localhost:3000/app")!, + url: URL(string: "vscode://myworkspace.coder/foo")!, external: true, slug: "test-app", display_name: "Test App", @@ -28,13 +28,12 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, iconBaseURL: baseAccessURL, - sessionToken: sessionToken, - newAppHost: host + sessionToken: sessionToken ) #expect(workspaceApp.slug == "test-app") #expect(workspaceApp.displayName == "Test App") - #expect(workspaceApp.url.absoluteString == "https://test-workspace.coder.test:3000/app") + #expect(workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo") #expect(workspaceApp.icon?.absoluteString == "https://coder.example.com/icon/test-app.svg") } @@ -42,7 +41,7 @@ struct WorkspaceAppTests { func testCreateWorkspaceApp_SessionTokenReplacement() throws { let sdkApp = CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "https://localhost:3000/app?token=$SESSION_TOKEN")!, + url: URL(string: "vscode://myworkspace.coder/foo?token=$SESSION_TOKEN")!, external: true, slug: "token-app", display_name: "Token App", @@ -55,12 +54,11 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, iconBaseURL: baseAccessURL, - sessionToken: sessionToken, - newAppHost: host + sessionToken: sessionToken ) #expect( - workspaceApp.url.absoluteString == "https://test-workspace.coder.test:3000/app?token=test-session-token" + workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo?token=test-session-token" ) } @@ -82,8 +80,7 @@ struct WorkspaceAppTests { try WorkspaceApp( sdkApp, iconBaseURL: baseAccessURL, - sessionToken: sessionToken, - newAppHost: host + sessionToken: sessionToken ) } } @@ -92,7 +89,7 @@ struct WorkspaceAppTests { func testCreateWorkspaceApp_CommandApp() throws { let sdkApp = CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "https://localhost:3000/app")!, + url: URL(string: "vscode://myworkspace.coder/foo")!, external: true, slug: "command-app", display_name: "Command App", @@ -106,8 +103,7 @@ struct WorkspaceAppTests { try WorkspaceApp( sdkApp, iconBaseURL: baseAccessURL, - sessionToken: sessionToken, - newAppHost: host + sessionToken: sessionToken ) } } @@ -149,28 +145,51 @@ struct WorkspaceAppTests { ) } + @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: "https://localhost:3000/app1")!, + url: URL(string: "vscode://myworkspace.coder/foo1")!, external: true, slug: "app1", display_name: "App 1", command: nil, - icon: URL(string: "/icon/app1.svg")!, + icon: URL(string: "/icon/foo1.svg")!, subdomain: false, subdomain_name: nil ) let sdkApp2 = CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "https://localhost:3000/app2")!, + url: URL(string: "jetbrains://myworkspace.coder/foo2")!, external: true, slug: "app2", display_name: "App 2", command: nil, - icon: URL(string: "/icon/app2.svg")!, + icon: URL(string: "/icon/foo2.svg")!, subdomain: false, subdomain_name: nil ) @@ -178,7 +197,7 @@ struct WorkspaceAppTests { // Command app; skipped let sdkApp3 = CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "https://localhost:3000/app3")!, + url: URL(string: "vscode://myworkspace.coder/foo3")!, external: true, slug: "app3", display_name: "App 3", @@ -191,12 +210,12 @@ struct WorkspaceAppTests { // Web app skipped let sdkApp4 = CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "https://localhost:3000/app4")!, - external: false, + url: URL(string: "https://myworkspace.coder/foo4")!, + external: true, slug: "app4", display_name: "App 4", command: nil, - icon: URL(string: "/icon/app4.svg")!, + icon: URL(string: "/icon/foo4.svg")!, subdomain: false, subdomain_name: nil ) From e96fc49c1dcd0f6c37d75a0b154fb8af6ae8c53c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 22 Apr 2025 20:52:39 +1000 Subject: [PATCH 6/7] swift 6.1 is out apparently and xcode just updates to it --- Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 8f8376d2..27bb36aa 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -72,7 +72,7 @@ struct WorkspaceApp { init( _ original: CoderSDK.WorkspaceApp, iconBaseURL: URL, - sessionToken: String, + sessionToken: String ) throws(WorkspaceAppError) { slug = original.slug displayName = original.display_name From 34c0bee1e2162355760e7fe3cec9c22e8a34a0da Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 23 Apr 2025 14:07:05 +1000 Subject: [PATCH 7/7] typo --- .../Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift | 6 +++--- Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 27bb36aa..2d24abd0 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -89,7 +89,7 @@ struct WorkspaceApp { throw .isCommandApp } - // We don't want to show buttons for any websites, like internal wikies + // 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 @@ -190,7 +190,7 @@ func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) - // slugs as SwiftUI IDs slug: "-vscode", displayName: "VS Code Desktop", - url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)\(path ?? "")")!, + url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!, icon: icon ) } @@ -200,7 +200,7 @@ func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? return WorkspaceApp( slug: "-vscode-insiders", displayName: "VS Code Insiders Desktop", - url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)\(path ?? "")")!, + 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 index 17c67220..816c5e04 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -117,7 +117,7 @@ struct WorkspaceAppTests { #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].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") } @@ -140,7 +140,7 @@ struct WorkspaceAppTests { #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 + vscode-insiders://vscode-remote/ssh-remote+test-workspace.coder.test//home/user """ ) }