Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit a00e365

Browse files
committedApr 22, 2025·
feat: add workspace apps
1 parent 33da515 commit a00e365

File tree

10 files changed

+649
-37
lines changed

10 files changed

+649
-37
lines changed
 

‎Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import FluidMenuBarExtra
22
import NetworkExtension
3+
import SDWebImageSVGCoder
4+
import SDWebImageSwiftUI
35
import SwiftUI
46
import VPNLib
57

@@ -66,6 +68,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6668
}
6769

6870
func applicationDidFinishLaunching(_: Notification) {
71+
// Init SVG loader
72+
SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
73+
6974
menuBar = .init(menuBarExtra: FluidMenuBarExtra(
7075
title: "Coder Desktop",
7176
image: "MenuBarIcon",

‎Coder-Desktop/Coder-Desktop/State.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class AppState: ObservableObject {
3737
}
3838
}
3939

40-
private var client: Client?
40+
public var client: Client?
4141

4242
@Published var useLiteralHeaders: Bool = UserDefaults.standard.bool(forKey: Keys.useLiteralHeaders) {
4343
didSet {

‎Coder-Desktop/Coder-Desktop/Theme.swift

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ enum Theme {
77
static let trayInset: CGFloat = trayMargin + trayPadding
88

99
static let rectCornerRadius: CGFloat = 4
10+
11+
static let appIconWidth: CGFloat = 30
12+
static let appIconHeight: CGFloat = 30
13+
static let appIconSize: CGSize = .init(width: appIconWidth, height: appIconHeight)
1014
}
1115

1216
static let defaultVisibleAgents = 5

‎Coder-Desktop/Coder-Desktop/Views/ResponsiveLink.swift

+1-6
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,8 @@ struct ResponsiveLink: View {
1313
.font(.subheadline)
1414
.foregroundColor(isPressed ? .red : .blue)
1515
.underline(isHovered, color: isPressed ? .red : .blue)
16-
.onHover { hovering in
16+
.onHoverWithPointingHand { hovering in
1717
isHovered = hovering
18-
if hovering {
19-
NSCursor.pointingHand.push()
20-
} else {
21-
NSCursor.pop()
22-
}
2318
}
2419
.simultaneousGesture(
2520
DragGesture(minimumDistance: 0)

‎Coder-Desktop/Coder-Desktop/Views/Util.swift

+13
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,16 @@ extension UUID {
3131
self.init(uuid: uuid)
3232
}
3333
}
34+
35+
public extension View {
36+
@inlinable nonisolated func onHoverWithPointingHand(perform action: @escaping (Bool) -> Void) -> some View {
37+
onHover { hovering in
38+
if hovering {
39+
NSCursor.pointingHand.push()
40+
} else {
41+
NSCursor.pop()
42+
}
43+
action(hovering)
44+
}
45+
}
46+
}

‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift

+98-30
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import CoderSDK
2+
import os
13
import SwiftUI
24

35
// Each row in the workspaces list is an agent or an offline workspace
@@ -26,6 +28,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
2628
}
2729
}
2830

31+
var workspaceID: UUID {
32+
switch self {
33+
case let .agent(agent): agent.wsID
34+
case let .offlineWorkspace(workspace): workspace.id
35+
}
36+
}
37+
2938
static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
3039
switch (lhs, rhs) {
3140
case let (.agent(lhsAgent), .agent(rhsAgent)):
@@ -44,11 +53,17 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
4453
struct MenuItemView: View {
4554
@EnvironmentObject var state: AppState
4655

56+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VPNMenu")
57+
4758
let item: VPNMenuItem
4859
let baseAccessURL: URL
60+
4961
@State private var nameIsSelected: Bool = false
5062
@State private var copyIsSelected: Bool = false
5163

64+
private let defaultVisibleApps = 5
65+
@State private var apps: [WorkspaceApp] = []
66+
5267
private var itemName: AttributedString {
5368
let name = switch item {
5469
case let .agent(agent): agent.primaryHost ?? "\(item.wsName).\(state.hostnameSuffix)"
@@ -70,37 +85,90 @@ struct MenuItemView: View {
7085
}
7186

7287
var body: some View {
73-
HStack(spacing: 0) {
74-
Link(destination: wsURL) {
75-
HStack(spacing: Theme.Size.trayPadding) {
76-
StatusDot(color: item.status.color)
77-
Text(itemName).lineLimit(1).truncationMode(.tail)
88+
VStack(spacing: 0) {
89+
HStack(spacing: 0) {
90+
Link(destination: wsURL) {
91+
HStack(spacing: Theme.Size.trayPadding) {
92+
StatusDot(color: item.status.color)
93+
Text(itemName).lineLimit(1).truncationMode(.tail)
94+
Spacer()
95+
}.padding(.horizontal, Theme.Size.trayPadding)
96+
.frame(minHeight: 22)
97+
.frame(maxWidth: .infinity, alignment: .leading)
98+
.foregroundStyle(nameIsSelected ? .white : .primary)
99+
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
100+
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
101+
.onHoverWithPointingHand { hovering in
102+
nameIsSelected = hovering
103+
}
78104
Spacer()
79-
}.padding(.horizontal, Theme.Size.trayPadding)
80-
.frame(minHeight: 22)
81-
.frame(maxWidth: .infinity, alignment: .leading)
82-
.foregroundStyle(nameIsSelected ? .white : .primary)
83-
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
84-
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
85-
.onHover { hovering in nameIsSelected = hovering }
86-
Spacer()
87-
}.buttonStyle(.plain)
88-
if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
89-
Button {
90-
NSPasteboard.general.clearContents()
91-
NSPasteboard.general.setString(copyableDNS, forType: .string)
92-
} label: {
93-
Image(systemName: "doc.on.doc")
94-
.symbolVariant(.fill)
95-
.padding(3)
96-
.contentShape(Rectangle())
97-
}.foregroundStyle(copyIsSelected ? .white : .primary)
98-
.imageScale(.small)
99-
.background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
100-
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
101-
.onHover { hovering in copyIsSelected = hovering }
102-
.buttonStyle(.plain)
103-
.padding(.trailing, Theme.Size.trayMargin)
105+
}.buttonStyle(.plain)
106+
if case let .agent(agent) = item, let copyableDNS = agent.primaryHost {
107+
Button {
108+
NSPasteboard.general.clearContents()
109+
NSPasteboard.general.setString(copyableDNS, forType: .string)
110+
} label: {
111+
Image(systemName: "doc.on.doc")
112+
.symbolVariant(.fill)
113+
.padding(3)
114+
.contentShape(Rectangle())
115+
}.foregroundStyle(copyIsSelected ? .white : .primary)
116+
.imageScale(.small)
117+
.background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
118+
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
119+
.onHoverWithPointingHand { hovering in copyIsSelected = hovering }
120+
.buttonStyle(.plain)
121+
.padding(.trailing, Theme.Size.trayMargin)
122+
}
123+
}
124+
if !apps.isEmpty {
125+
HStack(spacing: 17) {
126+
ForEach(apps.prefix(defaultVisibleApps), id: \.id) { app in
127+
WorkspaceAppIcon(app: app)
128+
.frame(width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight)
129+
}
130+
if apps.count < defaultVisibleApps {
131+
Spacer()
132+
}
133+
}
134+
.padding(.leading, apps.count < defaultVisibleApps ? 14 : 0)
135+
.padding(.bottom, 5)
136+
.padding(.top, 10)
137+
}
138+
}
139+
.task { await loadApps() }
140+
}
141+
142+
func loadApps() async {
143+
// If this menu item is an agent, and the user is logged in
144+
if case let .agent(agent) = item,
145+
let client = state.client,
146+
let host = agent.primaryHost,
147+
let baseAccessURL = state.baseAccessURL,
148+
// Like the CLI, we'll re-use the existing session token to populate the URL
149+
let sessionToken = state.sessionToken
150+
{
151+
let workspace: CoderSDK.Workspace
152+
do {
153+
workspace = try await retry(floor: .milliseconds(100), ceil: .seconds(10)) {
154+
do {
155+
return try await client.workspace(item.workspaceID)
156+
} catch {
157+
logger.error("Failed to load apps for workspace \(item.wsName): \(error.localizedDescription)")
158+
throw error
159+
}
160+
}
161+
} catch { return } // Task cancelled
162+
163+
if let wsAgent = workspace
164+
.latest_build.resources
165+
.compactMap(\.agents)
166+
.flatMap(\.self)
167+
.first(where: { $0.id == agent.id })
168+
{
169+
apps = agentToApps(logger, wsAgent, host, baseAccessURL, sessionToken)
170+
} else {
171+
logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources")
104172
}
105173
}
106174
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import CoderSDK
2+
import os
3+
import SDWebImageSwiftUI
4+
import SwiftUI
5+
6+
struct WorkspaceAppIcon: View {
7+
let app: WorkspaceApp
8+
@Environment(\.openURL) private var openURL
9+
10+
@State var isHovering: Bool = false
11+
@State var isPressed = false
12+
13+
var body: some View {
14+
Group {
15+
Group {
16+
WebImage(
17+
url: app.icon,
18+
context: [.imageThumbnailPixelSize: Theme.Size.appIconSize]
19+
) { $0 }
20+
placeholder: {
21+
if app.icon != nil {
22+
ProgressView()
23+
} else {
24+
Text(app.displayName).frame(
25+
width: Theme.Size.appIconWidth,
26+
height: Theme.Size.appIconHeight
27+
)
28+
}
29+
}
30+
}.padding(4)
31+
}
32+
.clipShape(RoundedRectangle(cornerRadius: 8))
33+
.overlay(
34+
RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
35+
.stroke(.secondary, lineWidth: 1)
36+
.opacity(isHovering && !isPressed ? 0.6 : 0.3)
37+
).onHoverWithPointingHand { hovering in isHovering = hovering }
38+
.simultaneousGesture(
39+
DragGesture(minimumDistance: 0)
40+
.onChanged { _ in
41+
withAnimation(.easeInOut(duration: 0.1)) {
42+
isPressed = true
43+
}
44+
}
45+
.onEnded { _ in
46+
withAnimation(.easeInOut(duration: 0.1)) {
47+
isPressed = false
48+
}
49+
openURL(app.url)
50+
}
51+
).help(app.displayName)
52+
}
53+
}
54+
55+
struct WorkspaceApp {
56+
let slug: String
57+
let displayName: String
58+
let url: URL
59+
let icon: URL?
60+
61+
var id: String { slug }
62+
63+
private static let magicTokenString = "$SESSION_TOKEN"
64+
65+
init(slug: String, displayName: String, url: URL, icon: URL?) {
66+
self.slug = slug
67+
self.displayName = displayName
68+
self.url = url
69+
self.icon = icon
70+
}
71+
72+
init(
73+
_ original: CoderSDK.WorkspaceApp,
74+
iconBaseURL: URL,
75+
sessionToken: String,
76+
newAppHost: String
77+
) throws(WorkspaceAppError) {
78+
slug = original.slug
79+
displayName = original.display_name
80+
81+
guard let originalUrl = original.url else {
82+
throw .missingURL
83+
}
84+
85+
if let command = original.command, !command.isEmpty {
86+
throw .isCommandApp
87+
}
88+
89+
guard var urlComponents = URLComponents(url: originalUrl, resolvingAgainstBaseURL: false) else {
90+
throw .invalidURL
91+
}
92+
93+
var url: URL
94+
if urlComponents.host == "localhost" {
95+
urlComponents.host = newAppHost
96+
guard let newUrl = urlComponents.url else {
97+
throw .invalidURL
98+
}
99+
url = newUrl
100+
} else {
101+
url = originalUrl
102+
}
103+
104+
let newUrlString = url.absoluteString.replacingOccurrences(of: Self.magicTokenString, with: sessionToken)
105+
guard let newUrl = URL(string: newUrlString) else {
106+
throw .invalidURL
107+
}
108+
url = newUrl
109+
110+
self.url = url
111+
112+
var icon = original.icon
113+
if let originalIcon = original.icon,
114+
var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false)
115+
{
116+
if components.host == nil {
117+
components.port = iconBaseURL.port
118+
components.scheme = iconBaseURL.scheme
119+
components.host = iconBaseURL.host(percentEncoded: false)
120+
}
121+
122+
if let newIconURL = components.url {
123+
icon = newIconURL
124+
}
125+
}
126+
self.icon = icon
127+
}
128+
}
129+
130+
enum WorkspaceAppError: Error {
131+
case invalidURL
132+
case missingURL
133+
case isCommandApp
134+
135+
var description: String {
136+
switch self {
137+
case .invalidURL:
138+
"Invalid URL"
139+
case .missingURL:
140+
"Missing URL"
141+
case .isCommandApp:
142+
"is a Command App"
143+
}
144+
}
145+
146+
var localizedDescription: String { description }
147+
}
148+
149+
func agentToApps(
150+
_ logger: Logger,
151+
_ agent: CoderSDK.WorkspaceAgent,
152+
_ host: String,
153+
_ baseAccessURL: URL,
154+
_ sessionToken: String
155+
) -> [WorkspaceApp] {
156+
let workspaceApps = agent.apps.compactMap { app in
157+
do throws(WorkspaceAppError) {
158+
return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken, newAppHost: host)
159+
} catch {
160+
logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)")
161+
return nil
162+
}
163+
}
164+
165+
let displayApps = agent.display_apps.compactMap { displayApp in
166+
switch displayApp {
167+
case .vscode:
168+
return vscodeDisplayApp(
169+
hostname: host,
170+
baseIconURL: baseAccessURL,
171+
path: agent.expanded_directory
172+
)
173+
case .vscode_insiders:
174+
return vscodeInsidersDisplayApp(
175+
hostname: host,
176+
baseIconURL: baseAccessURL,
177+
path: agent.expanded_directory
178+
)
179+
default:
180+
logger.info("Skipping DisplayApp '\(displayApp.rawValue)' for \(host)")
181+
return nil
182+
}
183+
}
184+
185+
return displayApps + workspaceApps
186+
}
187+
188+
func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
189+
let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
190+
return WorkspaceApp(
191+
// Leading hyphen as to not conflict with a real app slug, since we only use
192+
// slugs as SwiftUI IDs
193+
slug: "-vscode",
194+
displayName: "VS Code",
195+
url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)\(path ?? "")")!,
196+
icon: icon
197+
)
198+
}
199+
200+
func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
201+
let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
202+
return WorkspaceApp(
203+
slug: "-vscode-insiders",
204+
displayName: "VS Code Insiders",
205+
url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)\(path ?? "")")!,
206+
icon: icon
207+
)
208+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
@testable import Coder_Desktop
2+
import CoderSDK
3+
import os
4+
import Testing
5+
6+
@MainActor
7+
@Suite
8+
struct WorkspaceAppTests {
9+
let logger = Logger(subsystem: "com.coder.Coder-Desktop-Tests", category: "WorkspaceAppTests")
10+
let baseAccessURL = URL(string: "https://coder.example.com")!
11+
let sessionToken = "test-session-token"
12+
let host = "test-workspace.coder.test"
13+
14+
@Test
15+
func testCreateWorkspaceApp_Success() throws {
16+
let sdkApp = CoderSDK.WorkspaceApp(
17+
id: UUID(),
18+
url: URL(string: "https://localhost:3000/app")!,
19+
external: false,
20+
slug: "test-app",
21+
display_name: "Test App",
22+
command: nil,
23+
icon: URL(string: "/icon/test-app.svg")!,
24+
subdomain: false,
25+
subdomain_name: nil
26+
)
27+
28+
let workspaceApp = try WorkspaceApp(
29+
sdkApp,
30+
iconBaseURL: baseAccessURL,
31+
sessionToken: sessionToken,
32+
newAppHost: host
33+
)
34+
35+
#expect(workspaceApp.slug == "test-app")
36+
#expect(workspaceApp.displayName == "Test App")
37+
#expect(workspaceApp.url.absoluteString == "https://test-workspace.coder.test:3000/app")
38+
#expect(workspaceApp.icon?.absoluteString == "https://coder.example.com/icon/test-app.svg")
39+
}
40+
41+
@Test
42+
func testCreateWorkspaceApp_SessionTokenReplacement() throws {
43+
let sdkApp = CoderSDK.WorkspaceApp(
44+
id: UUID(),
45+
url: URL(string: "https://localhost:3000/app?token=$SESSION_TOKEN")!,
46+
external: false,
47+
slug: "token-app",
48+
display_name: "Token App",
49+
command: nil,
50+
icon: URL(string: "/icon/test-app.svg")!,
51+
subdomain: false,
52+
subdomain_name: nil
53+
)
54+
55+
let workspaceApp = try WorkspaceApp(
56+
sdkApp,
57+
iconBaseURL: baseAccessURL,
58+
sessionToken: sessionToken,
59+
newAppHost: host
60+
)
61+
62+
#expect(
63+
workspaceApp.url.absoluteString == "https://test-workspace.coder.test:3000/app?token=test-session-token"
64+
)
65+
}
66+
67+
@Test
68+
func testCreateWorkspaceApp_MissingURL() throws {
69+
let sdkApp = CoderSDK.WorkspaceApp(
70+
id: UUID(),
71+
url: nil,
72+
external: false,
73+
slug: "no-url-app",
74+
display_name: "No URL App",
75+
command: nil,
76+
icon: nil,
77+
subdomain: false,
78+
subdomain_name: nil
79+
)
80+
81+
#expect(throws: WorkspaceAppError.missingURL) {
82+
try WorkspaceApp(
83+
sdkApp,
84+
iconBaseURL: baseAccessURL,
85+
sessionToken: sessionToken,
86+
newAppHost: host
87+
)
88+
}
89+
}
90+
91+
@Test
92+
func testCreateWorkspaceApp_CommandApp() throws {
93+
let sdkApp = CoderSDK.WorkspaceApp(
94+
id: UUID(),
95+
url: URL(string: "https://localhost:3000/app")!,
96+
external: false,
97+
slug: "command-app",
98+
display_name: "Command App",
99+
command: "echo 'hello'",
100+
icon: nil,
101+
subdomain: false,
102+
subdomain_name: nil
103+
)
104+
105+
#expect(throws: WorkspaceAppError.isCommandApp) {
106+
try WorkspaceApp(
107+
sdkApp,
108+
iconBaseURL: baseAccessURL,
109+
sessionToken: sessionToken,
110+
newAppHost: host
111+
)
112+
}
113+
}
114+
115+
@Test
116+
func testDisplayApps_VSCode() throws {
117+
let agent = createMockAgent(displayApps: [.vscode, .web_terminal, .ssh_helper, .port_forwarding_helper])
118+
119+
let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
120+
121+
#expect(apps.count == 1)
122+
#expect(apps[0].slug == "-vscode")
123+
#expect(apps[0].displayName == "VS Code")
124+
#expect(apps[0].url.absoluteString == "vscode://vscode-remote/ssh-remote+test-workspace.coder.test/home/user")
125+
#expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg")
126+
}
127+
128+
@Test
129+
func testDisplayApps_VSCodeInsiders() throws {
130+
let agent = createMockAgent(
131+
displayApps: [
132+
.vscode_insiders,
133+
.web_terminal,
134+
.ssh_helper,
135+
.port_forwarding_helper,
136+
]
137+
)
138+
139+
let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
140+
141+
#expect(apps.count == 1)
142+
#expect(apps[0].slug == "-vscode-insiders")
143+
#expect(apps[0].displayName == "VS Code Insiders")
144+
#expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg")
145+
#expect(
146+
apps[0].url.absoluteString == """
147+
vscode-insiders://vscode-remote/ssh-remote+test-workspace.coder.test/home/user
148+
"""
149+
)
150+
}
151+
152+
@Test
153+
func testAgentToApps_MultipleApps() throws {
154+
let sdkApp1 = CoderSDK.WorkspaceApp(
155+
id: UUID(),
156+
url: URL(string: "https://localhost:3000/app1")!,
157+
external: false,
158+
slug: "app1",
159+
display_name: "App 1",
160+
command: nil,
161+
icon: URL(string: "/icon/app1.svg")!,
162+
subdomain: false,
163+
subdomain_name: nil
164+
)
165+
166+
let sdkApp2 = CoderSDK.WorkspaceApp(
167+
id: UUID(),
168+
url: URL(string: "https://localhost:3000/app2")!,
169+
external: false,
170+
slug: "app2",
171+
display_name: "App 2",
172+
command: nil,
173+
icon: URL(string: "/icon/app2.svg")!,
174+
subdomain: false,
175+
subdomain_name: nil
176+
)
177+
178+
// Command app; skipped
179+
let sdkApp3 = CoderSDK.WorkspaceApp(
180+
id: UUID(),
181+
url: URL(string: "https://localhost:3000/app3")!,
182+
external: false,
183+
slug: "app3",
184+
display_name: "App 3",
185+
command: "echo 'skip me'",
186+
icon: nil,
187+
subdomain: false,
188+
subdomain_name: nil
189+
)
190+
191+
let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3], displayApps: [.vscode])
192+
let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
193+
194+
#expect(apps.count == 3)
195+
let appSlugs = apps.map(\.slug)
196+
#expect(appSlugs.contains("app1"))
197+
#expect(appSlugs.contains("app2"))
198+
#expect(!appSlugs.contains("app3"))
199+
#expect(appSlugs.contains("-vscode"))
200+
}
201+
202+
private func createMockAgent(
203+
apps: [CoderSDK.WorkspaceApp] = [],
204+
displayApps: [DisplayApp] = []
205+
) -> CoderSDK.WorkspaceAgent {
206+
CoderSDK.WorkspaceAgent(
207+
id: UUID(),
208+
expanded_directory: "/home/user",
209+
apps: apps,
210+
display_apps: displayApps
211+
)
212+
}
213+
}
+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
public extension Client {
2+
func workspace(_ id: UUID) async throws(SDKError) -> Workspace {
3+
let res = try await request("/api/v2/workspaces/\(id.uuidString)", method: .get)
4+
guard res.resp.statusCode == 200 else {
5+
throw responseAsError(res)
6+
}
7+
return try decode(Workspace.self, from: res.data)
8+
}
9+
}
10+
11+
public struct Workspace: Codable, Identifiable, Sendable {
12+
public let id: UUID
13+
public let name: String
14+
public let latest_build: WorkspaceBuild
15+
16+
public init(id: UUID, name: String, latest_build: WorkspaceBuild) {
17+
self.id = id
18+
self.name = name
19+
self.latest_build = latest_build
20+
}
21+
}
22+
23+
public struct WorkspaceBuild: Codable, Identifiable, Sendable {
24+
public let id: UUID
25+
public let resources: [WorkspaceResource]
26+
27+
public init(id: UUID, resources: [WorkspaceResource]) {
28+
self.id = id
29+
self.resources = resources
30+
}
31+
}
32+
33+
public struct WorkspaceResource: Codable, Identifiable, Sendable {
34+
public let id: UUID
35+
public let agents: [WorkspaceAgent]? // `omitempty`
36+
37+
public init(id: UUID, agents: [WorkspaceAgent]?) {
38+
self.id = id
39+
self.agents = agents
40+
}
41+
}
42+
43+
public struct WorkspaceAgent: Codable, Identifiable, Sendable {
44+
public let id: UUID
45+
public let expanded_directory: String? // `omitempty`
46+
public let apps: [WorkspaceApp]
47+
public let display_apps: [DisplayApp]
48+
49+
public init(id: UUID, expanded_directory: String?, apps: [WorkspaceApp], display_apps: [DisplayApp]) {
50+
self.id = id
51+
self.expanded_directory = expanded_directory
52+
self.apps = apps
53+
self.display_apps = display_apps
54+
}
55+
}
56+
57+
public struct WorkspaceApp: Codable, Identifiable, Sendable {
58+
public let id: UUID
59+
// Not `omitempty`, but `coderd` sends empty string if `command` is set
60+
public var url: URL?
61+
public let external: Bool
62+
public let slug: String
63+
public let display_name: String
64+
public let command: String? // `omitempty`
65+
public let icon: URL? // `omitempty`
66+
public let subdomain: Bool
67+
public let subdomain_name: String? // `omitempty`
68+
69+
public init(
70+
id: UUID,
71+
url: URL?,
72+
external: Bool,
73+
slug: String,
74+
display_name: String,
75+
command: String?,
76+
icon: URL?,
77+
subdomain: Bool,
78+
subdomain_name: String?
79+
) {
80+
self.id = id
81+
self.url = url
82+
self.external = external
83+
self.slug = slug
84+
self.display_name = display_name
85+
self.command = command
86+
self.icon = icon
87+
self.subdomain = subdomain
88+
self.subdomain_name = subdomain_name
89+
}
90+
}
91+
92+
public enum DisplayApp: String, Codable, Sendable {
93+
case vscode
94+
case vscode_insiders
95+
case web_terminal
96+
case port_forwarding_helper
97+
case ssh_helper
98+
}

‎Coder-Desktop/project.yml

+8
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ packages:
120120
Semaphore:
121121
url: https://github.com/groue/Semaphore/
122122
exactVersion: 0.1.0
123+
SDWebImageSwiftUI:
124+
url: https://github.com/SDWebImage/SDWebImageSwiftUI
125+
exactVersion: 3.1.3
126+
SDWebImageSVGCoder:
127+
url: https://github.com/SDWebImage/SDWebImageSVGCoder
128+
exactVersion: 1.7.0
123129

124130
targets:
125131
Coder Desktop:
@@ -177,6 +183,8 @@ targets:
177183
- package: FluidMenuBarExtra
178184
- package: KeychainAccess
179185
- package: LaunchAtLogin
186+
- package: SDWebImageSwiftUI
187+
- package: SDWebImageSVGCoder
180188
scheme:
181189
testPlans:
182190
- path: Coder-Desktop.xctestplan

0 commit comments

Comments
 (0)
Please sign in to comment.