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 5f067b6

Browse files
authoredMay 1, 2025··
feat: add workspace apps (#136)
Closes #94. <img width="311" alt="Screenshot 2025-04-22 at 2 10 32 pm" src="https://github.com/user-attachments/assets/36e20e2e-49b4-4cbd-8bcc-e41840fdc45c" /> https://github.com/user-attachments/assets/0777d1c9-6183-487d-b24a-b2ad9639d75b The cursor does not change to a pointing hand as it should when screen-recording, and the display name of the app is also shown on hover: <img width="255" alt="image" src="https://github.com/user-attachments/assets/95c1f06b-b14a-457c-85a6-5a514b017def" /> As per the linked issue, this only shows the first five apps. If there's less than 5 apps, they won't be centered (I think this looks a bit better): <img width="325" alt="image" src="https://github.com/user-attachments/assets/348c1b46-f8d5-4a32-8ba6-eb03d8125344" /> Later designs will likely include a Workspace window where all the apps can be viewed, and potentially reordered to control what is shown on the tray. EDIT: Web apps have been filtered out of the above examples, as we don't currently have a way to determine whether they will work properly via Coder Connect.
1 parent 681a9a6 commit 5f067b6

File tree

10 files changed

+680
-37
lines changed

10 files changed

+680
-37
lines changed
 

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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 4 additions & 0 deletions
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

Lines changed: 1 addition & 6 deletions
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

Lines changed: 13 additions & 0 deletions
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

Lines changed: 98 additions & 30 deletions
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
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
}.frame(
30+
width: Theme.Size.appIconWidth,
31+
height: Theme.Size.appIconHeight
32+
)
33+
}.padding(4)
34+
}
35+
.clipShape(RoundedRectangle(cornerRadius: 8))
36+
.overlay(
37+
RoundedRectangle(cornerRadius: Theme.Size.rectCornerRadius * 2)
38+
.stroke(.secondary, lineWidth: 1)
39+
.opacity(isHovering && !isPressed ? 0.6 : 0.3)
40+
).onHoverWithPointingHand { hovering in isHovering = hovering }
41+
.simultaneousGesture(
42+
DragGesture(minimumDistance: 0)
43+
.onChanged { _ in
44+
withAnimation(.easeInOut(duration: 0.1)) {
45+
isPressed = true
46+
}
47+
}
48+
.onEnded { _ in
49+
withAnimation(.easeInOut(duration: 0.1)) {
50+
isPressed = false
51+
}
52+
openURL(app.url)
53+
}
54+
).help(app.displayName)
55+
}
56+
}
57+
58+
struct WorkspaceApp {
59+
let slug: String
60+
let displayName: String
61+
let url: URL
62+
let icon: URL?
63+
64+
var id: String { slug }
65+
66+
private static let magicTokenString = "$SESSION_TOKEN"
67+
68+
init(slug: String, displayName: String, url: URL, icon: URL?) {
69+
self.slug = slug
70+
self.displayName = displayName
71+
self.url = url
72+
self.icon = icon
73+
}
74+
75+
init(
76+
_ original: CoderSDK.WorkspaceApp,
77+
iconBaseURL: URL,
78+
sessionToken: String
79+
) throws(WorkspaceAppError) {
80+
slug = original.slug
81+
displayName = original.display_name
82+
83+
guard original.external else {
84+
throw .isWebApp
85+
}
86+
87+
guard let originalUrl = original.url else {
88+
throw .missingURL
89+
}
90+
91+
if let command = original.command, !command.isEmpty {
92+
throw .isCommandApp
93+
}
94+
95+
// We don't want to show buttons for any websites, like internal wikis
96+
// or portals. Those *should* have 'external' set, but if they don't:
97+
guard originalUrl.scheme != "https", originalUrl.scheme != "http" else {
98+
throw .isWebApp
99+
}
100+
101+
let newUrlString = originalUrl.absoluteString.replacingOccurrences(
102+
of: Self.magicTokenString,
103+
with: sessionToken
104+
)
105+
guard let newUrl = URL(string: newUrlString) else {
106+
throw .invalidURL
107+
}
108+
url = newUrl
109+
110+
var icon = original.icon
111+
if let originalIcon = original.icon,
112+
var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false)
113+
{
114+
if components.host == nil {
115+
components.port = iconBaseURL.port
116+
components.scheme = iconBaseURL.scheme
117+
components.host = iconBaseURL.host(percentEncoded: false)
118+
}
119+
120+
if let newIconURL = components.url {
121+
icon = newIconURL
122+
}
123+
}
124+
self.icon = icon
125+
}
126+
}
127+
128+
enum WorkspaceAppError: Error {
129+
case invalidURL
130+
case missingURL
131+
case isCommandApp
132+
case isWebApp
133+
134+
var description: String {
135+
switch self {
136+
case .invalidURL:
137+
"Invalid URL"
138+
case .missingURL:
139+
"Missing URL"
140+
case .isCommandApp:
141+
"is a Command App"
142+
case .isWebApp:
143+
"is an External App"
144+
}
145+
}
146+
147+
var localizedDescription: String { description }
148+
}
149+
150+
func agentToApps(
151+
_ logger: Logger,
152+
_ agent: CoderSDK.WorkspaceAgent,
153+
_ host: String,
154+
_ baseAccessURL: URL,
155+
_ sessionToken: String
156+
) -> [WorkspaceApp] {
157+
let workspaceApps = agent.apps.compactMap { app in
158+
do throws(WorkspaceAppError) {
159+
return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken)
160+
} catch {
161+
logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)")
162+
return nil
163+
}
164+
}
165+
166+
let displayApps = agent.display_apps.compactMap { displayApp in
167+
switch displayApp {
168+
case .vscode:
169+
return vscodeDisplayApp(
170+
hostname: host,
171+
baseIconURL: baseAccessURL,
172+
path: agent.expanded_directory
173+
)
174+
case .vscode_insiders:
175+
return vscodeInsidersDisplayApp(
176+
hostname: host,
177+
baseIconURL: baseAccessURL,
178+
path: agent.expanded_directory
179+
)
180+
default:
181+
logger.info("Skipping DisplayApp '\(displayApp.rawValue)' for \(host)")
182+
return nil
183+
}
184+
}
185+
186+
return displayApps + workspaceApps
187+
}
188+
189+
func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
190+
let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
191+
return WorkspaceApp(
192+
// Leading hyphen as to not conflict with a real app slug, since we only use
193+
// slugs as SwiftUI IDs
194+
slug: "-vscode",
195+
displayName: "VS Code Desktop",
196+
url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!,
197+
icon: icon
198+
)
199+
}
200+
201+
func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) -> WorkspaceApp {
202+
let icon = baseIconURL.appendingPathComponent("/icon/code.svg")
203+
return WorkspaceApp(
204+
slug: "-vscode-insiders",
205+
displayName: "VS Code Insiders Desktop",
206+
url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!,
207+
icon: icon
208+
)
209+
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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: "vscode://myworkspace.coder/foo")!,
19+
external: true,
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+
)
33+
34+
#expect(workspaceApp.slug == "test-app")
35+
#expect(workspaceApp.displayName == "Test App")
36+
#expect(workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo")
37+
#expect(workspaceApp.icon?.absoluteString == "https://coder.example.com/icon/test-app.svg")
38+
}
39+
40+
@Test
41+
func testCreateWorkspaceApp_SessionTokenReplacement() throws {
42+
let sdkApp = CoderSDK.WorkspaceApp(
43+
id: UUID(),
44+
url: URL(string: "vscode://myworkspace.coder/foo?token=$SESSION_TOKEN")!,
45+
external: true,
46+
slug: "token-app",
47+
display_name: "Token App",
48+
command: nil,
49+
icon: URL(string: "/icon/test-app.svg")!,
50+
subdomain: false,
51+
subdomain_name: nil
52+
)
53+
54+
let workspaceApp = try WorkspaceApp(
55+
sdkApp,
56+
iconBaseURL: baseAccessURL,
57+
sessionToken: sessionToken
58+
)
59+
60+
#expect(
61+
workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo?token=test-session-token"
62+
)
63+
}
64+
65+
@Test
66+
func testCreateWorkspaceApp_MissingURL() throws {
67+
let sdkApp = CoderSDK.WorkspaceApp(
68+
id: UUID(),
69+
url: nil,
70+
external: true,
71+
slug: "no-url-app",
72+
display_name: "No URL App",
73+
command: nil,
74+
icon: nil,
75+
subdomain: false,
76+
subdomain_name: nil
77+
)
78+
79+
#expect(throws: WorkspaceAppError.missingURL) {
80+
try WorkspaceApp(
81+
sdkApp,
82+
iconBaseURL: baseAccessURL,
83+
sessionToken: sessionToken
84+
)
85+
}
86+
}
87+
88+
@Test
89+
func testCreateWorkspaceApp_CommandApp() throws {
90+
let sdkApp = CoderSDK.WorkspaceApp(
91+
id: UUID(),
92+
url: URL(string: "vscode://myworkspace.coder/foo")!,
93+
external: true,
94+
slug: "command-app",
95+
display_name: "Command App",
96+
command: "echo 'hello'",
97+
icon: nil,
98+
subdomain: false,
99+
subdomain_name: nil
100+
)
101+
102+
#expect(throws: WorkspaceAppError.isCommandApp) {
103+
try WorkspaceApp(
104+
sdkApp,
105+
iconBaseURL: baseAccessURL,
106+
sessionToken: sessionToken
107+
)
108+
}
109+
}
110+
111+
@Test
112+
func testDisplayApps_VSCode() throws {
113+
let agent = createMockAgent(displayApps: [.vscode, .web_terminal, .ssh_helper, .port_forwarding_helper])
114+
115+
let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
116+
117+
#expect(apps.count == 1)
118+
#expect(apps[0].slug == "-vscode")
119+
#expect(apps[0].displayName == "VS Code Desktop")
120+
#expect(apps[0].url.absoluteString == "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/user")
121+
#expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg")
122+
}
123+
124+
@Test
125+
func testDisplayApps_VSCodeInsiders() throws {
126+
let agent = createMockAgent(
127+
displayApps: [
128+
.vscode_insiders,
129+
.web_terminal,
130+
.ssh_helper,
131+
.port_forwarding_helper,
132+
]
133+
)
134+
135+
let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
136+
137+
#expect(apps.count == 1)
138+
#expect(apps[0].slug == "-vscode-insiders")
139+
#expect(apps[0].displayName == "VS Code Insiders Desktop")
140+
#expect(apps[0].icon?.absoluteString == "https://coder.example.com/icon/code.svg")
141+
#expect(
142+
apps[0].url.absoluteString == """
143+
vscode-insiders://vscode-remote/ssh-remote+test-workspace.coder.test//home/user
144+
"""
145+
)
146+
}
147+
148+
@Test
149+
func testCreateWorkspaceApp_WebAppFilter() throws {
150+
let sdkApp = CoderSDK.WorkspaceApp(
151+
id: UUID(),
152+
url: URL(string: "https://myworkspace.coder/foo")!,
153+
external: false,
154+
slug: "web-app",
155+
display_name: "Web App",
156+
command: nil,
157+
icon: URL(string: "/icon/web-app.svg")!,
158+
subdomain: false,
159+
subdomain_name: nil
160+
)
161+
162+
#expect(throws: WorkspaceAppError.isWebApp) {
163+
try WorkspaceApp(
164+
sdkApp,
165+
iconBaseURL: baseAccessURL,
166+
sessionToken: sessionToken
167+
)
168+
}
169+
}
170+
171+
@Test
172+
func testAgentToApps_MultipleApps() throws {
173+
let sdkApp1 = CoderSDK.WorkspaceApp(
174+
id: UUID(),
175+
url: URL(string: "vscode://myworkspace.coder/foo1")!,
176+
external: true,
177+
slug: "app1",
178+
display_name: "App 1",
179+
command: nil,
180+
icon: URL(string: "/icon/foo1.svg")!,
181+
subdomain: false,
182+
subdomain_name: nil
183+
)
184+
185+
let sdkApp2 = CoderSDK.WorkspaceApp(
186+
id: UUID(),
187+
url: URL(string: "jetbrains://myworkspace.coder/foo2")!,
188+
external: true,
189+
slug: "app2",
190+
display_name: "App 2",
191+
command: nil,
192+
icon: URL(string: "/icon/foo2.svg")!,
193+
subdomain: false,
194+
subdomain_name: nil
195+
)
196+
197+
// Command app; skipped
198+
let sdkApp3 = CoderSDK.WorkspaceApp(
199+
id: UUID(),
200+
url: URL(string: "vscode://myworkspace.coder/foo3")!,
201+
external: true,
202+
slug: "app3",
203+
display_name: "App 3",
204+
command: "echo 'skip me'",
205+
icon: nil,
206+
subdomain: false,
207+
subdomain_name: nil
208+
)
209+
210+
// Web app skipped
211+
let sdkApp4 = CoderSDK.WorkspaceApp(
212+
id: UUID(),
213+
url: URL(string: "https://myworkspace.coder/foo4")!,
214+
external: true,
215+
slug: "app4",
216+
display_name: "App 4",
217+
command: nil,
218+
icon: URL(string: "/icon/foo4.svg")!,
219+
subdomain: false, subdomain_name: nil
220+
)
221+
222+
let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3, sdkApp4], displayApps: [.vscode])
223+
let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken)
224+
225+
#expect(apps.count == 3)
226+
let appSlugs = apps.map(\.slug)
227+
#expect(appSlugs.contains("app1"))
228+
#expect(appSlugs.contains("app2"))
229+
#expect(appSlugs.contains("-vscode"))
230+
}
231+
232+
private func createMockAgent(
233+
apps: [CoderSDK.WorkspaceApp] = [],
234+
displayApps: [DisplayApp] = []
235+
) -> CoderSDK.WorkspaceAgent {
236+
CoderSDK.WorkspaceAgent(
237+
id: UUID(),
238+
expanded_directory: "/home/user",
239+
apps: apps,
240+
display_apps: displayApps
241+
)
242+
}
243+
}
Lines changed: 98 additions & 0 deletions
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

Lines changed: 8 additions & 0 deletions
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.