diff --git a/Coder Desktop/.swiftlint.yml b/Coder Desktop/.swiftlint.yml
index d824232..49d9e03 100644
--- a/Coder Desktop/.swiftlint.yml
+++ b/Coder Desktop/.swiftlint.yml
@@ -3,3 +3,5 @@ disabled_rules:
- trailing_comma
type_name:
allowed_symbols: "_"
+identifier_name:
+ allowed_symbols: "_"
diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
index a17d2ff..f65c5a1 100644
--- a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
+++ b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
AA8BC3392D0060A900E1ABAA /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC3382D0060A900E1ABAA /* ViewInspector */; };
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */; };
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */; };
+ AAD720D02D0816B200F6304D /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = AAD720CF2D0816B200F6304D /* Alamofire */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -101,6 +102,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ AAD720D02D0816B200F6304D /* Alamofire in Frameworks */,
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */,
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */,
);
@@ -188,6 +190,7 @@
packageProductDependencies = (
AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */,
AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */,
+ AAD720CF2D0816B200F6304D /* Alamofire */,
);
productName = "Coder Desktop";
productReference = 961678FC2CFF100D00B2B6DF /* Coder Desktop.app */;
@@ -302,6 +305,7 @@
AA8BC33A2D0060C500E1ABAA /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */,
AA8BC33D2D0061F200E1ABAA /* XCRemoteSwiftPackageReference "fluid-menu-bar-extra" */,
AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */,
+ AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 961678FD2CFF100D00B2B6DF /* Products */;
@@ -533,6 +537,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -561,6 +566,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -778,6 +784,14 @@
kind = branch;
};
};
+ AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/Alamofire/Alamofire";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 5.10.2;
+ };
+ };
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -801,6 +815,11 @@
package = AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */;
productName = KeychainAccess;
};
+ AAD720CF2D0816B200F6304D /* Alamofire */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */;
+ productName = Alamofire;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 961678F42CFF100D00B2B6DF /* Project object */;
diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 1070ac8..3d9b7f4 100644
--- a/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,15 @@
{
- "originHash" : "726475d6c2c0355de7a4de72708853eaf53eb295e791efe2cc4b8eb5ce4e9ae8",
+ "originHash" : "42dc2e0a0e0417a7f4f62b3e875c9559038beef7d2265073dd4fc81f2e11ee13",
"pins" : [
+ {
+ "identity" : "alamofire",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/Alamofire/Alamofire",
+ "state" : {
+ "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5",
+ "version" : "5.10.2"
+ }
+ },
{
"identity" : "fluid-menu-bar-extra",
"kind" : "remoteSourceControl",
diff --git a/Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme b/Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme
new file mode 100644
index 0000000..004582c
--- /dev/null
+++ b/Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Coder Desktop/Coder Desktop.xctestplan b/Coder Desktop/Coder Desktop.xctestplan
new file mode 100644
index 0000000..ab80bb8
--- /dev/null
+++ b/Coder Desktop/Coder Desktop.xctestplan
@@ -0,0 +1,37 @@
+{
+ "configurations" : [
+ {
+ "id" : "BB7F7563-199E-4896-BCDE-1F751C24B71F",
+ "name" : "Test Scheme Action",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "targetForVariableExpansion" : {
+ "containerPath" : "container:Coder Desktop.xcodeproj",
+ "identifier" : "961678FB2CFF100D00B2B6DF",
+ "name" : "Coder Desktop"
+ }
+ },
+ "testTargets" : [
+ {
+ "parallelizable" : true,
+ "target" : {
+ "containerPath" : "container:Coder Desktop.xcodeproj",
+ "identifier" : "9616790E2CFF100E00B2B6DF",
+ "name" : "Coder DesktopTests"
+ }
+ },
+ {
+ "parallelizable" : true,
+ "target" : {
+ "containerPath" : "container:Coder Desktop.xcodeproj",
+ "identifier" : "961679182CFF100E00B2B6DF",
+ "name" : "Coder DesktopUITests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/Coder Desktop/Coder Desktop/About.swift b/Coder Desktop/Coder Desktop/About.swift
new file mode 100644
index 0000000..7f040a1
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/About.swift
@@ -0,0 +1,47 @@
+import SwiftUI
+
+enum About {
+ private static var credits: NSAttributedString {
+ let coder = NSMutableAttributedString(
+ string: "Coder.com",
+ attributes: [
+ .foregroundColor: NSColor.labelColor,
+ .link: NSURL(string: "https://coder.com")!,
+ .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
+ ]
+ )
+ let separator = NSAttributedString(
+ string: " | ",
+ attributes: [
+ .foregroundColor: NSColor.labelColor,
+ .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
+ ]
+ )
+ let source = NSAttributedString(
+ string: "GitHub",
+ attributes: [
+ .foregroundColor: NSColor.labelColor,
+ .link: NSURL(string: "https://github.com/coder/coder-desktop-macos")!,
+ .font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
+ ]
+ )
+ coder.append(separator)
+ coder.append(source)
+ return coder
+ }
+
+ static func open() {
+ #if compiler(>=5.9) && canImport(AppKit)
+ if #available(macOS 14, *) {
+ NSApp.activate()
+ } else {
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ #else
+ NSApp.activate(ignoringOtherApps: true)
+ #endif
+ NSApp.orderFrontStandardAboutPanel(options: [
+ .credits: credits,
+ ])
+ }
+}
diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
index bde4a63..26e5ab4 100644
--- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
+++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift
@@ -1,5 +1,5 @@
-import SwiftUI
import FluidMenuBarExtra
+import SwiftUI
@main
struct DesktopApp: App {
@@ -10,21 +10,33 @@ struct DesktopApp: App {
MenuBarExtra("", isInserted: $hidden) {
EmptyView()
}
+ Window("Sign In", id: Windows.login.rawValue) {
+ LoginForm()
+ }.environmentObject(appDelegate.session)
+ .windowResizability(.contentSize)
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
private var menuBarExtra: FluidMenuBarExtra?
- // TODO: Replace with real implementations
- private var vpn = PreviewVPN()
- private var session = PreviewSession()
+ let vpn: PreviewVPN
+ let session: PreviewSession
+
+ override init() {
+ // TODO: Replace with real implementations
+ vpn = PreviewVPN()
+ session = PreviewSession()
+ }
- func applicationDidFinishLaunching(_ notification: Notification) {
- self.menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") {
- VPNMenu(
- vpn: self.vpn,
- session: self.session
- ).frame(width: 256)
+ func applicationDidFinishLaunching(_: Notification) {
+ menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") {
+ VPNMenu().frame(width: 256)
+ .environmentObject(self.vpn)
+ .environmentObject(self.session)
}
}
+
+ func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool {
+ false
+ }
}
diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift
new file mode 100644
index 0000000..e21e8c6
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewClient.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+struct PreviewClient: Client {
+ init(url _: URL, token _: String? = nil) {}
+
+ func user(_: String) async throws -> User {
+ try await Task.sleep(for: .seconds(1))
+ return User(
+ id: UUID(),
+ username: "admin",
+ avatar_url: "",
+ name: "admin",
+ email: "admin@coder.com",
+ created_at: Date.now,
+ updated_at: Date.now,
+ last_seen_at: Date.now,
+ status: "active",
+ login_type: "none",
+ theme_preference: "dark",
+ organization_ids: [],
+ roles: []
+ )
+ }
+}
diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift
index c567207..c4022ff 100644
--- a/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift
+++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift
@@ -11,15 +11,14 @@ class PreviewSession: Session {
baseAccessURL = nil
}
- func login(baseAccessURL: URL, sessionToken: String) {
+ func store(baseAccessURL: URL, sessionToken: String) {
hasSession = true
self.baseAccessURL = baseAccessURL
self.sessionToken = sessionToken
}
- func logout() {
+ func clear() {
hasSession = false
- self.baseAccessURL = nil
- self.sessionToken = nil
+ sessionToken = nil
}
}
diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
index b46194b..2599db4 100644
--- a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
+++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
@@ -5,15 +5,13 @@ class PreviewVPN: Coder_Desktop.VPNService {
@Published var agents: [Coder_Desktop.Agent] = [
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
- workspaceName: "testing-a-very-long-name"
- ),
+ workspaceName: "testing-a-very-long-name"),
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
Agent(id: UUID(), name: "dogfood2", status: .error, copyableDNS: "asdf.coder", workspaceName: "dogfood2"),
Agent(id: UUID(), name: "testing-a-very-long-name", status: .okay, copyableDNS: "asdf.coder",
- workspaceName: "testing-a-very-long-name"
- ),
+ workspaceName: "testing-a-very-long-name"),
Agent(id: UUID(), name: "opensrc", status: .warn, copyableDNS: "asdf.coder", workspaceName: "opensrc"),
Agent(id: UUID(), name: "gvisor", status: .off, copyableDNS: "asdf.coder", workspaceName: "gvisor"),
Agent(id: UUID(), name: "example", status: .off, copyableDNS: "asdf.coder", workspaceName: "example"),
@@ -33,7 +31,7 @@ class PreviewVPN: Coder_Desktop.VPNService {
func start() async {
await setState(.connecting)
do {
- try await Task.sleep(nanoseconds: 1000000000)
+ try await Task.sleep(for: .seconds(1))
} catch {
await setState(.failed(.exampleError))
return
@@ -49,7 +47,7 @@ class PreviewVPN: Coder_Desktop.VPNService {
guard state == .connected else { return }
await setState(.disconnecting)
do {
- try await Task.sleep(nanoseconds: 1000000000) // Simulate network delay
+ try await Task.sleep(for: .seconds(1))
} catch {
await setState(.failed(.exampleError))
return
diff --git a/Coder Desktop/Coder Desktop/SDK/Client.swift b/Coder Desktop/Coder Desktop/SDK/Client.swift
new file mode 100644
index 0000000..0e32e91
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/SDK/Client.swift
@@ -0,0 +1,62 @@
+import Alamofire
+import Foundation
+
+protocol Client {
+ init(url: URL, token: String?)
+ func user(_ ident: String) async throws -> User
+}
+
+struct CoderClient: Client {
+ public let url: URL
+ public var token: String?
+
+ static let decoder: JSONDecoder = {
+ var dec = JSONDecoder()
+ dec.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds
+ return dec
+ }()
+
+ let encoder: JSONEncoder = {
+ var enc = JSONEncoder()
+ enc.dateEncodingStrategy = .iso8601withFractionalSeconds
+ return enc
+ }()
+
+ func request(
+ _ path: String,
+ method: HTTPMethod,
+ body: T
+ ) async -> DataResponse {
+ let url = self.url.appendingPathComponent(path)
+ let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
+ return await AF.request(
+ url,
+ method: method,
+ parameters: body,
+ encoder: JSONParameterEncoder.default,
+ headers: headers
+ ).serializingData().response
+ }
+
+ func request(
+ _ path: String,
+ method: HTTPMethod
+ ) async -> DataResponse {
+ let url = self.url.appendingPathComponent(path)
+ let headers: HTTPHeaders = [Headers.sessionToken: token ?? ""]
+ return await AF.request(
+ url,
+ method: method,
+ headers: headers
+ ).serializingData().response
+ }
+}
+
+enum ClientError: Error {
+ case unexpectedStatusCode
+ case badResponse
+}
+
+enum Headers {
+ static let sessionToken = "Coder-Session-Token"
+}
diff --git a/Coder Desktop/Coder Desktop/SDK/Date.swift b/Coder Desktop/Coder Desktop/SDK/Date.swift
new file mode 100644
index 0000000..05d536f
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/SDK/Date.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+// Handling for ISO8601 Timestamps with fractional seconds
+// Directly from https://stackoverflow.com/questions/46458487/
+
+extension ParseStrategy where Self == Date.ISO8601FormatStyle {
+ static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) }
+}
+
+extension JSONDecoder.DateDecodingStrategy {
+ static let iso8601withOptionalFractionalSeconds = custom {
+ let string = try $0.singleValueContainer().decode(String.self)
+ do {
+ return try .init(string, strategy: .iso8601withFractionalSeconds)
+ } catch {
+ return try .init(string, strategy: .iso8601)
+ }
+ }
+}
+
+extension FormatStyle where Self == Date.ISO8601FormatStyle {
+ static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) }
+}
+
+extension JSONEncoder.DateEncodingStrategy {
+ static let iso8601withFractionalSeconds = custom {
+ var container = $1.singleValueContainer()
+ try container.encode($0.formatted(.iso8601withFractionalSeconds))
+ }
+}
diff --git a/Coder Desktop/Coder Desktop/SDK/User.swift b/Coder Desktop/Coder Desktop/SDK/User.swift
new file mode 100644
index 0000000..4ca26eb
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/SDK/User.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+extension CoderClient {
+ func user(_ ident: String) async throws -> User {
+ let resp = await request("/api/v2/users/\(ident)", method: .get)
+ guard let response = resp.response, response.statusCode == 200 else {
+ throw ClientError.unexpectedStatusCode
+ }
+ guard let data = resp.data else {
+ throw ClientError.badResponse
+ }
+ return try CoderClient.decoder.decode(User.self, from: data)
+ }
+}
+
+struct User: Decodable {
+ let id: UUID
+ let username: String
+ let avatar_url: String
+ let name: String
+ let email: String
+ let created_at: Date
+ let updated_at: Date
+ let last_seen_at: Date
+ let status: String
+ let login_type: String
+ let theme_preference: String
+ let organization_ids: [UUID]
+ let roles: [Role]
+}
+
+struct Role: Decodable {
+ let name: String
+ let display_name: String
+ let organization_id: UUID?
+}
diff --git a/Coder Desktop/Coder Desktop/Session.swift b/Coder Desktop/Coder Desktop/Session.swift
index 95036d7..ec99d3e 100644
--- a/Coder Desktop/Coder Desktop/Session.swift
+++ b/Coder Desktop/Coder Desktop/Session.swift
@@ -1,64 +1,73 @@
-import KeychainAccess
import Foundation
+import KeychainAccess
protocol Session: ObservableObject {
var hasSession: Bool { get }
- var sessionToken: String? { get }
var baseAccessURL: URL? { get }
+ var sessionToken: String? { get }
- func login(baseAccessURL: URL, sessionToken: String)
- func logout()
+ func store(baseAccessURL: URL, sessionToken: String)
+ func clear()
}
class SecureSession: ObservableObject {
+ // Stored in UserDefaults
@Published private(set) var hasSession: Bool {
didSet {
- UserDefaults.standard.set(hasSession, forKey: "hasSession")
+ UserDefaults.standard.set(hasSession, forKey: Keys.hasSession)
}
}
- @Published private(set) var sessionToken: String? {
+
+ @Published private(set) var baseAccessURL: URL? {
didSet {
- setValue(sessionToken, for: "sessionToken")
+ UserDefaults.standard.set(baseAccessURL, forKey: Keys.baseAccessURL)
}
}
- @Published private(set) var baseAccessURL: URL? {
+
+ // Stored in Keychain
+ @Published private(set) var sessionToken: String? {
didSet {
- setValue(baseAccessURL?.absoluteString, for: "baseAccessURL")
+ keychainSet(sessionToken, for: Keys.sessionToken)
}
}
+
private let keychain: Keychain
public init() {
keychain = Keychain(service: Bundle.main.bundleIdentifier!)
- _hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: "hasSession"))
+ _hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: Keys.hasSession))
+ _baseAccessURL = Published(initialValue: UserDefaults.standard.url(forKey: Keys.baseAccessURL))
if hasSession {
- _sessionToken = Published(initialValue: getValue(for: "sessionToken"))
- _baseAccessURL = Published(initialValue: getValue(for: "baseAccessURL").flatMap(URL.init))
+ _sessionToken = Published(initialValue: keychainGet(for: Keys.sessionToken))
}
}
- public func login(baseAccessURL: URL, sessionToken: String) {
+ public func store(baseAccessURL: URL, sessionToken: String) {
hasSession = true
self.baseAccessURL = baseAccessURL
self.sessionToken = sessionToken
}
- // Called when the user logs out, or if we find out the token has expired
- public func logout() {
+ public func clear() {
hasSession = false
sessionToken = nil
- baseAccessURL = nil
}
- private func getValue(for key: String) -> String? {
+ private func keychainGet(for key: String) -> String? {
try? keychain.getString(key)
}
- private func setValue(_ value: String?, for key: String) {
+ private func keychainSet(_ value: String?, for key: String) {
if let value = value {
try? keychain.set(value, key: key)
} else {
try? keychain.remove(key)
}
}
+
+ enum Keys {
+ static let hasSession = "hasSession"
+ static let baseAccessURL = "baseAccessURL"
+ static let sessionToken = "sessionToken"
+ }
}
diff --git a/Coder Desktop/Coder Desktop/Theme.swift b/Coder Desktop/Coder Desktop/Theme.swift
index b44a610..1303a4c 100644
--- a/Coder Desktop/Coder Desktop/Theme.swift
+++ b/Coder Desktop/Coder Desktop/Theme.swift
@@ -8,4 +8,5 @@ enum Theme {
static let rectCornerRadius: CGFloat = 4
}
+ static let defaultVisibleAgents = 5
}
diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift
index 8c2c5f3..3f535c7 100644
--- a/Coder Desktop/Coder Desktop/VPNService.swift
+++ b/Coder Desktop/Coder Desktop/VPNService.swift
@@ -17,7 +17,7 @@ enum VPNServiceState: Equatable {
}
enum VPNServiceError: Error, Equatable {
- // TODO:
+ // TODO:
case exampleError
var description: String {
diff --git a/Coder Desktop/Coder Desktop/Views/Agents.swift b/Coder Desktop/Coder Desktop/Views/Agents.swift
index e11804d..79c402f 100644
--- a/Coder Desktop/Coder Desktop/Views/Agents.swift
+++ b/Coder Desktop/Coder Desktop/Views/Agents.swift
@@ -6,23 +6,27 @@ struct Agents: View {
@State private var viewAll = false
private let defaultVisibleRows = 5
+ internal let inspection = Inspection()
+
var body: some View {
- // Workspaces List
- if vpn.state == .connected {
- let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows))
- ForEach(visibleData, id: \.id) { workspace in
- AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!)
- .padding(.horizontal, Theme.Size.trayMargin)
- }
- if vpn.agents.count > defaultVisibleRows {
- Toggle(isOn: $viewAll) {
- Text(viewAll ? "Show Less" : "Show All")
- .font(.headline)
- .foregroundColor(.gray)
- .padding(.horizontal, Theme.Size.trayInset)
- .padding(.top, 2)
- }.toggleStyle(.button).buttonStyle(.plain)
+ Group {
+ // Workspaces List
+ if vpn.state == .connected {
+ let visibleData = viewAll ? vpn.agents : Array(vpn.agents.prefix(defaultVisibleRows))
+ ForEach(visibleData, id: \.id) { workspace in
+ AgentRowView(workspace: workspace, baseAccessURL: session.baseAccessURL!)
+ .padding(.horizontal, Theme.Size.trayMargin)
+ }
+ if vpn.agents.count > defaultVisibleRows {
+ Toggle(isOn: $viewAll) {
+ Text(viewAll ? "Show Less" : "Show All")
+ .font(.headline)
+ .foregroundColor(.gray)
+ .padding(.horizontal, Theme.Size.trayInset)
+ .padding(.top, 2)
+ }.toggleStyle(.button).buttonStyle(.plain)
+ }
}
- }
+ }.onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
}
}
diff --git a/Coder Desktop/Coder Desktop/Views/AuthButton.swift b/Coder Desktop/Coder Desktop/Views/AuthButton.swift
index 86abf07..cfab088 100644
--- a/Coder Desktop/Coder Desktop/Views/AuthButton.swift
+++ b/Coder Desktop/Coder Desktop/Views/AuthButton.swift
@@ -3,21 +3,21 @@ import SwiftUI
struct AuthButton: View {
@EnvironmentObject var session: S
@EnvironmentObject var vpn: VPN
+ @Environment(\.openWindow) var openWindow
var body: some View {
Button {
if session.hasSession {
Task {
await vpn.stop()
- session.logout()
+ session.clear()
}
} else {
- // TODO: Login flow
- session.login(baseAccessURL: URL(string: "https://dev.coder.com")!, sessionToken: "fake-token")
+ openWindow(id: .login)
}
} label: {
ButtonRowView {
- Text(session.hasSession ? "Logout" : "Login")
+ Text(session.hasSession ? "Sign Out" : "Sign In")
}
}.buttonStyle(.plain)
}
diff --git a/Coder Desktop/Coder Desktop/Views/LoginForm.swift b/Coder Desktop/Coder Desktop/Views/LoginForm.swift
new file mode 100644
index 0000000..2888d0b
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/Views/LoginForm.swift
@@ -0,0 +1,195 @@
+import SwiftUI
+
+struct LoginForm: View {
+ @EnvironmentObject var session: S
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var baseAccessURL: String = ""
+ @State private var sessionToken: String = ""
+ @State private var loginError: LoginError?
+ @State private var currentPage: LoginPage = .serverURL
+ @State private var loading: Bool = false
+ @FocusState private var focusedField: LoginField?
+
+ internal let inspection = Inspection()
+
+ var body: some View {
+ VStack {
+ VStack {
+ switch currentPage {
+ case .serverURL:
+ serverURLPage
+ .transition(.move(edge: .leading))
+ .onAppear {
+ DispatchQueue.main.async {
+ focusedField = .baseAccessURL
+ }
+ }
+ case .sessionToken:
+ sessionTokenPage
+ .transition(.move(edge: .trailing))
+ .onAppear {
+ DispatchQueue.main.async {
+ focusedField = .sessionToken
+ }
+ }
+ }
+ }
+ .animation(.easeInOut, value: currentPage)
+ .onAppear {
+ loginError = nil
+ baseAccessURL = session.baseAccessURL?.absoluteString ?? baseAccessURL
+ sessionToken = ""
+ }.padding(.top, 35)
+ VStack(alignment: .center) {
+ if let loginError {
+ Text("\(loginError.description)")
+ .font(.headline)
+ .foregroundColor(.red)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .frame(height: 35)
+ }.padding()
+ .frame(width: 450, height: 220)
+ .disabled(loading)
+ .onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
+ }
+
+ internal func submit() async {
+ loginError = nil
+ guard sessionToken != "" else {
+ loginError = .invalidToken
+ return
+ }
+ guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
+ loginError = .invalidURL
+ return
+ }
+ loading = true
+ defer { loading = false}
+ let client = C(url: url, token: sessionToken)
+ do {
+ _ = try await client.user("me")
+ } catch {
+ loginError = .failedAuth
+ print("Set error")
+ return
+ }
+ session.store(baseAccessURL: url, sessionToken: sessionToken)
+ dismiss()
+ }
+
+ private var serverURLPage: some View {
+ VStack(spacing: 15) {
+ Text("Coder Desktop").font(.title).padding(.bottom, 15)
+ VStack(alignment: .leading) {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Server URL")
+ Spacer()
+ TextField("https://coder.example.com", text: $baseAccessURL)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .disableAutocorrection(true)
+ .frame(width: 290, alignment: .leading)
+ .focused($focusedField, equals: .baseAccessURL)
+ }
+ }
+ HStack {
+ Button("Next", action: next)
+ .buttonStyle(.borderedProminent)
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(.top, 10)
+ }.padding(.horizontal, 15)
+ }
+
+ private var sessionTokenPage: some View {
+ VStack {
+ VStack(alignment: .leading) {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Server URL")
+ Spacer()
+ TextField("https://coder.example.com", text: $baseAccessURL)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .disableAutocorrection(true)
+ .frame(width: 290, alignment: .leading)
+ .disabled(true)
+ }
+ HStack(alignment: .firstTextBaseline) {
+ Text("Session Token")
+ Spacer()
+ SecureField("", text: $sessionToken)
+ .textFieldStyle(RoundedBorderTextFieldStyle())
+ .disableAutocorrection(true)
+ .frame(width: 290, alignment: .leading)
+ .privacySensitive()
+ .focused($focusedField, equals: .sessionToken)
+ }
+ Link(
+ "Generate a token via the Web UI",
+ destination: URL(string: baseAccessURL)!.appendingPathComponent("cli-auth")
+ ).font(.callout).foregroundColor(.blue).underline()
+ }.padding()
+ HStack {
+ Button("Back", action: back)
+ Button("Sign In") {
+ Task { await submit() }
+ }
+ .buttonStyle(.borderedProminent)
+ .keyboardShortcut(.defaultAction)
+ }.padding(.top, 5)
+ }
+ }
+
+ private func next() {
+ loginError = nil
+ guard let url = URL(string: baseAccessURL), url.scheme == "https" else {
+ loginError = .invalidURL
+ return
+ }
+ withAnimation {
+ currentPage = .sessionToken
+ focusedField = .sessionToken
+ }
+ }
+
+ private func back() {
+ withAnimation {
+ loginError = nil
+ currentPage = .serverURL
+ focusedField = .baseAccessURL
+ }
+ }
+}
+
+enum LoginError {
+ case invalidURL
+ case invalidToken
+ case failedAuth
+
+ var description: String {
+ switch self {
+ case .invalidURL:
+ return "Invalid URL"
+ case .invalidToken:
+ return "Invalid Session Token"
+ case .failedAuth:
+ return "Could not authenticate with Coder deployment"
+ }
+ }
+}
+
+enum LoginPage {
+ case serverURL
+ case sessionToken
+}
+
+enum LoginField: Hashable {
+ case baseAccessURL
+ case sessionToken
+}
+
+#Preview {
+ LoginForm()
+ .environmentObject(PreviewSession())
+}
diff --git a/Coder Desktop/Coder Desktop/Views/Util.swift b/Coder Desktop/Coder Desktop/Views/Util.swift
new file mode 100644
index 0000000..8f42891
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/Views/Util.swift
@@ -0,0 +1,13 @@
+import Combine
+
+// This is required for inspecting stateful views
+internal final class Inspection {
+ let notice = PassthroughSubject()
+ var callbacks = [UInt: (V) -> Void]()
+
+ func visit(_ view: V, _ line: UInt) {
+ if let callback = callbacks.removeValue(forKey: line) {
+ callback(view)
+ }
+ }
+}
diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift
index 6e43947..814c828 100644
--- a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift
+++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift
@@ -1,8 +1,10 @@
import SwiftUI
struct VPNMenu: View {
- @ObservedObject var vpn: VPN
- @ObservedObject var session: S
+ @EnvironmentObject var vpn: VPN
+ @EnvironmentObject var session: S
+
+ internal let inspection = Inspection()
var body: some View {
// Main stack
@@ -13,15 +15,14 @@ struct VPNMenu: View {
Toggle(isOn: Binding(
get: { self.vpn.state == .connected || self.vpn.state == .connecting },
set: { isOn in Task {
- if isOn { await self.vpn.start() } else { await self.vpn.stop() }
- }
+ if isOn { await self.vpn.start() } else { await self.vpn.stop() }
+ }
}
)) {
Text("CoderVPN")
.frame(maxWidth: .infinity, alignment: .leading)
}.toggleStyle(.switch)
.disabled(vpnDisabled)
- .accessibilityIdentifier("coderVPNToggle")
}
Divider()
Text("Workspace Agents")
@@ -30,7 +31,7 @@ struct VPNMenu: View {
if session.hasSession {
VPNState()
} else {
- Text("Login to use CoderVPN")
+ Text("Sign in to use CoderVPN")
.font(.body)
.foregroundColor(.gray)
}
@@ -49,8 +50,12 @@ struct VPNMenu: View {
TrayDivider()
}
AuthButton()
- ButtonRowView {
- Text("About")
+ Button {
+ About.open()
+ } label: {
+ ButtonRowView {
+ Text("About")
+ }
}.buttonStyle(.plain)
TrayDivider()
Button {
@@ -67,18 +72,18 @@ struct VPNMenu: View {
}.padding(.bottom, Theme.Size.trayMargin)
.environmentObject(vpn)
.environmentObject(session)
+ .onReceive(inspection.notice) { self.inspection.visit(self, $0) } // ViewInspector
}
private var vpnDisabled: Bool {
return !session.hasSession ||
- vpn.state == .connecting ||
- vpn.state == .disconnecting
+ vpn.state == .connecting ||
+ vpn.state == .disconnecting
}
}
#Preview {
- VPNMenu(
- vpn: PreviewVPN(shouldFail: false),
- session: PreviewSession()
- ).frame(width: 256)
+ VPNMenu().frame(width: 256)
+ .environmentObject(PreviewVPN())
+ .environmentObject(PreviewSession())
}
diff --git a/Coder Desktop/Coder Desktop/Views/VPNState.swift b/Coder Desktop/Coder Desktop/Views/VPNState.swift
index b1e5466..05f1c8b 100644
--- a/Coder Desktop/Coder Desktop/Views/VPNState.swift
+++ b/Coder Desktop/Coder Desktop/Views/VPNState.swift
@@ -3,31 +3,36 @@ import SwiftUI
struct VPNState: View {
@EnvironmentObject var vpn: VPN
+ internal let inspection = Inspection()
+
var body: some View {
- switch vpn.state {
- case .disabled:
- Text("Enable CoderVPN to see agents")
- .font(.body)
- .foregroundColor(.gray)
- case .connecting, .disconnecting:
- HStack {
- Spacer()
- ProgressView(
- vpn.state == .connecting ? "Starting CoderVPN..." : "Stopping CoderVPN..."
- ).padding()
- Spacer()
+ Group {
+ switch vpn.state {
+ case .disabled:
+ Text("Enable CoderVPN to see agents")
+ .font(.body)
+ .foregroundColor(.gray)
+ case .connecting, .disconnecting:
+ HStack {
+ Spacer()
+ ProgressView(
+ vpn.state == .connecting ? "Starting CoderVPN..." : "Stopping CoderVPN..."
+ ).padding()
+ Spacer()
+ }
+ case let .failed(vpnErr):
+ Text("\(vpnErr.description)")
+ .font(.headline)
+ .foregroundColor(.red)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, Theme.Size.trayInset)
+ .padding(.vertical, Theme.Size.trayPadding)
+ .frame(maxWidth: .infinity)
+ default:
+ EmptyView()
}
- case let .failed(vpnErr):
- Text("\(vpnErr.description)")
- .font(.headline)
- .foregroundColor(.red)
- .multilineTextAlignment(.center)
- .fixedSize(horizontal: false, vertical: true)
- .padding(.horizontal, Theme.Size.trayInset)
- .padding(.vertical, Theme.Size.trayPadding)
- .frame(maxWidth: .infinity)
- default:
- EmptyView()
}
+ .onReceive(inspection.notice) { inspection.visit(self, $0) } // viewInspector
}
}
diff --git a/Coder Desktop/Coder Desktop/Windows.swift b/Coder Desktop/Coder Desktop/Windows.swift
new file mode 100644
index 0000000..e82680c
--- /dev/null
+++ b/Coder Desktop/Coder Desktop/Windows.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+// Window IDs
+enum Windows: String {
+ case login
+}
+
+extension OpenWindowAction {
+ // Type-safe wrapper for opening windows that also focuses the new window
+ func callAsFunction(id: Windows) {
+ #if compiler(>=5.9) && canImport(AppKit)
+ if #available(macOS 14, *) {
+ NSApp.activate()
+ } else {
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ #else
+ NSApp.activate(ignoringOtherApps: true)
+ #endif
+ callAsFunction(id: id.rawValue)
+ // The arranging behaviour is flakey without this
+ NSApp.arrangeInFront(nil)
+ }
+}
diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift
index 978ed2f..e6c679a 100644
--- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift
+++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift
@@ -1,10 +1,24 @@
@testable import Coder_Desktop
+import Testing
import ViewInspector
-import XCTest
+import SwiftUI
+
+@Suite(.timeLimit(.minutes(1)))
+struct AgentsTests {
+ let vpn: MockVPNService
+ let session: MockSession
+ let sut: Agents
+ let view: any View
+
+ init() {
+ vpn = MockVPNService()
+ session = MockSession()
+ sut = Agents()
+ view = sut.environmentObject(vpn).environmentObject(session)
+ }
-final class AgentsTests: XCTestCase {
private func createMockAgents(count: Int) -> [Agent] {
- return (1...count).map {
+ return (1 ... count).map {
Agent(
id: UUID(),
name: "a\($0)",
@@ -15,34 +29,62 @@ final class AgentsTests: XCTestCase {
}
}
- func testAgentsWhenVPNOff() throws {
- let vpn = MockVPNService()
+ @Test
+ @MainActor
+ func agentsWhenVPNOff() throws {
vpn.state = .disabled
- let session = MockSession()
- let view = Agents().environmentObject(vpn).environmentObject(session)
- XCTAssertThrowsError(try view.inspect().find(ViewType.ForEach.self))
+ #expect(throws: (any Error).self) {
+ _ = try view.inspect().find(ViewType.ForEach.self)
+ }
}
- func testAgentsWhenVPNOn() throws {
- let vpn = MockVPNService()
+ @Test
+ @MainActor
+ func agentsWhenVPNOn() throws {
vpn.state = .connected
- vpn.agents = createMockAgents(count: 7)
- let session = MockSession()
- let view = Agents().environmentObject(vpn).environmentObject(session)
+ vpn.agents = createMockAgents(count: Theme.defaultVisibleAgents + 2)
let forEach = try view.inspect().find(ViewType.ForEach.self)
- XCTAssertEqual(forEach.count, 5)
- let _ = try view.inspect().find(link: "a1.coder")
+ #expect(forEach.count == Theme.defaultVisibleAgents)
+ #expect(throws: Never.self) { try view.inspect().find(link: "a1.coder")}
+ }
+
+ @Test
+ @MainActor
+ func showAllToggle() async throws {
+ vpn.state = .connected
+ vpn.agents = createMockAgents(count: 7)
+
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ var toggle = try view.find(ViewType.Toggle.self)
+ #expect(try toggle.labelView().text().string() == "Show All")
+ #expect(try !toggle.isOn())
+
+ try toggle.tap()
+ toggle = try view.find(ViewType.Toggle.self)
+ var forEach = try view.find(ViewType.ForEach.self)
+ #expect(forEach.count == Theme.defaultVisibleAgents + 2)
+ #expect(try toggle.labelView().text().string() == "Show Less")
+
+ try toggle.tap()
+ toggle = try view.find(ViewType.Toggle.self)
+ forEach = try view.find(ViewType.ForEach.self)
+ #expect(try toggle.labelView().text().string() == "Show All")
+ #expect(forEach.count == Theme.defaultVisibleAgents)
+ }
+ }
}
- func testNoToggleWhenAgentsAreFew() throws {
- let vpn = MockVPNService()
+ @Test
+ @MainActor
+ func noToggleFewAgents() throws {
vpn.state = .connected
vpn.agents = createMockAgents(count: 3)
- let session = MockSession()
- let view = Agents().environmentObject(vpn).environmentObject(session)
- XCTAssertThrowsError(try view.inspect().find(ViewType.Toggle.self))
+ #expect(throws: (any Error).self) {
+ _ = try view.inspect().find(ViewType.Toggle.self)
+ }
}
}
diff --git a/Coder Desktop/Coder DesktopTests/LoginFormTests.swift b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift
new file mode 100644
index 0000000..3f3b547
--- /dev/null
+++ b/Coder Desktop/Coder DesktopTests/LoginFormTests.swift
@@ -0,0 +1,117 @@
+@testable import Coder_Desktop
+import ViewInspector
+import Testing
+import SwiftUI
+
+@Suite(.timeLimit(.minutes(1)))
+struct LoginTests {
+ let session: MockSession
+ let sut: LoginForm
+ let view: any View
+
+ init() {
+ session = MockSession()
+ sut = LoginForm()
+ view = sut.environmentObject(session)
+ }
+
+ @Test
+ @MainActor
+ func testInitialView() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ #expect(throws: Never.self) { try view.find(text: "Coder Desktop") }
+ #expect(throws: Never.self) { try view.find(text: "Server URL") }
+ #expect(throws: Never.self) { try view.find(button: "Next") }
+ }
+ }
+ }
+
+ @Test
+ @MainActor
+ func testInvalidServerURL() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ try view.find(ViewType.TextField.self).setInput("")
+ try view.find(button: "Next").tap()
+ #expect(throws: Never.self) { try view.find(text: "Invalid URL") }
+ }
+ }
+ }
+
+ @Test
+ @MainActor
+ func testValidServerURL() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
+ try view.find(button: "Next").tap()
+
+ #expect(throws: Never.self) { try view.find(text: "Session Token") }
+ #expect(throws: Never.self) { try view.find(ViewType.SecureField.self) }
+ #expect(throws: Never.self) { try view.find(button: "Sign In") }
+ }
+ }
+ }
+
+ @Test
+ @MainActor
+ func testBackButton() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
+ try view.find(button: "Next").tap()
+ try view.find(button: "Back").tap()
+
+ #expect(throws: Never.self) { try view.find(text: "Coder Desktop") }
+ #expect(throws: Never.self) { try view.find(button: "Next") }
+ }
+ }
+ }
+
+ @Test
+ @MainActor
+ func testInvalidSessionToken() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
+ try view.find(button: "Next").tap()
+ try view.find(ViewType.SecureField.self).setInput("")
+ try await view.actualView().submit()
+ #expect(throws: Never.self) { try view.find(text: "Invalid Session Token") }
+ }
+ }
+ }
+
+ @Test
+ @MainActor
+ func testFailedAuthentication() async throws {
+ let login = LoginForm()
+
+ try await ViewHosting.host(login.environmentObject(session)) { _ in
+ try await login.inspection.inspect { view in
+ try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
+ try view.find(button: "Next").tap()
+ #expect(throws: Never.self) { try view.find(text: "Session Token") }
+ try view.find(ViewType.SecureField.self).setInput("valid-token")
+ try await view.actualView().submit()
+ #expect(throws: Never.self) { try view.find(text: "Could not authenticate with Coder deployment") }
+ }
+ }
+ }
+
+ @Test
+ @MainActor
+ func testSuccessfulLogin() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ try view.find(ViewType.TextField.self).setInput("https://coder.example.com")
+ try view.find(button: "Next").tap()
+ try view.find(ViewType.SecureField.self).setInput("valid-token")
+ try view.find(button: "Sign In").tap()
+
+ #expect(session.hasSession)
+ }
+ }
+ }
+}
diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift
index 7afba27..cab9a8f 100644
--- a/Coder Desktop/Coder DesktopTests/Util.swift
+++ b/Coder Desktop/Coder DesktopTests/Util.swift
@@ -1,22 +1,24 @@
-import SwiftUI
@testable import Coder_Desktop
+import Combine
+import SwiftUI
+import ViewInspector
class MockVPNService: VPNService, ObservableObject {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
- @Published var baseAccessURL: URL = URL(string: "https://dev.coder.com")!
+ @Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
@Published var agents: [Coder_Desktop.Agent] = []
var onStart: (() async -> Void)?
var onStop: (() async -> Void)?
@MainActor
func start() async {
- self.state = .connecting
+ state = .connecting
await onStart?()
}
@MainActor
func stop() async {
- self.state = .disconnecting
+ state = .disconnecting
await onStop?()
}
}
@@ -29,15 +31,46 @@ class MockSession: Session {
@Published
var baseAccessURL: URL? = URL(string: "https://dev.coder.com")!
- func login(baseAccessURL: URL, sessionToken: String) {
+ func store(baseAccessURL _: URL, sessionToken _: String) {
hasSession = true
- self.baseAccessURL = URL(string: "https://dev.coder.com")!
- self.sessionToken = "fake-token"
+ baseAccessURL = URL(string: "https://dev.coder.com")!
+ sessionToken = "fake-token"
}
- func logout() {
+ func clear() {
hasSession = false
sessionToken = nil
baseAccessURL = nil
}
}
+
+struct MockClient: Client {
+ init(url _: URL, token _: String? = nil) {}
+
+ func user(_: String) async throws -> Coder_Desktop.User {
+ User(
+ id: UUID(),
+ username: "admin",
+ avatar_url: "",
+ name: "admin",
+ email: "admin@coder.com",
+ created_at: Date.now,
+ updated_at: Date.now,
+ last_seen_at: Date.now,
+ status: "active",
+ login_type: "none",
+ theme_preference: "dark",
+ organization_ids: [],
+ roles: []
+ )
+ }
+}
+
+struct MockErrorClient: Client {
+ init(url: URL, token: String?) {}
+ func user(_ ident: String) async throws -> Coder_Desktop.User {
+ throw ClientError.badResponse
+ }
+}
+
+extension Inspection: @retroactive InspectionEmissary { }
diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
index 1244351..f7d482c 100644
--- a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
+++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift
@@ -1,107 +1,126 @@
@testable import Coder_Desktop
+import Testing
import ViewInspector
-import XCTest
+import SwiftUI
-final class VPNMenuTests: XCTestCase {
- func testVPNLoggedOut() throws {
- let vpn = MockVPNService()
- let session = MockSession()
- session.hasSession = false
- let view = VPNMenu(vpn: vpn, session: session)
- let toggle = try view.inspect().find(ViewType.Toggle.self)
+@Suite(.timeLimit(.minutes(1)))
+struct VPNMenuTests {
+ let vpn: MockVPNService
+ let session: MockSession
+ let sut: VPNMenu
+ let view: any View
- XCTAssertTrue(toggle.isDisabled())
- XCTAssertNoThrow(try view.inspect().find(text: "Login to use CoderVPN"))
- XCTAssertNoThrow(try view.inspect().find(button: "Login"))
+ init() {
+ vpn = MockVPNService()
+ session = MockSession()
+ sut = VPNMenu()
+ view = sut.environmentObject(vpn).environmentObject(session)
}
- func testStartStopCalled() throws {
- let vpn = MockVPNService()
- let session = MockSession()
- let view = VPNMenu(vpn: vpn, session: session)
- let toggle = try view.inspect().find(ViewType.Toggle.self)
- XCTAssertFalse(try toggle.isOn())
-
- var e = expectation(description: "start is called")
- vpn.onStart = {
- vpn.state = .connected
- e.fulfill()
+ @Test
+ @MainActor
+ func testVPNLoggedOut() async throws {
+ session.hasSession = false
+
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ let toggle = try view.find(ViewType.Toggle.self)
+ #expect(toggle.isDisabled())
+ #expect(throws: Never.self) { try view.find(text: "Sign in to use CoderVPN") }
+ #expect(throws: Never.self) { try view.find(button: "Sign In") }
+ }
}
- try toggle.tap()
- wait(for: [e], timeout: 1.0)
- XCTAssertTrue(try toggle.isOn())
-
- e = expectation(description: "stop is called")
- vpn.onStop = {
- vpn.state = .disabled
- e.fulfill()
+ }
+
+ @Test
+ @MainActor
+ func testStartStopCalled() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ var toggle = try view.find(ViewType.Toggle.self)
+ #expect(try !toggle.isOn())
+
+ vpn.onStart = {
+ vpn.state = .connected
+ }
+ await vpn.start()
+
+ toggle = try view.find(ViewType.Toggle.self)
+ #expect(try toggle.isOn())
+
+ vpn.onStop = {
+ vpn.state = .disabled
+ }
+ await vpn.stop()
+ #expect(try !toggle.isOn())
+ }
}
- try toggle.tap()
- wait(for: [e], timeout: 1.0)
}
- func testVPNDisabledWhileConnecting() throws {
- let vpn = MockVPNService()
- let session = MockSession()
+ @Test
+ @MainActor
+ func testVPNDisabledWhileConnecting() async throws {
vpn.state = .disabled
- let view = VPNMenu(vpn: vpn, session: session)
- var toggle = try view.inspect().find(ViewType.Toggle.self)
- XCTAssertFalse(try toggle.isOn())
-
- let e = expectation(description: "start is called")
- vpn.onStart = {
- e.fulfill()
+
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ var toggle = try view.find(ViewType.Toggle.self)
+ #expect(try !toggle.isOn())
+
+ vpn.onStart = {
+ vpn.state = .connecting
+ }
+ await vpn.start()
+
+ toggle = try view.find(ViewType.Toggle.self)
+ #expect(toggle.isDisabled())
+ }
}
- try toggle.tap()
- wait(for: [e], timeout: 1.0)
-
- toggle = try view.inspect().find(ViewType.Toggle.self)
- XCTAssertTrue(toggle.isDisabled())
}
-
- func testVPNDisabledWhileDisconnecting() throws {
- let vpn = MockVPNService()
- let session = MockSession()
+
+ @Test
+ @MainActor
+ func testVPNDisabledWhileDisconnecting() async throws {
vpn.state = .disabled
- let view = VPNMenu(vpn: vpn, session: session)
- var toggle = try view.inspect().find(ViewType.Toggle.self)
- XCTAssertFalse(try toggle.isOn())
-
- var e = expectation(description: "start is called")
- vpn.onStart = {
- e.fulfill()
- vpn.state = .connected
- }
- try toggle.tap()
- wait(for: [e], timeout: 1.0)
-
- e = expectation(description: "stop is called")
- vpn.onStop = {
- e.fulfill()
+
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ var toggle = try view.find(ViewType.Toggle.self)
+ #expect(try !toggle.isOn())
+
+ vpn.onStart = {
+ vpn.state = .connected
+ }
+ await vpn.start()
+ #expect(try toggle.isOn())
+
+ vpn.onStop = {
+ vpn.state = .disconnecting
+ }
+ await vpn.stop()
+
+ toggle = try view.find(ViewType.Toggle.self)
+ #expect(toggle.isDisabled())
+ }
}
- try toggle.tap()
- wait(for: [e], timeout: 1.0)
-
- toggle = try view.inspect().find(ViewType.Toggle.self)
- XCTAssertTrue(toggle.isDisabled())
}
-
- func testOffWhenFailed() throws {
- let vpn = MockVPNService()
- let session = MockSession()
- let view = VPNMenu(vpn: vpn, session: session)
- let toggle = try view.inspect().find(ViewType.Toggle.self)
- XCTAssertFalse(try toggle.isOn())
-
- let e = expectation(description: "toggle is off")
- vpn.onStart = {
- vpn.state = .failed(.exampleError)
- e.fulfill()
+
+ @Test
+ @MainActor
+ func testOffWhenFailed() async throws {
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ let toggle = try view.find(ViewType.Toggle.self)
+ #expect(try !toggle.isOn())
+
+ vpn.onStart = {
+ vpn.state = .failed(.exampleError)
+ }
+ await vpn.start()
+
+ #expect(try !toggle.isOn())
+ #expect(!toggle.isDisabled())
+ }
}
- try toggle.tap()
- wait(for: [e], timeout: 1.0)
- XCTAssertFalse(try toggle.isOn())
- XCTAssertFalse(toggle.isDisabled())
}
-
}
diff --git a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
index 0934557..5a2c960 100644
--- a/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
+++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift
@@ -1,48 +1,84 @@
@testable import Coder_Desktop
import ViewInspector
-import XCTest
+import Testing
+import SwiftUI
-final class VPNStateTests: XCTestCase {
+@Suite(.timeLimit(.minutes(1)))
+struct VPNStateTests {
+ let vpn: MockVPNService
+ let sut: VPNState
+ let view: any View
- func testDisabledState() throws {
- let vpn = MockVPNService()
+ init() {
+ vpn = MockVPNService()
+ sut = VPNState()
+ view = sut.environmentObject(vpn)
+ }
+
+ @Test
+ @MainActor
+ func testDisabledState() async throws {
vpn.state = .disabled
- let view = VPNState().environmentObject(vpn)
- _ = try view.inspect().find(text: "Enable CoderVPN to see agents")
+
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ #expect(throws: Never.self) {
+ try view.find(text: "Enable CoderVPN to see agents")
+ }
+ }
+ }
}
- func testConnectingState() throws {
- let vpn = MockVPNService()
+ @Test
+ @MainActor
+ func testConnectingState() async throws {
vpn.state = .connecting
- let view = VPNState().environmentObject(vpn)
- let progressView = try view.inspect().find(ViewType.ProgressView.self)
- XCTAssertEqual(try progressView.labelView().text().string(), "Starting CoderVPN...")
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ let progressView = try view.find(ViewType.ProgressView.self)
+ #expect(try progressView.labelView().text().string() == "Starting CoderVPN...")
+ }
+ }
}
- func testDisconnectingState() throws {
- let vpn = MockVPNService()
+ @Test
+ @MainActor
+ func testDisconnectingState() async throws {
vpn.state = .disconnecting
- let view = VPNState().environmentObject(vpn)
- let progressView = try view.inspect().find(ViewType.ProgressView.self)
- XCTAssertEqual(try progressView.labelView().text().string(), "Stopping CoderVPN...")
+ try await ViewHosting.host(view) { _ in
+ try await sut.inspection.inspect { view in
+ let progressView = try view.find(ViewType.ProgressView.self)
+ #expect(try progressView.labelView().text().string() == "Stopping CoderVPN...")
+ }
+ }
}
- func testFailedState() throws {
- let vpn = MockVPNService()
+ @Test
+ @MainActor
+ func testFailedState() async throws {
vpn.state = .failed(.exampleError)
- let view = VPNState().environmentObject(vpn)
- let text = try view.inspect().find(ViewType.Text.self)
- XCTAssertEqual(try text.string(), VPNServiceError.exampleError.description)
+ try await ViewHosting.host(view.environmentObject(vpn)) { _ in
+ try await sut.inspection.inspect { view in
+ let text = try view.find(ViewType.Text.self)
+ #expect(try text.string() == VPNServiceError.exampleError.description)
+ }
+ }
}
- func testDefaultState() throws {
- let vpn = MockVPNService()
+ @Test
+ @MainActor
+ func testDefaultState() async throws {
vpn.state = .connected
- let view = VPNState().environmentObject(vpn)
- XCTAssertThrowsError(try view.inspect().find(ViewType.Text.self))
+ try await ViewHosting.host(view.environmentObject(vpn)) { _ in
+ try await sut.inspection.inspect { view in
+ #expect(throws: (any Error).self) {
+ _ = try view.find(ViewType.Text.self)
+ }
+ }
+ }
}
}
diff --git a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift
index cc2b586..7ceceec 100644
--- a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift
+++ b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift
@@ -1,7 +1,6 @@
import XCTest
final class Coder_DesktopUITests: XCTestCase {
-
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
diff --git a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift
index bde06fb..ad6b62c 100644
--- a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift
+++ b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITestsLaunchTests.swift
@@ -1,7 +1,6 @@
import XCTest
final class Coder_DesktopUITestsLaunchTests: XCTestCase {
-
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}