From d7b1356d5a57a61b4b60d9cd41223bdd5a0dc19b Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 5 Dec 2024 14:10:19 +1100 Subject: [PATCH 1/3] feat: add menubar tray --- Coder Desktop/.swiftlint.yml | 5 + .../Coder Desktop.xcodeproj/project.pbxproj | 78 +++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 41 +++++++ .../MenuBarIcon.imageset/Contents.json | 18 +++ .../MenuBarIcon.imageset/coder_icon_16.png | Bin 0 -> 1053 bytes .../MenuBarIcon.imageset/coder_icon_32.png | Bin 0 -> 1780 bytes .../Coder Desktop/Coder_DesktopApp.swift | 37 +++--- Coder Desktop/Coder Desktop/ContentView.swift | 52 --------- Coder Desktop/Coder Desktop/Item.swift | 11 -- .../Preview Content/PreviewSession.swift | 25 ++++ .../Preview Content/PreviewVPN.swift | 59 ++++++++++ Coder Desktop/Coder Desktop/Session.swift | 64 +++++++++++ Coder Desktop/Coder Desktop/Theme.swift | 11 ++ Coder Desktop/Coder Desktop/VPNService.swift | 29 +++++ .../Coder Desktop/Views/AgentRow.swift | 70 ++++++++++++ .../Coder Desktop/Views/Agents.swift | 28 +++++ .../Coder Desktop/Views/AuthButton.swift | 24 ++++ .../Coder Desktop/Views/ButtonRow.swift | 20 ++++ .../Coder Desktop/Views/TrayDivider.swift | 9 ++ .../Coder Desktop/Views/VPNMenu.swift | 84 ++++++++++++++ .../Coder Desktop/Views/VPNState.swift | 33 ++++++ .../Coder DesktopTests/AgentsTests.swift | 48 ++++++++ .../Coder_DesktopTests.swift | 10 -- Coder Desktop/Coder DesktopTests/Util.swift | 43 +++++++ .../Coder DesktopTests/VPNMenuTests.swift | 107 ++++++++++++++++++ .../Coder DesktopTests/VPNStateTests.swift | 48 ++++++++ .../Coder_DesktopUITests.swift | 6 +- 27 files changed, 867 insertions(+), 93 deletions(-) create mode 100644 Coder Desktop/.swiftlint.yml create mode 100644 Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/Contents.json create mode 100644 Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_16.png create mode 100644 Coder Desktop/Coder Desktop/Assets.xcassets/MenuBarIcon.imageset/coder_icon_32.png delete mode 100644 Coder Desktop/Coder Desktop/ContentView.swift delete mode 100644 Coder Desktop/Coder Desktop/Item.swift create mode 100644 Coder Desktop/Coder Desktop/Preview Content/PreviewSession.swift create mode 100644 Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift create mode 100644 Coder Desktop/Coder Desktop/Session.swift create mode 100644 Coder Desktop/Coder Desktop/Theme.swift create mode 100644 Coder Desktop/Coder Desktop/VPNService.swift create mode 100644 Coder Desktop/Coder Desktop/Views/AgentRow.swift create mode 100644 Coder Desktop/Coder Desktop/Views/Agents.swift create mode 100644 Coder Desktop/Coder Desktop/Views/AuthButton.swift create mode 100644 Coder Desktop/Coder Desktop/Views/ButtonRow.swift create mode 100644 Coder Desktop/Coder Desktop/Views/TrayDivider.swift create mode 100644 Coder Desktop/Coder Desktop/Views/VPNMenu.swift create mode 100644 Coder Desktop/Coder Desktop/Views/VPNState.swift create mode 100644 Coder Desktop/Coder DesktopTests/AgentsTests.swift delete mode 100644 Coder Desktop/Coder DesktopTests/Coder_DesktopTests.swift create mode 100644 Coder Desktop/Coder DesktopTests/Util.swift create mode 100644 Coder Desktop/Coder DesktopTests/VPNMenuTests.swift create mode 100644 Coder Desktop/Coder DesktopTests/VPNStateTests.swift diff --git a/Coder Desktop/.swiftlint.yml b/Coder Desktop/.swiftlint.yml new file mode 100644 index 0000000..dae9134 --- /dev/null +++ b/Coder Desktop/.swiftlint.yml @@ -0,0 +1,5 @@ +disabled_rules: + - todo + - trailing_comma +type_name: + allowed_symbols: "_" \ No newline at end of file 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 0000000000000000000000000000000000000000..3112e48e6112949f31923fbf04f7e4b946d7b245 GIT binary patch literal 1053 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{x0%imor0saV%ts)_S>O>_%)r1c48n{Iv*t(uO^eJ7i71Ki^|4CM&(%vz$xlkv ztH>0+>{umUo3Q%e#RDspr3imfVamB1>jfNYSkzLEl1NlCV?QiN}Sf^&XRs)CuG zfu4bq9hZWFf=y9MnpKdC8&o@xXRDM^Qc_^0uU}qXu2*iXmtT~wZ)j<0sc&GUZ)Btk zRH0j3nOBlnp_^B%3^4>|j!SBBa#3bMNoIbY0?6FNr2NtnTO}osMQ{LdXGvxn!lt}p zsJDO~)CbAv8|oS8!_5Y2wE>A*`4?rT0&NDFZ)a!&R*518wZ}#uWI2*!AU*|)0=;U- zWup%dHajlKxQFb(K%VF6;uvBfm^*p57qg>CTeGgx^eS{4M8ePIn3-Dg>+gKB=lPqnm9CBRcC$ut)4z9_ALMXd9UZ}J(DBxB|zr5 z;>#YdM2R}}=oOms2l=;#ZF*J{{YQWOk^52`*-D>EZ&2RA%xkWZpLgJEIorewyI<#R|FDW}MuU=$!?jn-e_Tp=F3WMpeRHPw zji%ZI{(sb7Wsaw^#ZLtU3U}5;E>X66X0tnB_Tj63SI$Nq z<>9@>w0-RjEsysLK7UxmaMj_ytN$mqiF{qlwtY!{u)EnMrpdJ6xWsDXBLDur?@IqH zbFz8O)*hKqve4Zn|H=CqTB{wL%6o%Z>k_-aUU^skkM-$-1)2?gw;zELy{D_6%Q~lo FCIEAlSYH4D literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1e3ae4b9a178867e1c7159699cf7eb630abf1faf GIT binary patch literal 1780 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzmUKs7M+SzC{oH>NSwWJ?9znhg z3{`3j3=J&|48MRv4KElNN(~qoUL`OvSj}Ky5HFasE6@fg(UKbBnda-upao=eFt9QT zF)#yJj6lf1D8&FW4aj2fVw8rngBUfSYM2-p+A|qgplYIkGzfSAF-Q-DW?sOEFmVAB zT(!aiW&|6gEq)LG2Oz~+;1OBOz`!jG!i)^F=12fdi_8p(D2ed(u}aR*)k{ptPfFFR z$SnZrVz8;O0y1+`OA-|-a&z*EttxDlz$&bOY>=?Nk^)#sNw%$0gl~X?bAC~(f|;Iy zo`I4bmx6+VO;JjkRgjAtR6CGotCUevQedU8UtV6WS8lAAUzDzIXlZGwZ(yWvWTXpJ zp<7&;SCUwvn^&w1F$89gOKNd)QD#9&W`3Rm$lS!F{L&IzB_)tWZ~$>KW+6%?4_<0f}1q7iFdbZ3dZdXJ`Xfi6REI$3`DyIg(=_J_U;cy=up0 zqYn=@J1)t%hwQ*aRO;#C7!twxblTbOtEnQ#)7{+P9!=UHz;pQJRk3R>-CoPtvuXyJ5y2-6JxkebzJx3FZ}JhGrjDo zE9dh~ML*U)s5anUvN)lY^FeNR;vSnX{EF&xU2gEMn(^q$YNk2tD{gUKfBc=0PP{CIir0aq4}cVGMc{4R+W-2N$Q!R7O=a;C$3#;rRXQ?}>WZ)mX5PJOpjWWx6v z@q_t{>QV|Dg`dt_@48PWqCq%gKkvKFdVY_0_3U4D8dOvz12?Zpnt$=dxprTpvi{8$ zxA$>bEVRm1Z@lYyDycZ8LT-cEQfBq`KOfn0808!98^|(mtC!yq9bd!OU@maH;ceNb z^%wW@pP!~IcB*jZ?#0Y1TenJ`=91js_Q!=Y_wS8XYnE?!eYWuCohf}Fq+ofG|Dde; z%<%g~Zb2`*ZqM`JeG$iaX{B2BoQD4_|9T5w-Crkkm8GVJ>$KNHX(hFpvUlR{2(I!| zxXIZjspd|Z+WuKrf-h_B zMVAxv>T2~F_#1c)zpe8wTjqCM!bZkU?7=pv{}C2vS@Y$5*rJ0kw#vJ07h1!ZoLH|Z z#Ao==y5#PS?q8k~*UuOK==sQZ|GoBx=do9>Pu?S5(NUA*f>FnNDgydnxa} zpS4i>M0T0+C$$H;HyZzX|FLtCW1E!U8vW1W*ZVa&A}Y0A0k@W>&%N;8Q~gKrtp~C4 z;$QNw)!6gjpY1gLcmBkuRykn}YbvZmUvA*KA^VWsb15R%@3pPHe1-T{^NO` zy)p8|N(pss+0CNiZO0mvPl*KH4HRy_tLv#1ewuNgSeWQ?`(2(tBz~D5=)aydVZB;X zw~vX`VOy4S0uieDJ4-!wN?AYoJ@Lci6u$qgv9f{c4<0M*o}8|F#(?SCR=2l8+b%Kw zuvW;d-yNRa(tP{*=DA-shOvEbNs@YJdS}Hw(SVYlTTWT8zP#GA(qg&9J-<8KKO8<_ zoD_5HUc(--9lB+oD;4hEOmY3rzv=!|wTU?gw&0m)B>Nkb><~oii zE7xtmKf%7>U!>!qdI>Y8nk#>z>p0f!JN2?6(1<~2N9b3UUyXh1SKEF5>iz1#pC>)l sM|W=dwR^#O_8ldw@>V~$t$zBS@r|_2M+?iGJ)pAO)78&qol`;+08~`UfdBvi literal 0 HcmV?d00001 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..d5f0207 --- /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.AgentRow] = [ + AgentRow(id: UUID(), name: "dogfood2", status: .red, copyableDNS: "asdf.coder", workspaceName: "dogfood2"), + AgentRow(id: UUID(), name: "testing-a-very-long-name", status: .green, copyableDNS: "asdf.coder", + workspaceName: "testing-a-very-long-name" + ), + AgentRow(id: UUID(), name: "opensrc", status: .yellow, copyableDNS: "asdf.coder", workspaceName: "opensrc"), + AgentRow(id: UUID(), name: "gvisor", status: .gray, copyableDNS: "asdf.coder", workspaceName: "gvisor"), + AgentRow(id: UUID(), name: "example", status: .gray, copyableDNS: "asdf.coder", workspaceName: "example"), + AgentRow(id: UUID(), name: "dogfood2", status: .red, copyableDNS: "asdf.coder", workspaceName: "dogfood2"), + AgentRow(id: UUID(), name: "testing-a-very-long-name", status: .green, copyableDNS: "asdf.coder", + workspaceName: "testing-a-very-long-name" + ), + AgentRow(id: UUID(), name: "opensrc", status: .yellow, copyableDNS: "asdf.coder", workspaceName: "opensrc"), + AgentRow(id: UUID(), name: "gvisor", status: .gray, copyableDNS: "asdf.coder", workspaceName: "gvisor"), + AgentRow(id: UUID(), name: "example", status: .gray, 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..a14d9a2 --- /dev/null +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -0,0 +1,29 @@ +import SwiftUI + +protocol VPNService: ObservableObject { + var state: VPNServiceState { get } + var agents: [AgentRow] { 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/AgentRow.swift b/Coder Desktop/Coder Desktop/Views/AgentRow.swift new file mode 100644 index 0000000..a3005e0 --- /dev/null +++ b/Coder Desktop/Coder Desktop/Views/AgentRow.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct AgentRow: Identifiable, Equatable { + let id: UUID + let name: String + let status: Color + let copyableDNS: String + let workspaceName: String +} + +struct AgentRowView: View { + let workspace: AgentRow + 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.opacity(0.4)) + .frame(width: 12, height: 12) + Circle() + .fill(workspace.status.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..2eb4808 --- /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) -> [AgentRow] { + return (1...count).map { + AgentRow( + id: UUID(), + name: "a\($0)", + status: .green, + 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..2e26597 --- /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.AgentRow] = [] + 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 From e2d352c66bde0417bdbd51684dfbe34635a5bfbb Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 5 Dec 2024 21:39:12 +1100 Subject: [PATCH 2/3] review --- .../Preview Content/PreviewVPN.swift | 22 ++++++++-------- Coder Desktop/Coder Desktop/VPNService.swift | 2 +- .../Views/{AgentRow.swift => Agent.swift} | 26 +++++++++++++++---- .../Coder DesktopTests/AgentsTests.swift | 6 ++--- Coder Desktop/Coder DesktopTests/Util.swift | 2 +- 5 files changed, 37 insertions(+), 21 deletions(-) rename Coder Desktop/Coder Desktop/Views/{AgentRow.swift => Agent.swift} (83%) diff --git a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift index d5f0207..b46194b 100644 --- a/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift +++ b/Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift @@ -2,21 +2,21 @@ import SwiftUI class PreviewVPN: Coder_Desktop.VPNService { @Published var state: Coder_Desktop.VPNServiceState = .disabled - @Published var agents: [Coder_Desktop.AgentRow] = [ - AgentRow(id: UUID(), name: "dogfood2", status: .red, copyableDNS: "asdf.coder", workspaceName: "dogfood2"), - AgentRow(id: UUID(), name: "testing-a-very-long-name", status: .green, copyableDNS: "asdf.coder", + @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" ), - AgentRow(id: UUID(), name: "opensrc", status: .yellow, copyableDNS: "asdf.coder", workspaceName: "opensrc"), - AgentRow(id: UUID(), name: "gvisor", status: .gray, copyableDNS: "asdf.coder", workspaceName: "gvisor"), - AgentRow(id: UUID(), name: "example", status: .gray, copyableDNS: "asdf.coder", workspaceName: "example"), - AgentRow(id: UUID(), name: "dogfood2", status: .red, copyableDNS: "asdf.coder", workspaceName: "dogfood2"), - AgentRow(id: UUID(), name: "testing-a-very-long-name", status: .green, copyableDNS: "asdf.coder", + 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" ), - AgentRow(id: UUID(), name: "opensrc", status: .yellow, copyableDNS: "asdf.coder", workspaceName: "opensrc"), - AgentRow(id: UUID(), name: "gvisor", status: .gray, copyableDNS: "asdf.coder", workspaceName: "gvisor"), - AgentRow(id: UUID(), name: "example", status: .gray, copyableDNS: "asdf.coder", workspaceName: "example"), + 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 diff --git a/Coder Desktop/Coder Desktop/VPNService.swift b/Coder Desktop/Coder Desktop/VPNService.swift index a14d9a2..8c2c5f3 100644 --- a/Coder Desktop/Coder Desktop/VPNService.swift +++ b/Coder Desktop/Coder Desktop/VPNService.swift @@ -2,7 +2,7 @@ import SwiftUI protocol VPNService: ObservableObject { var state: VPNServiceState { get } - var agents: [AgentRow] { get } + var agents: [Agent] { get } func start() async // Stop must be idempotent func stop() async diff --git a/Coder Desktop/Coder Desktop/Views/AgentRow.swift b/Coder Desktop/Coder Desktop/Views/Agent.swift similarity index 83% rename from Coder Desktop/Coder Desktop/Views/AgentRow.swift rename to Coder Desktop/Coder Desktop/Views/Agent.swift index a3005e0..da57cc5 100644 --- a/Coder Desktop/Coder Desktop/Views/AgentRow.swift +++ b/Coder Desktop/Coder Desktop/Views/Agent.swift @@ -1,15 +1,31 @@ import SwiftUI -struct AgentRow: Identifiable, Equatable { +struct Agent: Identifiable, Equatable { let id: UUID let name: String - let status: Color + 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: AgentRow + let workspace: Agent let baseAccessURL: URL @State private var nameIsSelected: Bool = false @State private var copyIsSelected: Bool = false @@ -34,10 +50,10 @@ struct AgentRowView: View { HStack(spacing: Theme.Size.trayPadding) { ZStack { Circle() - .fill(workspace.status.opacity(0.4)) + .fill(workspace.status.color.opacity(0.4)) .frame(width: 12, height: 12) Circle() - .fill(workspace.status.opacity(1.0)) + .fill(workspace.status.color.opacity(1.0)) .frame(width: 7, height: 7) } Text(fmtWsName).lineLimit(1).truncationMode(.tail) diff --git a/Coder Desktop/Coder DesktopTests/AgentsTests.swift b/Coder Desktop/Coder DesktopTests/AgentsTests.swift index 2eb4808..978ed2f 100644 --- a/Coder Desktop/Coder DesktopTests/AgentsTests.swift +++ b/Coder Desktop/Coder DesktopTests/AgentsTests.swift @@ -3,12 +3,12 @@ import ViewInspector import XCTest final class AgentsTests: XCTestCase { - private func createMockAgents(count: Int) -> [AgentRow] { + private func createMockAgents(count: Int) -> [Agent] { return (1...count).map { - AgentRow( + Agent( id: UUID(), name: "a\($0)", - status: .green, + status: .okay, copyableDNS: "a\($0).example.com", workspaceName: "w\($0)" ) diff --git a/Coder Desktop/Coder DesktopTests/Util.swift b/Coder Desktop/Coder DesktopTests/Util.swift index 2e26597..7afba27 100644 --- a/Coder Desktop/Coder DesktopTests/Util.swift +++ b/Coder Desktop/Coder DesktopTests/Util.swift @@ -4,7 +4,7 @@ import SwiftUI 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.AgentRow] = [] + @Published var agents: [Coder_Desktop.Agent] = [] var onStart: (() async -> Void)? var onStop: (() async -> Void)? From 5cf3ca9456161c0cda1385e8a5b9667363cd9ed9 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 6 Dec 2024 14:31:20 +1100 Subject: [PATCH 3/3] newline --- Coder Desktop/.swiftlint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Coder Desktop/.swiftlint.yml b/Coder Desktop/.swiftlint.yml index dae9134..d824232 100644 --- a/Coder Desktop/.swiftlint.yml +++ b/Coder Desktop/.swiftlint.yml @@ -2,4 +2,4 @@ disabled_rules: - todo - trailing_comma type_name: - allowed_symbols: "_" \ No newline at end of file + allowed_symbols: "_"