Skip to content

feat: add workspace apps #136

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 1, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member Author

@ethanndickson ethanndickson Apr 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwiftUI has a great AsyncImage View, but it doesn't support svgs. FWICT there's not even a public macOS API for rendering them.

I also found this https://gist.github.com/erezhod/6e8e6af3c940d88a706a9d936c8838e6, but it doesn't have a license attached, and I didn't feel like reaching out to the two separate authors to ask.


menuBar = .init(menuBarExtra: FluidMenuBarExtra(
title: "Coder Desktop",
image: "MenuBarIcon",
2 changes: 1 addition & 1 deletion Coder-Desktop/Coder-Desktop/State.swift
Original file line number Diff line number Diff line change
@@ -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 {
4 changes: 4 additions & 0 deletions Coder-Desktop/Coder-Desktop/Theme.swift
Original file line number Diff line number Diff line change
@@ -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
7 changes: 1 addition & 6 deletions Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 13 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/Util.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
128 changes: 98 additions & 30 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
Original file line number Diff line number Diff line change
@@ -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() }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.task on a view ensures for each instance of the view, only one copy of the task is running at any given time, and if the view re-renders (such as a workspace going offline), the existing task will be cancelled, and a new one created, which is exactly what we want.

}

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")
}
}
}
209 changes: 209 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import CoderSDK
import os
import SDWebImageSwiftUI
import SwiftUI

struct WorkspaceAppIcon: View {
let app: WorkspaceApp
@Environment(\.openURL) private var openURL

@State var isHovering: Bool = false
@State var isPressed = false

var body: some View {
Group {
Group {
WebImage(
url: app.icon,
context: [.imageThumbnailPixelSize: Theme.Size.appIconSize]
) { $0 }
placeholder: {
if app.icon != nil {
ProgressView()
} else {
Text(app.displayName).frame(
width: Theme.Size.appIconWidth,
height: Theme.Size.appIconHeight
)
}
}.frame(
width: Theme.Size.appIconWidth,
height: Theme.Size.appIconHeight
)
}.padding(4)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
.stroke(.secondary, lineWidth: 1)
.opacity(isHovering && !isPressed ? 0.6 : 0.3)
).onHoverWithPointingHand { hovering in isHovering = hovering }
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(.easeInOut(duration: 0.1)) {
isPressed = true
}
}
.onEnded { _ in
withAnimation(.easeInOut(duration: 0.1)) {
isPressed = false
}
openURL(app.url)
}
).help(app.displayName)
}
}

struct WorkspaceApp {
let slug: String
let displayName: String
let url: URL
let icon: URL?

var id: String { slug }

private static let magicTokenString = "$SESSION_TOKEN"

init(slug: String, displayName: String, url: URL, icon: URL?) {
self.slug = slug
self.displayName = displayName
self.url = url
self.icon = icon
}

init(
_ original: CoderSDK.WorkspaceApp,
iconBaseURL: URL,
sessionToken: String
) throws(WorkspaceAppError) {
slug = original.slug
displayName = original.display_name

guard original.external else {
throw .isWebApp
}

guard let originalUrl = original.url else {
throw .missingURL
}

if let command = original.command, !command.isEmpty {
throw .isCommandApp
}

// We don't want to show buttons for any websites, like internal wikis
// or portals. Those *should* have 'external' set, but if they don't:
guard originalUrl.scheme != "https", originalUrl.scheme != "http" else {
throw .isWebApp
}

let newUrlString = originalUrl.absoluteString.replacingOccurrences(
of: Self.magicTokenString,
with: sessionToken
)
guard let newUrl = URL(string: newUrlString) else {
throw .invalidURL
}
url = newUrl

var icon = original.icon
if let originalIcon = original.icon,
var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false)
{
if components.host == nil {
components.port = iconBaseURL.port
components.scheme = iconBaseURL.scheme
components.host = iconBaseURL.host(percentEncoded: false)
}

if let newIconURL = components.url {
icon = newIconURL
}
}
self.icon = icon
}
}

enum WorkspaceAppError: Error {
case invalidURL
case missingURL
case isCommandApp
case isWebApp

var description: String {
switch self {
case .invalidURL:
"Invalid URL"
case .missingURL:
"Missing URL"
case .isCommandApp:
"is a Command App"
case .isWebApp:
"is an External App"
}
}

var localizedDescription: String { description }
}

func agentToApps(
_ logger: Logger,
_ agent: CoderSDK.WorkspaceAgent,
_ host: String,
_ baseAccessURL: URL,
_ sessionToken: String
) -> [WorkspaceApp] {
let workspaceApps = agent.apps.compactMap { app in
do throws(WorkspaceAppError) {
return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken)
} catch {
logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)")
return nil
}
}

let displayApps = agent.display_apps.compactMap { displayApp in
switch displayApp {
case .vscode:
return vscodeDisplayApp(
hostname: host,
baseIconURL: baseAccessURL,
path: agent.expanded_directory
)
case .vscode_insiders:
return vscodeInsidersDisplayApp(
hostname: host,
baseIconURL: baseAccessURL,
path: agent.expanded_directory
)
default:
logger.info("Skipping DisplayApp '\(displayApp.rawValue)' for \(host)")
return nil
}
}

return displayApps + workspaceApps
}

func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
return WorkspaceApp(
// Leading hyphen as to not conflict with a real app slug, since we only use
// slugs as SwiftUI IDs
slug: "-vscode",
displayName: "VS Code Desktop",
url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!,
icon: icon
)
}

func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
return WorkspaceApp(
slug: "-vscode-insiders",
displayName: "VS Code Insiders Desktop",
url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!,
icon: icon
)
}
243 changes: 243 additions & 0 deletions Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
98 changes: 98 additions & 0 deletions Coder-Desktop/CoderSDK/Workspace.swift
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions Coder-Desktop/project.yml
Original file line number Diff line number Diff line change
@@ -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