diff --git a/Coder Desktop/.swiftlint.yml b/Coder Desktop/.swiftlint.yml new file mode 100644 index 0000000..d824232 --- /dev/null +++ b/Coder Desktop/.swiftlint.yml @@ -0,0 +1,5 @@ +disabled_rules: + - todo + - trailing_comma +type_name: + allowed_symbols: "_" diff --git a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj index 91d1fbf..a17d2ff 100644 --- a/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj +++ b/Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* Begin PBXBuildFile section */ 961679332CFF117300B2B6DF /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 961679322CFF117300B2B6DF /* NetworkExtension.framework */; }; 9616793D2CFF117300B2B6DF /* com.coder.Coder-Desktop.VPN.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 961679302CFF117300B2B6DF /* com.coder.Coder-Desktop.VPN.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -98,6 +101,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */, + AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -105,6 +110,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AA8BC3392D0060A900E1ABAA /* ViewInspector in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -172,6 +178,7 @@ buildRules = ( ); dependencies = ( + AA8BC33C2D0060E700E1ABAA /* PBXTargetDependency */, 9616793C2CFF117300B2B6DF /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -179,6 +186,8 @@ ); name = "Coder Desktop"; packageProductDependencies = ( + AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */, + AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */, ); productName = "Coder Desktop"; productReference = 961678FC2CFF100D00B2B6DF /* Coder Desktop.app */; @@ -202,6 +211,7 @@ ); name = "Coder DesktopTests"; packageProductDependencies = ( + AA8BC3382D0060A900E1ABAA /* ViewInspector */, ); productName = "Coder DesktopTests"; productReference = 9616790F2CFF100E00B2B6DF /* Coder DesktopTests.xctest */; @@ -287,6 +297,12 @@ ); mainGroup = 961678F32CFF100D00B2B6DF; minimizedProjectReferenceProxies = 1; + packageReferences = ( + AA8BC3372D00609700E1ABAA /* XCRemoteSwiftPackageReference "ViewInspector" */, + AA8BC33A2D0060C500E1ABAA /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, + AA8BC33D2D0061F200E1ABAA /* XCRemoteSwiftPackageReference "fluid-menu-bar-extra" */, + AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 961678FD2CFF100D00B2B6DF /* Products */; projectDirPath = ""; @@ -378,6 +394,10 @@ target = 9616792F2CFF117300B2B6DF /* VPN */; targetProxy = 9616793B2CFF117300B2B6DF /* PBXContainerItemProxy */; }; + AA8BC33C2D0060E700E1ABAA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = AA8BC33B2D0060E700E1ABAA /* SwiftLintBuildToolPlugin */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -724,6 +744,64 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AA8BC3372D00609700E1ABAA /* XCRemoteSwiftPackageReference "ViewInspector" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/nalexn/ViewInspector"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.10.0; + }; + }; + AA8BC33A2D0060C500E1ABAA /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.57.1; + }; + }; + AA8BC33D2D0061F200E1ABAA /* XCRemoteSwiftPackageReference "fluid-menu-bar-extra" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/lfroms/fluid-menu-bar-extra"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; + AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess"; + requirement = { + branch = e0c7eebc5a4465a3c4680764f26b7a61f567cdaf; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AA8BC3382D0060A900E1ABAA /* ViewInspector */ = { + isa = XCSwiftPackageProductDependency; + package = AA8BC3372D00609700E1ABAA /* XCRemoteSwiftPackageReference "ViewInspector" */; + productName = ViewInspector; + }; + AA8BC33B2D0060E700E1ABAA /* SwiftLintBuildToolPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = AA8BC33A2D0060C500E1ABAA /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; + productName = "plugin:SwiftLintBuildToolPlugin"; + }; + AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */ = { + isa = XCSwiftPackageProductDependency; + package = AA8BC33D2D0061F200E1ABAA /* XCRemoteSwiftPackageReference "fluid-menu-bar-extra" */; + productName = FluidMenuBarExtra; + }; + AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; +/* 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 new file mode 100644 index 0000000..1070ac8 --- /dev/null +++ b/Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,41 @@ +{ + "originHash" : "726475d6c2c0355de7a4de72708853eaf53eb295e791efe2cc4b8eb5ce4e9ae8", + "pins" : [ + { + "identity" : "fluid-menu-bar-extra", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lfroms/fluid-menu-bar-extra", + "state" : { + "revision" : "e152a3a1a25aca24906217f8d4d63afbb08d7f97", + "version" : "1.1.0" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess", + "state" : { + "revision" : "e0c7eebc5a4465a3c4680764f26b7a61f567cdaf" + } + }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "f9731bef175c3eea3a0ca960f1be78fcc2bc7853", + "version" : "0.57.1" + } + }, + { + "identity" : "viewinspector", + "kind" : "remoteSourceControl", + "location" : "https://github.com/nalexn/ViewInspector", + "state" : { + "revision" : "5acfa0a3c095ac9ad050abe51c60d1831e8321da", + "version" : "0.10.0" + } + } + ], + "version" : 3 +} diff --git a/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json new file mode 100644 index 0000000..1035c9b --- /dev/null +++ b/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "filename" : "coder_icon_16.png", + "idiom" : "mac", + "scale" : "1x" + }, + { + "filename" : "coder_icon_32.png", + "idiom" : "mac", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png b/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png new file mode 100644 index 0000000..3112e48 Binary files /dev/null and b/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png differ diff --git a/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png b/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png new file mode 100644 index 0000000..1e3ae4b Binary files /dev/null and b/Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png differ diff --git a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift index a8dd38d..bde4a63 100644 --- a/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift +++ b/Coder Desktop/Coder Desktop/Coder_DesktopApp.swift @@ -1,25 +1,30 @@ import SwiftUI -import SwiftData +import FluidMenuBarExtra @main -struct Coder_DesktopApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) +struct DesktopApp: App { + @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate + @State private var hidden: Bool = false - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") + var body: some Scene { + MenuBarExtra("", isInserted: $hidden) { + EmptyView() } - }() + } +} - var body: some Scene { - WindowGroup { - ContentView() +class AppDelegate: NSObject, NSApplicationDelegate { + private var menuBarExtra: FluidMenuBarExtra? + // TODO: Replace with real implementations + private var vpn = PreviewVPN() + private var session = PreviewSession() + + func applicationDidFinishLaunching(_ notification: Notification) { + self.menuBarExtra = FluidMenuBarExtra(title: "Coder Desktop", image: "MenuBarIcon") { + VPNMenu( + vpn: self.vpn, + session: self.session + ).frame(width: 256) } - .modelContainer(sharedModelContainer) } } diff --git a/Coder Desktop/Coder Desktop/ContentView.swift b/Coder Desktop/Coder Desktop/ContentView.swift deleted file mode 100644 index 08b81a8..0000000 --- a/Coder Desktop/Coder Desktop/ContentView.swift +++ /dev/null @@ -1,52 +0,0 @@ -import SwiftUI -import SwiftData - -struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - - var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } - .navigationSplitViewColumnWidth(min: 180, ideal: 200) - .toolbar { - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") - } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -} diff --git a/Coder Desktop/Coder Desktop/Item.swift b/Coder Desktop/Coder Desktop/Item.swift deleted file mode 100644 index dd0170c..0000000 --- a/Coder Desktop/Coder Desktop/Item.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift new file mode 100644 index 0000000..c567207 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift @@ -0,0 +1,25 @@ +import SwiftUI + +class PreviewSession: Session { + @Published var hasSession: Bool + @Published var sessionToken: String? + @Published var baseAccessURL: URL? + + init() { + hasSession = false + sessionToken = nil + baseAccessURL = nil + } + + func login(baseAccessURL: URL, sessionToken: String) { + hasSession = true + self.baseAccessURL = baseAccessURL + self.sessionToken = sessionToken + } + + func logout() { + hasSession = false + self.baseAccessURL = nil + self.sessionToken = nil + } +} diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift new file mode 100644 index 0000000..b46194b --- /dev/null +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift @@ -0,0 +1,59 @@ +import SwiftUI + +class PreviewVPN: Coder_Desktop.VPNService { + @Published var state: Coder_Desktop.VPNServiceState = .disabled + @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" + ), + 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" + ), + 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"), + ] + let shouldFail: Bool + + init(shouldFail: Bool = false) { + self.shouldFail = shouldFail + } + + private func setState(_ newState: Coder_Desktop.VPNServiceState) async { + await MainActor.run { + self.state = newState + } + } + + func start() async { + await setState(.connecting) + do { + try await Task.sleep(nanoseconds: 1000000000) + } catch { + await setState(.failed(.exampleError)) + return + } + if shouldFail { + await setState(.failed(.exampleError)) + } else { + await setState(.connected) + } + } + + func stop() async { + guard state == .connected else { return } + await setState(.disconnecting) + do { + try await Task.sleep(nanoseconds: 1000000000) // Simulate network delay + } catch { + await setState(.failed(.exampleError)) + return + } + await setState(.disabled) + } +} diff --git a/Coder Desktop/Coder Desktop/Session.swift b/Coder Desktop/Coder Desktop/Session.swift new file mode 100644 index 0000000..95036d7 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Session.swift @@ -0,0 +1,64 @@ +import KeychainAccess +import Foundation + +protocol Session: ObservableObject { + var hasSession: Bool { get } + var sessionToken: String? { get } + var baseAccessURL: URL? { get } + + func login(baseAccessURL: URL, sessionToken: String) + func logout() +} + +class SecureSession: ObservableObject { + @Published private(set) var hasSession: Bool { + didSet { + UserDefaults.standard.set(hasSession, forKey: "hasSession") + } + } + @Published private(set) var sessionToken: String? { + didSet { + setValue(sessionToken, for: "sessionToken") + } + } + @Published private(set) var baseAccessURL: URL? { + didSet { + setValue(baseAccessURL?.absoluteString, for: "baseAccessURL") + } + } + private let keychain: Keychain + + public init() { + keychain = Keychain(service: Bundle.main.bundleIdentifier!) + _hasSession = Published(initialValue: UserDefaults.standard.bool(forKey: "hasSession")) + if hasSession { + _sessionToken = Published(initialValue: getValue(for: "sessionToken")) + _baseAccessURL = Published(initialValue: getValue(for: "baseAccessURL").flatMap(URL.init)) + } + } + + public func login(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() { + hasSession = false + sessionToken = nil + baseAccessURL = nil + } + + private func getValue(for key: String) -> String? { + try? keychain.getString(key) + } + + private func setValue(_ value: String?, for key: String) { + if let value = value { + try? keychain.set(value, key: key) + } else { + try? keychain.remove(key) + } + } +} diff --git a/Coder Desktop/Coder Desktop/Theme.swift b/Coder Desktop/Coder Desktop/Theme.swift new file mode 100644 index 0000000..b44a610 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Theme.swift @@ -0,0 +1,11 @@ +import Foundation + +enum Theme { + enum Size { + static let trayMargin: CGFloat = 5 + static let trayPadding: CGFloat = 10 + static let trayInset: CGFloat = trayMargin + trayPadding + + static let rectCornerRadius: CGFloat = 4 + } +} diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift new file mode 100644 index 0000000..8c2c5f3 --- /dev/null +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -0,0 +1,29 @@ +import SwiftUI + +protocol VPNService: ObservableObject { + var state: VPNServiceState { get } + var agents: [Agent] { get } + func start() async + // Stop must be idempotent + func stop() async +} + +enum VPNServiceState: Equatable { + case disabled + case connecting + case disconnecting + case connected + case failed(VPNServiceError) +} + +enum VPNServiceError: Error, Equatable { + // TODO: + case exampleError + + var description: String { + switch self { + case .exampleError: + return "This is a long error to test the UI with long errors" + } + } +} diff --git a/Coder Desktop/Coder Desktop/Views/Agent.swift b/Coder Desktop/Coder Desktop/Views/Agent.swift new file mode 100644 index 0000000..da57cc5 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/Agent.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct Agent: Identifiable, Equatable { + let id: UUID + let name: String + let status: AgentStatus + let copyableDNS: String + let workspaceName: String +} + +enum AgentStatus: Equatable { + case okay + case warn + case error + case off + + public var color: Color { + switch self { + case .okay: return .green + case .warn: return .yellow + case .error: return .red + case .off: return .gray + } + } +} + +struct AgentRowView: View { + let workspace: Agent + let baseAccessURL: URL + @State private var nameIsSelected: Bool = false + @State private var copyIsSelected: Bool = false + + private var fmtWsName: AttributedString { + var formattedName = AttributedString(workspace.name) + formattedName.foregroundColor = .primary + var coderPart = AttributedString(".coder") + coderPart.foregroundColor = .gray + formattedName.append(coderPart) + return formattedName + } + + private var wsURL: URL { + // TODO: CoderVPN currently only supports owned workspaces + return baseAccessURL.appending(path: "@me").appending(path: workspace.workspaceName) + } + + var body: some View { + HStack(spacing: 0) { + Link(destination: wsURL) { + HStack(spacing: Theme.Size.trayPadding) { + ZStack { + Circle() + .fill(workspace.status.color.opacity(0.4)) + .frame(width: 12, height: 12) + Circle() + .fill(workspace.status.color.opacity(1.0)) + .frame(width: 7, height: 7) + } + Text(fmtWsName).lineLimit(1).truncationMode(.tail) + Spacer() + }.padding(.horizontal, Theme.Size.trayPadding) + .frame(minHeight: 22) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(nameIsSelected ? Color.white : .primary) + .background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) + .onHover { hovering in nameIsSelected = hovering } + Spacer() + }.buttonStyle(.plain) + Button { + // TODO: Proper clipboard abstraction + NSPasteboard.general.setString(workspace.copyableDNS, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .symbolVariant(.fill) + .padding(3) + }.foregroundStyle(copyIsSelected ? Color.white : .primary) + .imageScale(.small) + .background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) + .onHover { hovering in copyIsSelected = hovering } + .buttonStyle(.plain) + .padding(.trailing, Theme.Size.trayMargin) + } + } +} diff --git a/Coder Desktop/Coder Desktop/Views/Agents.swift b/Coder Desktop/Coder Desktop/Views/Agents.swift new file mode 100644 index 0000000..e11804d --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/Agents.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct Agents: View { + @EnvironmentObject var vpn: VPN + @EnvironmentObject var session: S + @State private var viewAll = false + private let defaultVisibleRows = 5 + + 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) + } + } + } +} diff --git a/Coder Desktop/Coder Desktop/Views/AuthButton.swift b/Coder Desktop/Coder Desktop/Views/AuthButton.swift new file mode 100644 index 0000000..86abf07 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/AuthButton.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct AuthButton: View { + @EnvironmentObject var session: S + @EnvironmentObject var vpn: VPN + + var body: some View { + Button { + if session.hasSession { + Task { + await vpn.stop() + session.logout() + } + } else { + // TODO: Login flow + session.login(baseAccessURL: URL(string: "https://dev.coder.com")!, sessionToken: "fake-token") + } + } label: { + ButtonRowView { + Text(session.hasSession ? "Logout" : "Login") + } + }.buttonStyle(.plain) + } +} diff --git a/Coder Desktop/Coder Desktop/Views/ButtonRow.swift b/Coder Desktop/Coder Desktop/Views/ButtonRow.swift new file mode 100644 index 0000000..088eb13 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/ButtonRow.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct ButtonRowView: View { + @State private var isSelected: Bool = false + @ViewBuilder var label: () -> Label + + var body: some View { + HStack(spacing: 0) { + label() + Spacer() + } + .padding(.horizontal, Theme.Size.trayPadding) + .frame(minHeight: 22) + .frame(maxWidth: .infinity, alignment: .leading) + .foregroundStyle(isSelected ? Color.white : .primary) + .background(isSelected ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius)) + .onHover { hovering in isSelected = hovering } + } +} diff --git a/Coder Desktop/Coder Desktop/Views/TrayDivider.swift b/Coder Desktop/Coder Desktop/Views/TrayDivider.swift new file mode 100644 index 0000000..eed29b2 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/TrayDivider.swift @@ -0,0 +1,9 @@ +import SwiftUI + +struct TrayDivider: View { + var body: some View { + Divider() + .padding(.horizontal, Theme.Size.trayPadding) + .padding(.vertical, 4) + } +} diff --git a/Coder Desktop/Coder Desktop/Views/VPNMenu.swift b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift new file mode 100644 index 0000000..6e43947 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/VPNMenu.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct VPNMenu: View { + @ObservedObject var vpn: VPN + @ObservedObject var session: S + + var body: some View { + // Main stack + VStackLayout(alignment: .leading) { + // CoderVPN Stack + VStack(alignment: .leading, spacing: Theme.Size.trayPadding) { + HStack { + 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() } + } + } + )) { + Text("CoderVPN") + .frame(maxWidth: .infinity, alignment: .leading) + }.toggleStyle(.switch) + .disabled(vpnDisabled) + .accessibilityIdentifier("coderVPNToggle") + } + Divider() + Text("Workspace Agents") + .font(.headline) + .foregroundColor(.gray) + if session.hasSession { + VPNState() + } else { + Text("Login to use CoderVPN") + .font(.body) + .foregroundColor(.gray) + } + }.padding([.horizontal, .top], Theme.Size.trayInset) + Agents() + // Trailing stack + VStack(alignment: .leading, spacing: 3) { + TrayDivider() + if session.hasSession { + Link(destination: session.baseAccessURL!.appending(path: "templates")) { + ButtonRowView { + Text("Create workspace") + EmptyView() + } + }.buttonStyle(.plain) + TrayDivider() + } + AuthButton() + ButtonRowView { + Text("About") + }.buttonStyle(.plain) + TrayDivider() + Button { + Task { + await vpn.stop() + NSApp.terminate(nil) + } + } label: { + ButtonRowView { + Text("Quit") + } + }.buttonStyle(.plain) + }.padding([.horizontal, .bottom], Theme.Size.trayMargin) + }.padding(.bottom, Theme.Size.trayMargin) + .environmentObject(vpn) + .environmentObject(session) + } + + private var vpnDisabled: Bool { + return !session.hasSession || + vpn.state == .connecting || + vpn.state == .disconnecting + } +} + +#Preview { + VPNMenu( + vpn: PreviewVPN(shouldFail: false), + session: PreviewSession() + ).frame(width: 256) +} diff --git a/Coder Desktop/Coder Desktop/Views/VPNState.swift b/Coder Desktop/Coder Desktop/Views/VPNState.swift new file mode 100644 index 0000000..b1e5466 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/VPNState.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct VPNState: View { + @EnvironmentObject var vpn: VPN + + 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() + } + 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() + } + } +} diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift new file mode 100644 index 0000000..978ed2f --- /dev/null +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -0,0 +1,48 @@ +@testable import Coder_Desktop +import ViewInspector +import XCTest + +final class AgentsTests: XCTestCase { + private func createMockAgents(count: Int) -> [Agent] { + return (1...count).map { + Agent( + id: UUID(), + name: "a\($0)", + status: .okay, + copyableDNS: "a\($0).example.com", + workspaceName: "w\($0)" + ) + } + } + + func testAgentsWhenVPNOff() throws { + let vpn = MockVPNService() + vpn.state = .disabled + let session = MockSession() + let view = Agents().environmentObject(vpn).environmentObject(session) + + XCTAssertThrowsError(try view.inspect().find(ViewType.ForEach.self)) + } + + func testAgentsWhenVPNOn() throws { + let vpn = MockVPNService() + vpn.state = .connected + vpn.agents = createMockAgents(count: 7) + let session = MockSession() + let view = Agents().environmentObject(vpn).environmentObject(session) + + let forEach = try view.inspect().find(ViewType.ForEach.self) + XCTAssertEqual(forEach.count, 5) + let _ = try view.inspect().find(link: "a1.coder") + } + + func testNoToggleWhenAgentsAreFew() throws { + let vpn = MockVPNService() + 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)) + } +} diff --git a/Coder Desktop/Coder DesktopTests/Coder_DesktopTests.swift b/Coder Desktop/Coder DesktopTests/Coder_DesktopTests.swift deleted file mode 100644 index 6605d34..0000000 --- a/Coder Desktop/Coder DesktopTests/Coder_DesktopTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Testing -@testable import Coder_Desktop - -struct Coder_DesktopTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift new file mode 100644 index 0000000..7afba27 --- /dev/null +++ b/Coder Desktop/Coder DesktopTests/Util.swift @@ -0,0 +1,43 @@ +import SwiftUI +@testable import Coder_Desktop + +class MockVPNService: VPNService, ObservableObject { + @Published var state: Coder_Desktop.VPNServiceState = .disabled + @Published var baseAccessURL: URL = URL(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 + await onStart?() + } + + @MainActor + func stop() async { + self.state = .disconnecting + await onStop?() + } +} + +class MockSession: Session { + @Published + var hasSession: Bool = true + @Published + var sessionToken: String? = "fake-token" + @Published + var baseAccessURL: URL? = URL(string: "https://dev.coder.com")! + + func login(baseAccessURL: URL, sessionToken: String) { + hasSession = true + self.baseAccessURL = URL(string: "https://dev.coder.com")! + self.sessionToken = "fake-token" + } + + func logout() { + hasSession = false + sessionToken = nil + baseAccessURL = nil + } +} diff --git a/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift new file mode 100644 index 0000000..1244351 --- /dev/null +++ b/Coder Desktop/Coder DesktopTests/VPNMenuTests.swift @@ -0,0 +1,107 @@ +@testable import Coder_Desktop +import ViewInspector +import XCTest + +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) + + XCTAssertTrue(toggle.isDisabled()) + XCTAssertNoThrow(try view.inspect().find(text: "Login to use CoderVPN")) + XCTAssertNoThrow(try view.inspect().find(button: "Login")) + } + + 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() + } + 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() + } + try toggle.tap() + wait(for: [e], timeout: 1.0) + } + + func testVPNDisabledWhileConnecting() throws { + let vpn = MockVPNService() + let session = MockSession() + 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 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() + 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 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() + } + 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 new file mode 100644 index 0000000..0934557 --- /dev/null +++ b/Coder Desktop/Coder DesktopTests/VPNStateTests.swift @@ -0,0 +1,48 @@ +@testable import Coder_Desktop +import ViewInspector +import XCTest + +final class VPNStateTests: XCTestCase { + + func testDisabledState() throws { + let vpn = MockVPNService() + vpn.state = .disabled + let view = VPNState().environmentObject(vpn) + _ = try view.inspect().find(text: "Enable CoderVPN to see agents") + } + + func testConnectingState() throws { + let vpn = MockVPNService() + 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...") + } + + func testDisconnectingState() throws { + let vpn = MockVPNService() + 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...") + } + + func testFailedState() throws { + let vpn = MockVPNService() + 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) + } + + func testDefaultState() throws { + let vpn = MockVPNService() + vpn.state = .connected + let view = VPNState().environmentObject(vpn) + + XCTAssertThrowsError(try view.inspect().find(ViewType.Text.self)) + } +} diff --git a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift index f405706..cc2b586 100644 --- a/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift +++ b/Coder Desktop/Coder DesktopUITests/Coder_DesktopUITests.swift @@ -16,12 +16,10 @@ final class Coder_DesktopUITests: XCTestCase { } @MainActor - func testExample() throws { - // UI tests must launch the application that they test. + func testStatusItemExists() throws { let app = XCUIApplication() app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. + app.statusItems.firstMatch.tap() } @MainActor