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 }