diff --git a/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift b/Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift index 369c48b..4ec412f 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 3aa8842..2247c46 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 192cc36..1c15b08 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 fd37881..5428562 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 693dc93..69981a2 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 0b231de..700cefa 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 0000000..2d24abd --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -0,0 +1,206 @@ +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 + ) throws(WorkspaceAppError) { + slug = original.slug + displayName = original.display_name + + guard original.external else { + throw .isWebApp + } + + guard let originalUrl = original.url else { + throw .missingURL + } + + if let command = original.command, !command.isEmpty { + throw .isCommandApp + } + + // We don't want to show buttons for any websites, like internal wikis + // or portals. Those *should* have 'external' set, but if they don't: + guard originalUrl.scheme != "https", originalUrl.scheme != "http" else { + throw .isWebApp + } + + let newUrlString = originalUrl.absoluteString.replacingOccurrences( + of: Self.magicTokenString, + with: sessionToken + ) + guard let newUrl = URL(string: newUrlString) else { + throw .invalidURL + } + url = newUrl + + var icon = original.icon + if let originalIcon = original.icon, + var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false) + { + if components.host == nil { + components.port = iconBaseURL.port + components.scheme = iconBaseURL.scheme + components.host = iconBaseURL.host(percentEncoded: false) + } + + if let newIconURL = components.url { + icon = newIconURL + } + } + self.icon = icon + } +} + +enum WorkspaceAppError: Error { + case invalidURL + case missingURL + case isCommandApp + case isWebApp + + var description: String { + switch self { + case .invalidURL: + "Invalid URL" + case .missingURL: + "Missing URL" + case .isCommandApp: + "is a Command App" + case .isWebApp: + "is an External App" + } + } + + var localizedDescription: String { description } +} + +func agentToApps( + _ logger: Logger, + _ agent: CoderSDK.WorkspaceAgent, + _ host: String, + _ baseAccessURL: URL, + _ sessionToken: String +) -> [WorkspaceApp] { + let workspaceApps = agent.apps.compactMap { app in + do throws(WorkspaceAppError) { + return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken) + } catch { + logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)") + return nil + } + } + + let displayApps = agent.display_apps.compactMap { displayApp in + switch displayApp { + case .vscode: + return vscodeDisplayApp( + hostname: host, + baseIconURL: baseAccessURL, + path: agent.expanded_directory + ) + case .vscode_insiders: + return vscodeInsidersDisplayApp( + hostname: host, + baseIconURL: baseAccessURL, + path: agent.expanded_directory + ) + default: + logger.info("Skipping DisplayApp '\(displayApp.rawValue)' for \(host)") + return nil + } + } + + return displayApps + workspaceApps +} + +func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp { + let icon = baseIconURL.appendingPathComponent("/icon/code.svg") + return WorkspaceApp( + // Leading hyphen as to not conflict with a real app slug, since we only use + // slugs as SwiftUI IDs + slug: "-vscode", + displayName: "VS Code Desktop", + url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!, + icon: icon + ) +} + +func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp { + let icon = baseIconURL.appendingPathComponent("/icon/code.svg") + return WorkspaceApp( + slug: "-vscode-insiders", + displayName: "VS Code Insiders Desktop", + url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!, + icon: icon + ) +} diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift new file mode 100644 index 0000000..816c5e0 --- /dev/null +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -0,0 +1,243 @@ +@testable import Coder_Desktop +import CoderSDK +import os +import Testing + +@MainActor +@Suite +struct WorkspaceAppTests { + let logger = Logger(subsystem: "com.coder.Coder-Desktop-Tests", category: "WorkspaceAppTests") + let baseAccessURL = URL(string: "https://coder.example.com")! + let sessionToken = "test-session-token" + let host = "test-workspace.coder.test" + + @Test + func testCreateWorkspaceApp_Success() throws { + let sdkApp = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "vscode://myworkspace.coder/foo")!, + external: true, + slug: "test-app", + display_name: "Test App", + command: nil, + icon: URL(string: "/icon/test-app.svg")!, + subdomain: false, + subdomain_name: nil + ) + + let workspaceApp = try WorkspaceApp( + sdkApp, + iconBaseURL: baseAccessURL, + sessionToken: sessionToken + ) + + #expect(workspaceApp.slug == "test-app") + #expect(workspaceApp.displayName == "Test App") + #expect(workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo") + #expect(workspaceApp.icon?.absoluteString == "https://coder.example.com/icon/test-app.svg") + } + + @Test + func testCreateWorkspaceApp_SessionTokenReplacement() throws { + let sdkApp = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "vscode://myworkspace.coder/foo?token=$SESSION_TOKEN")!, + external: true, + slug: "token-app", + display_name: "Token App", + command: nil, + icon: URL(string: "/icon/test-app.svg")!, + subdomain: false, + subdomain_name: nil + ) + + let workspaceApp = try WorkspaceApp( + sdkApp, + iconBaseURL: baseAccessURL, + sessionToken: sessionToken + ) + + #expect( + workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo?token=test-session-token" + ) + } + + @Test + func testCreateWorkspaceApp_MissingURL() throws { + let sdkApp = CoderSDK.WorkspaceApp( + id: UUID(), + url: nil, + external: true, + slug: "no-url-app", + display_name: "No URL App", + command: nil, + icon: nil, + subdomain: false, + subdomain_name: nil + ) + + #expect(throws: WorkspaceAppError.missingURL) { + try WorkspaceApp( + sdkApp, + iconBaseURL: baseAccessURL, + sessionToken: sessionToken + ) + } + } + + @Test + func testCreateWorkspaceApp_CommandApp() throws { + let sdkApp = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "vscode://myworkspace.coder/foo")!, + external: true, + slug: "command-app", + display_name: "Command App", + command: "echo 'hello'", + icon: nil, + subdomain: false, + subdomain_name: nil + ) + + #expect(throws: WorkspaceAppError.isCommandApp) { + try WorkspaceApp( + sdkApp, + iconBaseURL: baseAccessURL, + sessionToken: sessionToken + ) + } + } + + @Test + func testDisplayApps_VSCode() throws { + let agent = createMockAgent(displayApps: [.vscode, .web_terminal, .ssh_helper, .port_forwarding_helper]) + + let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + + #expect(apps.count == 1) + #expect(apps[0].slug == "-vscode") + #expect(apps[0].displayName == "VS Code Desktop") + #expect(apps[0].url.absoluteString == "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/user") + #expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg") + } + + @Test + func testDisplayApps_VSCodeInsiders() throws { + let agent = createMockAgent( + displayApps: [ + .vscode_insiders, + .web_terminal, + .ssh_helper, + .port_forwarding_helper, + ] + ) + + let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + + #expect(apps.count == 1) + #expect(apps[0].slug == "-vscode-insiders") + #expect(apps[0].displayName == "VS Code Insiders Desktop") + #expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg") + #expect( + apps[0].url.absoluteString == """ + vscode-insiders://vscode-remote/ssh-remote+test-workspace.coder.test//home/user + """ + ) + } + + @Test + func testCreateWorkspaceApp_WebAppFilter() throws { + let sdkApp = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "https://myworkspace.coder/foo")!, + external: false, + slug: "web-app", + display_name: "Web App", + command: nil, + icon: URL(string: "/icon/web-app.svg")!, + subdomain: false, + subdomain_name: nil + ) + + #expect(throws: WorkspaceAppError.isWebApp) { + try WorkspaceApp( + sdkApp, + iconBaseURL: baseAccessURL, + sessionToken: sessionToken + ) + } + } + + @Test + func testAgentToApps_MultipleApps() throws { + let sdkApp1 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "vscode://myworkspace.coder/foo1")!, + external: true, + slug: "app1", + display_name: "App 1", + command: nil, + icon: URL(string: "/icon/foo1.svg")!, + subdomain: false, + subdomain_name: nil + ) + + let sdkApp2 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "jetbrains://myworkspace.coder/foo2")!, + external: true, + slug: "app2", + display_name: "App 2", + command: nil, + icon: URL(string: "/icon/foo2.svg")!, + subdomain: false, + subdomain_name: nil + ) + + // Command app; skipped + let sdkApp3 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "vscode://myworkspace.coder/foo3")!, + external: true, + slug: "app3", + display_name: "App 3", + command: "echo 'skip me'", + icon: nil, + subdomain: false, + subdomain_name: nil + ) + + // Web app skipped + let sdkApp4 = CoderSDK.WorkspaceApp( + id: UUID(), + url: URL(string: "https://myworkspace.coder/foo4")!, + external: true, + slug: "app4", + display_name: "App 4", + command: nil, + icon: URL(string: "/icon/foo4.svg")!, + subdomain: false, subdomain_name: nil + ) + + let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3, sdkApp4], displayApps: [.vscode]) + let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + + #expect(apps.count == 3) + let appSlugs = apps.map(\.slug) + #expect(appSlugs.contains("app1")) + #expect(appSlugs.contains("app2")) + #expect(appSlugs.contains("-vscode")) + } + + private func createMockAgent( + apps: [CoderSDK.WorkspaceApp] = [], + displayApps: [DisplayApp] = [] + ) -> CoderSDK.WorkspaceAgent { + CoderSDK.WorkspaceAgent( + id: UUID(), + expanded_directory: "/home/user", + apps: apps, + display_apps: displayApps + ) + } +} diff --git a/Coder-Desktop/CoderSDK/Workspace.swift b/Coder-Desktop/CoderSDK/Workspace.swift new file mode 100644 index 0000000..e8f95df --- /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 d256767..f557304 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