diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fcd65717..e87f8862 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -67,6 +67,7 @@ jobs: POSTHOG_API_KEY = OIDC_ISSUER_URL = OIDC_CLIENT_ID = + FLEET_PLATFORM_BASE_URL = XCEOF - name: Generate Xcode project diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c05156be..fadb30e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -309,6 +309,7 @@ jobs: #include "Version.xcconfig" OIDC_ISSUER_URL = https:/$()/auth.arcbox.dev/api/auth OIDC_CLIENT_ID = arcbox-desktop + FLEET_PLATFORM_BASE_URL = https:/$()/api.arcbox.dev XCEOF - name: Trust SwiftPM plugins diff --git a/.swiftlint.yml b/.swiftlint.yml index c0d1bbbb..6ebe0beb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,7 +4,7 @@ included: - Packages excluded: - - Packages/ArcBoxClient/Sources/ArcBoxClient/Generated + - Packages/*/Sources/*/Generated - Packages/*/.build - .build - .claude diff --git a/AGENTS.md b/AGENTS.md index 087f3b43..3885bb9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,11 @@ A bare `Bool` like `hasCompletedInitialLoad` cannot distinguish "never started" ### Default tab vs lazy tabs The default tab's view renders during startup. Other tabs render lazily when the user switches to them. This means timing bugs in `.task(id:)` only manifest on the default tab — other tabs work by accident because dependencies are already available when they appear. Always test startup behavior on the default tab specifically. +### `fixedSize(horizontal: false, vertical: true)` window blowup (macOS 26) +Any state change inside a `fixedSize(vertical: true)` subtree in a main-window view triggers a window-sizing pass that resizes the window — or, if the window can't grow, the `NavigationSplitView` content inside it — to the screen's *visible-frame height* (content slides under the title bar, bottom-pinned views disappear). Verified on macOS 26.5 with a minimal repro: inserting, removing, or even changing the text of such a label fires it; the same label without `fixedSize` does not, and still wraps correctly inside width-constrained containers. + +**Rule**: don't use `fixedSize(vertical: true)` on labels whose content appears/changes dynamically in the main window (error banners, status text). Text wraps without it in width-bounded layouts; use it only for genuinely static text, ideally in sheets. + ## Code Style - Swift 6 strict concurrency (`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `SWIFT_APPROACHABLE_CONCURRENCY = YES`) - ViewModels use `@Observable`; environment injection via custom `EnvironmentKey` diff --git a/ArcBox.xcodeproj/project.pbxproj b/ArcBox.xcodeproj/project.pbxproj index 297dd1d5..899d08b7 100644 --- a/ArcBox.xcodeproj/project.pbxproj +++ b/ArcBox.xcodeproj/project.pbxproj @@ -13,14 +13,17 @@ 0720D45362971FA9338BAA53 /* SwiftTermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA74DBE71D29DEB680F08E7 /* SwiftTermView.swift */; }; 07A382D149C73C697CAA1601 /* SandboxRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE564AAFEC0EF6D094409728 /* SandboxRowView.swift */; }; 08DDBA6E0000548FEF44EB7B /* StatsFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47B33A34931421365A5BCAE6 /* StatsFormat.swift */; }; + 096533539D74E8FA5A322C0A /* RunnerJobsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2387BC90EBBF4477B65367E /* RunnerJobsView.swift */; }; + 0992A10AFBEFE1BC32E351DE /* FleetViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239658775BBC68EB492EF715 /* FleetViewModelTests.swift */; }; 0999A1C28C17EFD150E56CF8 /* MachinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ECCF18CC36CC3579EE2B5B9 /* MachinesView.swift */; }; 09A2217B8D33E2BD87D23713 /* ActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DAC9EE4F067CB69F022C1E0 /* ActivityViewModel.swift */; }; 09FF7CE0E252DAD805077C7E /* ImagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 559C97F0CA659149BCBA59A2 /* ImagesViewModel.swift */; }; 0A2CD753BB4FE0991213273A /* CommandEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7024634F15938CC362E861 /* CommandEmptyStateView.swift */; }; - 0AF01BD1D2FC14E2F99E9B4C /* K8sClient in Frameworks */ = {isa = PBXBuildFile; productRef = A8DA5BF5FF6E9CABB1BAAD3A /* K8sClient */; }; + 0AF01BD1D2FC14E2F99E9B4C /* FleetPlatformClient in Frameworks */ = {isa = PBXBuildFile; productRef = 1143C225F6C8A5BF7366588B /* FleetPlatformClient */; }; 0BCE503CC2BF6C2BEC9DC4EA /* ImagesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B6587D59B9EFA18E51A3F3 /* ImagesViewModelTests.swift */; }; 0C35F427DABEE19C0C583BE1 /* ContainersViewModel+Docker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12C2D038DE6CC49702F85F0 /* ContainersViewModel+Docker.swift */; }; 0C6B829AD5010FDB0E749471 /* PullImageSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8254D67C0D80B99FC18DAD5 /* PullImageSheet.swift */; }; + 0E1E08820992C2853A6F23F3 /* FleetEnrollmentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8619720F1A99E2E6A431585C /* FleetEnrollmentCoordinator.swift */; }; 0E680E7A421A91FFB668D65E /* SandboxEventsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38681FE9A98E55B1608E662 /* SandboxEventsTab.swift */; }; 0EA623FAB4206D834C87DD75 /* MainWindowControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2110402B2013A53E306B4E43 /* MainWindowControllerTests.swift */; }; 0EB4E66405DD58C377BCE0E6 /* ActivityRowGroupingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CD990EEEF43CDA673BC1816 /* ActivityRowGroupingTests.swift */; }; @@ -30,13 +33,14 @@ 14D3EFEEF183BBA78A496DCF /* PodsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EBEB3FD3F7F3D2E7DF91B0 /* PodsListViewController.swift */; }; 14F016496706C25F9CF50D75 /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06688AB84392716F1108AE36 /* Logging.swift */; }; 15FFC6590CB1CDFE38CFD3EA /* DockerEventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DB22F8A7AA46286F186A73F /* DockerEventMonitor.swift */; }; - 16C56B60D5654AB3F14B41A9 /* IDTokenClaimsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE25BFAD7CCE499E2AA55EE9 /* IDTokenClaimsTests.swift */; }; 172FA4919687A4616E0BC135 /* DaemonLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E8FB9C9E3349126EF773522 /* DaemonLoadingView.swift */; }; 17ED6FFFD9A61998178E38B9 /* VolumesListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BAE047B96D10B4716BA1A2 /* VolumesListViewControllerTests.swift */; }; 1941A5EBBFB259911F7E9ADE /* SandboxesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A764F03F9EA5C3149B777338 /* SandboxesViewModel.swift */; }; 1BC0DF2E6DAA6E0A94058B5F /* ContainerListModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9467D1D1EA60DDF80414B20B /* ContainerListModels.swift */; }; 1CA50DB029E0C7A18CAF87A2 /* LocalRootFSOutlineCoordinator+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A45A5AAA9B74A59E7543D8D0 /* LocalRootFSOutlineCoordinator+Actions.swift */; }; 1CB8AE8C16D0C333B7C0B32F /* ResourceStatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52400B734764C05E50D6FFC /* ResourceStatsTests.swift */; }; + 1E07B971374B4CE76D32DBC2 /* RunnerHostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F1497910C2DE7F04BA5528 /* RunnerHostDetailView.swift */; }; + 1E08F7DA3B549BAE7F5ECBFC /* RunnerJobDetailModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F4663B38FDFB64EBE74EAC /* RunnerJobDetailModelTests.swift */; }; 1FB4D8B5B61B82155960542D /* NetworksListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089AAF447EC30EF108B76EF7 /* NetworksListViewController.swift */; }; 20C4C1664AC3D1942C7D72CD /* SandboxesViewModel+GRPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 829D8DC499E6339D13A2ADE7 /* SandboxesViewModel+GRPC.swift */; }; 21D545F71EFE312362029E61 /* SandboxPortsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11EB6AEBF371E6B88EF8D08E /* SandboxPortsTab.swift */; }; @@ -50,6 +54,7 @@ 2C918E08295E3751C9FD1EC1 /* MachineDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9C7ADBBD83C5F92EEBAAD88 /* MachineDetailView.swift */; }; 2D13FABE82FCAF045B2CF25D /* LoadPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A4AA61258823B0E929F4520 /* LoadPhase.swift */; }; 2EFFFF0AEC16386829FAC491 /* AuthSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA33C724456FCDF5A3E52AD /* AuthSessionTests.swift */; }; + 30E9F74DC5FA96D1B1023EC2 /* RunnerJobDetailTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1CB07080BD71955DCF864A /* RunnerJobDetailTab.swift */; }; 31474CB365691C57A5568D14 /* ChangelogParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10ED4BA65D6B9CC56B6B329 /* ChangelogParserTests.swift */; }; 31A9DB06B08CC74D635ECB8A /* NetworksListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297FCAB334AF289575D13F72 /* NetworksListView.swift */; }; 33DEED20119675484C189E77 /* LocalRootFSOutlineCoordinator+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F932C1467A5FB5CFBD0444 /* LocalRootFSOutlineCoordinator+DataSource.swift */; }; @@ -57,6 +62,8 @@ 361A7AD4626352A09571245C /* ContainerLogsTab+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200F15F29A3634EC779554D4 /* ContainerLogsTab+Actions.swift */; }; 3B38458299F141A08B8C7107 /* ImageTerminalTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB477EF803A15EDFF2685A23 /* ImageTerminalTab.swift */; }; 3B6BCFE408976AA0CDD0A5F8 /* SandboxesViewModel+PortsFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B80964B0A136E03F5FDA0FE /* SandboxesViewModel+PortsFiles.swift */; }; + 3B817A9A7D75BB17FF688B75 /* RunnerHostStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3DBF7CBDB3176071B996D3 /* RunnerHostStatus.swift */; }; + 3F06D58F1B4041869FEC4F19 /* RunnerHostJobSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2759FD2E220699E0BB1153A3 /* RunnerHostJobSummary.swift */; }; 3F0B1B3F51E1663A5BA7EA66 /* SystemSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F734677F8F8EFD08CB32E8B3 /* SystemSettingsView.swift */; }; 3F5E421D1DA0E9C653BAEB70 /* LayerStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B589789A38796A969785F9 /* LayerStack.swift */; }; 3FA536EBD8D183064D0627E2 /* QuitWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F75C788ADEDBC9B1F007E3 /* QuitWindowController.swift */; }; @@ -69,7 +76,7 @@ 442FF72709337D1BCAA0643D /* SandboxEventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C68609B67AC82FF386CB92 /* SandboxEventMonitor.swift */; }; 44974322E172A05817F3D858 /* KubernetesListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C83FBFE31C0C7B96918BFCC6 /* KubernetesListViewControllerTests.swift */; }; 45029921A85B7F5C86F49E3B /* AboutView+ReleaseRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0100B138714BA1626E5E8D48 /* AboutView+ReleaseRow.swift */; }; - 456A5157DDC9968B75F853BD /* WebAuthenticationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E780C92770118BBD052882E /* WebAuthenticationController.swift */; }; + 45BB87866AFBADAF6F07CA76 /* FleetBackend+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B81497897F5306643CC2AD1 /* FleetBackend+DisplayName.swift */; }; 45BD43408706D6F5044BDEB2 /* VolumeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CEDB1A7E6E5F0EEA2214D21 /* VolumeTypes.swift */; }; 466BD76F6F334FBAEA392BD5 /* HelperInstallErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF45523D23D3F96612423943 /* HelperInstallErrorTests.swift */; }; 46717964B62163546276C1D1 /* AboutView+Sections.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC98017408ED56AFF734125A /* AboutView+Sections.swift */; }; @@ -80,12 +87,16 @@ 4B14BB17843A552B8D1B51BA /* ExternalTerminalDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77BE672A52785F040AC6CEB2 /* ExternalTerminalDiscovery.swift */; }; 4C3BC465BFE58BF0D59AFE78 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915561BEEE49F5E27F4B0D40 /* OnboardingWindowController.swift */; }; 4D2029E55A7979B8BD9EE89A /* ContainersViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E610512C9106BB92A47DC044 /* ContainersViewModelTests.swift */; }; - 4D2FB362066EF19A8C5983F1 /* DockerClient in Frameworks */ = {isa = PBXBuildFile; productRef = 984278376FF4860E6FADC66F /* DockerClient */; }; + 4D2FB362066EF19A8C5983F1 /* FleetControlClient in Frameworks */ = {isa = PBXBuildFile; productRef = 0631A4C3410E8D6E9CFDDA2E /* FleetControlClient */; }; + 4F2B260760140AEAEDBACBC2 /* RunnerJobRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 080B7568EE5A517F8CC343FE /* RunnerJobRow.swift */; }; + 50BAFC716B373FD97055B801 /* FleetRunnerJobStatus+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB846BC41EE075896CD5598 /* FleetRunnerJobStatus+Display.swift */; }; 50CCDEFCA186BCD1E99D291F /* ChangelogParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBEBBF08F0E4AC45D9C0BB83 /* ChangelogParser.swift */; }; + 510B8922C820BE4607F7817B /* RunnerHostViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BC28A842D0A91FA84517CC /* RunnerHostViewModel.swift */; }; 519C116D9CC62268CE4859D4 /* ExternalTerminalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D70EF1BF223C47C7B8E71A5 /* ExternalTerminalApp.swift */; }; 525ECDE24CB1E8ADC7AADB49 /* UpdaterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9234FC5BCCDDCBF441A94880 /* UpdaterDelegate.swift */; }; 53B84DC35DF078E19C8D7780 /* StatusItemController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E217254D0827A011E01C3F05 /* StatusItemController.swift */; }; 547AB2E719C9C6B7F66046F7 /* ServicesListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85296C05DC9D33C7930366D7 /* ServicesListViewController.swift */; }; + 592084F442E916A94A8B9CBA /* RunnerSelectionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16087ADE0B07FDD6E3D719F4 /* RunnerSelectionDetailView.swift */; }; 592D8D15F0AD149B578AC661 /* VolumesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 209E974D66F4B2D9B5CDA2D9 /* VolumesListView.swift */; }; 5C24B61DEC9BC6CE303FF52A /* AppMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0084D4B147FA59C7CCC0F5 /* AppMetrics.swift */; }; 5D4AD52D29953C3DB6552B97 /* ContainerLogsTab+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DD7F5E0115C839BB364D59F /* ContainerLogsTab+Formatting.swift */; }; @@ -108,6 +119,7 @@ 6EE6805F5EC85742CC665575 /* DockerCLIResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDE93A683D2613C431BEBF2 /* DockerCLIResolver.swift */; }; 6EE78011A83F450B9E754CAB /* DockerTerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC42E91F0BCACABF173B87F /* DockerTerminalSession.swift */; }; 6F8362D2C8129F7BBF6E9635 /* VolumeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD5F80A65CA7E59F453286D /* VolumeDetailView.swift */; }; + 6FBDCCAB7F2960C0A0330A04 /* RunnersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDFC648740B4664FCC1E5E5 /* RunnersView.swift */; }; 7024DB45668AC27567EB655E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE5A29026FA64A7C0A47122 /* SettingsView.swift */; }; 70996419B03971A57B1DCF0F /* ByteFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3750055AEC4C49576A78A909 /* ByteFormattingTests.swift */; }; 70BCB75DB0902FECADF9BEBC /* SandboxTerminalTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C5CFE4B4D21711F8580690 /* SandboxTerminalTab.swift */; }; @@ -124,8 +136,13 @@ 7895A0328F3567814B656CFD /* LocalRootFSOutlineCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0D0444E612EC32D81A7E0C /* LocalRootFSOutlineCoordinator.swift */; }; 7910184754585B4CF647FA92 /* ContainerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C6B2F85E39321931CEC3ED /* ContainerDetailView.swift */; }; 79C5F3DA19E7431F8B28B6F0 /* MenuBarView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC010DD9F0AEC7E4EA778C59 /* MenuBarView+Actions.swift */; }; + 7B283BBB6B3C8DA7B650328A /* RunnerHostOverviewTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8586B73ACB03595FB3074B38 /* RunnerHostOverviewTab.swift */; }; + 7B40CC08EA615C5CB027380A /* RunnerHostJobSummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5758E839E4363E54EEECD99 /* RunnerHostJobSummaryTests.swift */; }; 7C881FAC221912F138CD65E7 /* ActivityMetricStrip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61F15E941FF769135D0AA218 /* ActivityMetricStrip.swift */; }; + 7D6BA204C7E5B022B6267289 /* RunnerHostCapability.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1FE07EA71512ACEE609B537 /* RunnerHostCapability.swift */; }; 7DABE0FBDD17F88D47333964 /* CommandHint.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65698313C3204F5F23E099B /* CommandHint.swift */; }; + 7DB0F638AAD7DCB4F8349402 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 9C709A3E8E1972F5BB841623 /* PostHog */; }; + 7DD53D9600A91363E177DE2D /* RunnerJobLogsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B0888CBDEF33130735BA648 /* RunnerJobLogsTab.swift */; }; 7DFC19087BE929DDF207A677 /* UpdaterSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93968609F8E058EAC838DD18 /* UpdaterSettingsModel.swift */; }; 7F091F3F3F75F500937DC7BC /* ImageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8BD8A4B6EEE9722D54739D /* ImageModel.swift */; }; 7F8FB0C66C04AEF7895A85AD /* NetworkDetailViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA3C6B6F10198DFC435A33E /* NetworkDetailViewControllerTests.swift */; }; @@ -137,6 +154,7 @@ 8466B000B68A87CEECA6756F /* DockerEventMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06DFFB59D72F27F14A233B81 /* DockerEventMonitorTests.swift */; }; 84DDB437165AE31AD63C30F9 /* MenuBarView+Metrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECF57E2C2B0281B8CEA6132C /* MenuBarView+Metrics.swift */; }; 853F2D06A878BE9A867BE3FD /* CreateOperationErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFE1C88F20E40C1A36A384C /* CreateOperationErrorTests.swift */; }; + 868F62A98F4BD1023DD79167 /* FleetEnrollmentCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92FD51B5947EFEEFD73D00F9 /* FleetEnrollmentCoordinatorTests.swift */; }; 869B14DB25CD44955C7C32AE /* ContainersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C69EC561EEA79ADE5A9000 /* ContainersViewModel.swift */; }; 8939A06F20FE4F92A72E3372 /* ImageTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E17CF68FBBA9447E1B15371 /* ImageTypes.swift */; }; 8BBBA54130403D89D35986F7 /* ImageLayerChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C964312D5336EE55A8E4680B /* ImageLayerChainTests.swift */; }; @@ -150,6 +168,7 @@ 8ED7BA2FEE7010BDBD4BAEDC /* VolumesViewModel+Docker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420516961E57589A91B445DA /* VolumesViewModel+Docker.swift */; }; 8FB67BF11BF2A3194A28BD32 /* SandboxTerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0841C2969EC1A1BC07489C8 /* SandboxTerminalSession.swift */; }; 9010BB836D577FF81084FAAA /* ToolbarSeparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 823F87591055C314EC29BF67 /* ToolbarSeparator.swift */; }; + 90CB65EA852B32711B9418B0 /* RunnerJobRuntimeTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21EDC6099569584471CD2FEC /* RunnerJobRuntimeTab.swift */; }; 927458F82097ECF1A07412FD /* StatePlaceholderTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBD1F83B573C40DFA09E5633 /* StatePlaceholderTestSupport.swift */; }; 931763946B40148917024A02 /* K8sClient in Frameworks */ = {isa = PBXBuildFile; productRef = E3B0A98EBADEEAA76280EF06 /* K8sClient */; }; 932BD3DB61039B6A7B298661 /* ApplicationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78827F9105251AF033577933 /* ApplicationCoordinator.swift */; }; @@ -162,17 +181,21 @@ 961ABBF2262B0BA2139BECBA /* KubernetesStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC273E219CB1B213B2DC57A /* KubernetesStateTests.swift */; }; 967B79B7F3A20ACDDC610AD4 /* ContainerFilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80B0A0F4FC2E7ECA0B844DD /* ContainerFilesTab.swift */; }; 96C7FFF79579D1B999ABAF3F /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A093F9D88FA4D914DE6F45 /* Utilities.swift */; }; + 975051DCB1D5B9738AEF3661 /* FleetMachineStatus+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F4B62DCEFF676208ACD162 /* FleetMachineStatus+DisplayName.swift */; }; 97746F2287918514ECD6F1C3 /* LocalRootFSOutlineCoordinator+Loading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F6CFF90B9BC6AE2836414C /* LocalRootFSOutlineCoordinator+Loading.swift */; }; 977632E34C7EC1E3FEEAA25E /* KubernetesState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37F8C85EB27987F81FFB5F4 /* KubernetesState.swift */; }; + 977BE039C964F896AB613634 /* RunnersViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E902A97C32D4034C22684BBD /* RunnersViewModelTests.swift */; }; 97B9638AA9C685C6DB2780B4 /* PerformanceTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA6DBFBB43078F295AFE54C0 /* PerformanceTracing.swift */; }; 97E56AA0BEC7024FEBBE1B75 /* ExternalTerminalLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A87C9445565D341EA85D2F4 /* ExternalTerminalLauncher.swift */; }; - 98E7A55FC92F874A28D762FD /* PKCETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3DB0D1F23DD6976DA73A33C /* PKCETests.swift */; }; 9BBC2F30A81F4AE668951B67 /* SandboxModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA990161E9F1709745BE5796 /* SandboxModel.swift */; }; 9D6E13499CA3B4666836F0F1 /* InfoRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F318A121B0C8EA1A05BFE5 /* InfoRow.swift */; }; 9E79E2BBA05690D9ECAED2CD /* SortMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97E7E52D3A54846906EB064D /* SortMenuButton.swift */; }; 9EAE5E0BF3CB9432D7A228C2 /* ContainersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA69DFAC30647687529C962A /* ContainersListView.swift */; }; A03ACBF5074B007C8CD7B101 /* StartupProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32115BE04C97CECA9C05A273 /* StartupProgressView.swift */; }; A0AA341C4E3000ED0828F01B /* ResourceListRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9750454C23940C3F74E7690E /* ResourceListRowView.swift */; }; + A2FA1D274C5DADFC199EE437 /* RunnerEmptyState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E327BEE2A1A04A55A730BF /* RunnerEmptyState.swift */; }; + A32149C2AC3A6B3A7BE1B8EC /* RunnerPlatformStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062988A69CDF42259286245B /* RunnerPlatformStore.swift */; }; + A45901889EB62F97532D443E /* RunnerPlatformStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8937BC21E9813E2F8AA7F2 /* RunnerPlatformStoreTests.swift */; }; A537498E1D8181BB0230E199 /* ContentViewColumnLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE7E377F9D8C89E24989DE3D /* ContentViewColumnLayoutTests.swift */; }; A75C60F77B89D7AEE365BC2E /* VolumeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDF2337ED21F8382F7ABB29A /* VolumeModel.swift */; }; A7E66915A83CDD9FF95EF09F /* MachineEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81FCCED6F39D73E946107243 /* MachineEmptyStateView.swift */; }; @@ -183,56 +206,73 @@ AAC2BE593C229A83757CCD23 /* LiquidGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3572105F94A4F2399F9446D4 /* LiquidGlass.swift */; }; ABA9E2D6873BE5FF255045F3 /* AboutView+SystemInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0C5A43740FFD68902A2F77 /* AboutView+SystemInfo.swift */; }; AC66C291D8250EF48B8FE3D2 /* MenuBarView+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC6744BCBFA0A05831AFED0E /* MenuBarView+Layout.swift */; }; + AD65518905804CCB175FD6CA /* RunnerHostOverviewStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96EF490DB110369883A43D6 /* RunnerHostOverviewStatusSection.swift */; }; ADE462B8234F0D1F9D8165B3 /* VolumesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 699A57392F1D9366CFA78BAF /* VolumesViewModel.swift */; }; + AF8784321B0BF66F476749C5 /* BetterAuthClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12EE31052572ABF8B455044 /* BetterAuthClientTests.swift */; }; B29A0911581DF9A70350B726 /* NewSandboxSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF34CA0688377F7956F2FD3D /* NewSandboxSheet.swift */; }; B3DF6F1A9B47905BF6337A81 /* StorageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F915D676AC7EDF66A67B77A9 /* StorageSettingsView.swift */; }; B5181BB7367CF1DCA823F7D3 /* NetworkDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D24EB7C6D2CE78ED32D7AC8 /* NetworkDetailViewController.swift */; }; B5E8D265254E1C224247214A /* MachineImageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71D28721E41F8303DBF7232 /* MachineImageCatalog.swift */; }; - B6AB384B22968963098E96F3 /* OIDCClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DE3993B3F888A667AA988F /* OIDCClientTests.swift */; }; B8F04FD5D42B1934B13D9D3A /* PodDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38661BA5859384BCCE61B0C /* PodDetailView.swift */; }; B91D470DA46003D64389C7DE /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F62FD6244C56EE8E45DF0EC7 /* AppColors.swift */; }; BA3195811383AD00D29B32AE /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ADF9F8FFB694C45042664E8 /* DeepLink.swift */; }; BA5C4C0A98D9650B14A2ECB8 /* GuestDataMount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83FBE0C82E0EB430C69EB944 /* GuestDataMount.swift */; }; + BA69D54C021A974036846623 /* RunnersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474FD439D2B872416A0CC116 /* RunnersViewModel.swift */; }; BA8E4BE30E5FBB6B8548E214 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657D7543F1D47988AEC3339D /* AppDelegate.swift */; }; BB6DA9C1B509BF12CDA5A92E /* ImageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185A238D021D6C976574528C /* ImageDetailView.swift */; }; BD0363ABBB77C56F049EE2E4 /* LayerMergeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E1595F793A3AD5691E191D /* LayerMergeBadge.swift */; }; BD99D14EC5ED57BA1FF283CC /* DockerContextManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064086AEC8C204E8224DF45A /* DockerContextManager.swift */; }; BDE44A1851827C2069778F08 /* ImageFilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BE02225F61EF92333916B1 /* ImageFilesTab.swift */; }; + BECE57281BC047E471893209 /* AuthClientConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D8365238BDE73993270477 /* AuthClientConfigurationTests.swift */; }; + BF0046600965F7A52FCDEB8F /* RunnerJobInfoTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53A3591FEDEA055FFA59367 /* RunnerJobInfoTab.swift */; }; BF22080E6092E730A9DD1424 /* ContainerTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC37601A89C4ED758AC6C45 /* ContainerTypes.swift */; }; BFA2E94DC17244A06D187331 /* MachineInfoTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791DD121033683719C609B4D /* MachineInfoTab.swift */; }; - C12978695EDC917704747269 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B37EA00E3A32B9866C9BE293 /* Sparkle */; }; + C12978695EDC917704747269 /* K8sClient in Frameworks */ = {isa = PBXBuildFile; productRef = A8DA5BF5FF6E9CABB1BAAD3A /* K8sClient */; }; C12DED2A6B5168308D89BE15 /* MachineCreateSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144C9321F8A17DA72A862DA /* MachineCreateSheet.swift */; }; + C19B191AF0DE9179608BF40A /* RunnersViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6ABF0A0B6218A3FE504C7DA /* RunnersViewState.swift */; }; + C1CEE635A115E843DF66A65B /* FleetSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E69E766A830CB3A26231BA11 /* FleetSettingsView.swift */; }; + C38303D18080C9AED2BD8D20 /* FleetRunnerImageReadiness.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0656F14604E5C2380BFA93 /* FleetRunnerImageReadiness.swift */; }; C8181916F2EBD889B0C8046B /* MachineTerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8632512053E9C750CAA97F9 /* MachineTerminalSession.swift */; }; C8C56406446AE0906E11100C /* InfoTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED1683EF0A3490B8B7B5CF6 /* InfoTableView.swift */; }; C94515858BC220B73B93EC5D /* ArcBoxClientErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC5F65FECB2F80BA3AF1D84 /* ArcBoxClientErrorTests.swift */; }; C9850114DD3E3F1B3C22E09D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 68009FEC4A2F0EB046ACCA4C /* Assets.xcassets */; }; CA35C83C2741D945EF23C5E3 /* VolumeViewModel+Docker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 867A6CA0B4DD07BE74610FCB /* VolumeViewModel+Docker.swift */; }; - CC2502232C562D3167B2F3D2 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 9C709A3E8E1972F5BB841623 /* PostHog */; }; + CC2502232C562D3167B2F3D2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B37EA00E3A32B9866C9BE293 /* Sparkle */; }; CCAF8E87C516B0ACE300BDCD /* PodsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A76C0B459745654F31B3B2 /* PodsListView.swift */; }; CCD9ABC3F69FE5DA233E85EC /* MachineModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0647CCB67160C2509345B5E /* MachineModelTests.swift */; }; + CF0180F7923FEC8E8C5FE917 /* RunnerHostOverviewJobsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0C6C10DE353C0ED6E2E85DF /* RunnerHostOverviewJobsSection.swift */; }; D0518D07973520530916560B /* ServiceDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F238F21169A97B94193454CF /* ServiceDetailView.swift */; }; D0A2395C0C2BC0A77B175840 /* MachineEventMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2021CE387B9546EE87FA4CD5 /* MachineEventMonitor.swift */; }; D2AB4B6BF0723EE12A0DBC83 /* GuestDataMountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27130DDA57D2645C7E81D75D /* GuestDataMountTests.swift */; }; + D2E52E2552B8D7E96A843632 /* RunnerHostIdentityTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C60393B474CFC92B46386C /* RunnerHostIdentityTab.swift */; }; D2F870759080FB9D1DF58A3A /* NewContainerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B011CA693D5A9EFCCF61AA2D /* NewContainerSheet.swift */; }; D32166A92BDA2FB6B6037D94 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C649765AA76CFCBFC3302DA /* DeepLinkRouter.swift */; }; D4B90FF1E30A90C8B962A30B /* AboutWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE313F40E79E409C942B1EE /* AboutWindow.swift */; }; D4ECBBCAC3D4631041F292D5 /* QuitWindowControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B084B3E5BD66E2DA018764E8 /* QuitWindowControllerTests.swift */; }; D4FFF718B8B6D79B469D2C3C /* GuestExportFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738233A82C7BAA825F73EE2F /* GuestExportFixture.swift */; }; + D555C507200C70946F381C91 /* RunnerImagePreparationStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9A6D7A71C8A071AB76AE2C /* RunnerImagePreparationStatusView.swift */; }; D69B9880564FF221556379CF /* SleepWakeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7058C8975F3F2684714E24E2 /* SleepWakeManager.swift */; }; - D6C6DEA33D73FF969786C018 /* OIDCClientConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71DF8E5C8AADE1AD5C60F457 /* OIDCClientConfigurationTests.swift */; }; D6D1C18BADA9AB8CAE608A7F /* SidebarAccountButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7607A876F6C88A440DA53BE1 /* SidebarAccountButton.swift */; }; + D7149FCB6052803F6900926B /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = D50E2875839AD5944467DBC0 /* Sentry */; }; D745D0D7EF5FF65174C47F28 /* NewVolumeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 903FA72CEF39142ADA5CA378 /* NewVolumeSheet.swift */; }; + D8E28DEE224DA7D44727E216 /* RunnerHostOverviewPoolsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53A873988FE80AD4424FE13 /* RunnerHostOverviewPoolsSection.swift */; }; DA5B57293BC69B7D95AABBA8 /* ActivityRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0201CD86DCD5E042D07FF66F /* ActivityRow.swift */; }; + DAA8D86963F68C3BE402619B /* FleetAgentConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8DB8D7DB7317B7B3E4775C /* FleetAgentConnection.swift */; }; DB19F0073D84ED7A7C937D49 /* ContainersListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80010452354615CAC08FAF1D /* ContainersListViewControllerTests.swift */; }; DB5CE5663EE824474F0739FF /* OnboardingWindowControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A517313E1085B62A40075622 /* OnboardingWindowControllerTests.swift */; }; + DC8E940905BADCF4888BEA0A /* RunnerHostStatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02391823A10B16B12627E6C /* RunnerHostStatusBar.swift */; }; DC9E1747A0F6F6574688BBD0 /* arcbox.version in Resources */ = {isa = PBXBuildFile; fileRef = AC70A79B919D909CF6D811B4 /* arcbox.version */; }; DDF9F74BA0638A51322686DE /* PodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDF5B0B3EC77FB12D8B4EA6A /* PodsViewModel.swift */; }; + DF8E90AA48D1B41916F87C5E /* RunnerJobDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B99162EF318CE94662C54BA /* RunnerJobDetailView.swift */; }; E0C997A42E6F8D8B4D9C26FF /* ImagesListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35969670AC4F826B4A3BB567 /* ImagesListViewControllerTests.swift */; }; E12C55C4C275DCBAB9DA8150 /* VolumeFilesTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDFA2EDA7A3AC8448696F91 /* VolumeFilesTab.swift */; }; E7931822EE59221AEC359CDF /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9FDA2F6F3036772AC0B23C /* SettingsWindowController.swift */; }; E7E796A682B8956706429D45 /* ContainerListCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52CF09CC08AE563196CE3BED /* ContainerListCells.swift */; }; - E92C8245EE6AE51D99035E4D /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = B23ADDA7527EDFFFD6615B12 /* SwiftTerm */; }; + E809752CD798B29AFF096BD3 /* RunnerHostCapacityTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFC35C028AEC068D062C12F /* RunnerHostCapacityTab.swift */; }; + E8A6AABEC56C79575194CAAF /* RunnerHostSettingsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496FD50DEB793E0F91A70DA4 /* RunnerHostSettingsTab.swift */; }; + E92C8245EE6AE51D99035E4D /* DockerClient in Frameworks */ = {isa = PBXBuildFile; productRef = 984278376FF4860E6FADC66F /* DockerClient */; }; E9B0A374B532221710729A34 /* ContainerTerminalTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1AFAED2CB7429B714C8A06 /* ContainerTerminalTab.swift */; }; + E9DA27A7255C72A3FE6E5599 /* FleetControlServicing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8FE218992C260511CE601B8 /* FleetControlServicing.swift */; }; ECA0B439D329FA52C529ABE8 /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3D2AE66CD9DD4A18CADB71 /* ActivityView.swift */; }; ED3023CCFF235C0CF302C812 /* MenuBarView+Containers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15C37C29F16DD4E28B30271 /* MenuBarView+Containers.swift */; }; ED5B6539D3909308DA1C4AE4 /* TerminalSessionBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312B3A0CBD47EB616127BBE5 /* TerminalSessionBridge.swift */; }; @@ -242,14 +282,19 @@ F0B6CC9DB9992CE44F2A7F70 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F707491EC9A47AE534822664 /* AppViewModel.swift */; }; F0B924B312EABA596C870505 /* LayeredRootFS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33250D1306B30B4E3D4C9B0E /* LayeredRootFS.swift */; }; F319F8722F145CCFA01650B6 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F60CF8B8E81089BA2E8DC20 /* GeneralSettingsView.swift */; }; + F3B97C8B5BDF2AF98BB2F0B3 /* RunnerJobDetailModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F86AD3D61B9D443086AA78 /* RunnerJobDetailModel.swift */; }; F40C3E6023452858EEEA893F /* K8sModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B6FDB677AE01E62877A17AC /* K8sModelsTests.swift */; }; F5350A9E11B4C65AAE8BD569 /* ServiceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13203BD67479A8191C1A3BD6 /* ServiceModel.swift */; }; - F6386D2A578AD21E751CFC57 /* OIDCAuthorizationURLBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8071725DD08666599A15CE65 /* OIDCAuthorizationURLBuilderTests.swift */; }; F65C1148B776397730836A3D /* TerminalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454ED6CBDC3B851667C70AF8 /* TerminalViewController.swift */; }; + F9B2E0EFEECE4F44B659E8D5 /* RunnerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844E4C199E51918262A8C14F /* RunnerSelection.swift */; }; + F9DFD7D1A67ECB96BEEC3D6F /* RunnerJobListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BCD10A967FC56763AAAD3AD /* RunnerJobListItem.swift */; }; FA631E839B38C6D0DBDBA928 /* ArcBoxAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 1EEA05F99067212B54970FA5 /* ArcBoxAuth */; }; - FBDBF39F818DDE35E0B7D0DF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = D50E2875839AD5944467DBC0 /* Sentry */; }; + FBDBF39F818DDE35E0B7D0DF /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = B23ADDA7527EDFFFD6615B12 /* SwiftTerm */; }; FBDC1F10288E1448184A91E3 /* IconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E73318FC9D8EBCB39E5C0AE /* IconButton.swift */; }; FBE7E964F895EFE9C7818256 /* ContainerViewModel+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5BDCAAF11D1A6B52990E28 /* ContainerViewModel+Mapping.swift */; }; + FD81DD803F100451034B3384 /* RunnerJobListItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62D737D532B4BB4169914591 /* RunnerJobListItemTests.swift */; }; + FF140AF8CD86CF1FD3AAAAAC /* RunnerHostDetailTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF537351518DBAB4EC8BAE61 /* RunnerHostDetailTab.swift */; }; + FF70A2FA2732211E1C4F7297 /* FleetViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B51BA58BAD575480118B2D2 /* FleetViewModel.swift */; }; FFAB94A328F83148EC0ED794 /* LocalRootFSOutlineCoordinator+Columns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A4B5E95EA5A894B19867570 /* LocalRootFSOutlineCoordinator+Columns.swift */; }; /* End PBXBuildFile section */ @@ -281,9 +326,12 @@ 0100B138714BA1626E5E8D48 /* AboutView+ReleaseRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AboutView+ReleaseRow.swift"; sourceTree = ""; }; 0201CD86DCD5E042D07FF66F /* ActivityRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityRow.swift; sourceTree = ""; }; 03C9AACAD52AFF216B6A69E0 /* ImagesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesListView.swift; sourceTree = ""; }; + 062988A69CDF42259286245B /* RunnerPlatformStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerPlatformStore.swift; sourceTree = ""; }; 064086AEC8C204E8224DF45A /* DockerContextManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockerContextManager.swift; sourceTree = ""; }; 06688AB84392716F1108AE36 /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; 06DFFB59D72F27F14A233B81 /* DockerEventMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockerEventMonitorTests.swift; sourceTree = ""; }; + 06F86AD3D61B9D443086AA78 /* RunnerJobDetailModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobDetailModel.swift; sourceTree = ""; }; + 080B7568EE5A517F8CC343FE /* RunnerJobRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobRow.swift; sourceTree = ""; }; 089AAF447EC30EF108B76EF7 /* NetworksListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworksListViewController.swift; sourceTree = ""; }; 08A76C0B459745654F31B3B2 /* PodsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodsListView.swift; sourceTree = ""; }; 0B8670CF269C853BD2039B34 /* NetworkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkModel.swift; sourceTree = ""; }; @@ -294,8 +342,11 @@ 11EB6AEBF371E6B88EF8D08E /* SandboxPortsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPortsTab.swift; sourceTree = ""; }; 13203BD67479A8191C1A3BD6 /* ServiceModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceModel.swift; sourceTree = ""; }; 13FD9C27ABD090B7DBA435AA /* ImagesListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesListViewController.swift; sourceTree = ""; }; + 16087ADE0B07FDD6E3D719F4 /* RunnerSelectionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerSelectionDetailView.swift; sourceTree = ""; }; 185A238D021D6C976574528C /* ImageDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDetailView.swift; sourceTree = ""; }; 18DFEA7126F89956523019B4 /* KeychainTokenStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainTokenStoreTests.swift; sourceTree = ""; }; + 19C60393B474CFC92B46386C /* RunnerHostIdentityTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostIdentityTab.swift; sourceTree = ""; }; + 1A8DB8D7DB7317B7B3E4775C /* FleetAgentConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetAgentConnection.swift; sourceTree = ""; }; 1A9181A312D267BBD4687762 /* AppPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPreferencesTests.swift; sourceTree = ""; }; 1B18FDEC4B5AE8513C98D7B4 /* SandboxDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxDetailView.swift; sourceTree = ""; }; 1B80964B0A136E03F5FDA0FE /* SandboxesViewModel+PortsFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SandboxesViewModel+PortsFiles.swift"; sourceTree = ""; }; @@ -307,13 +358,15 @@ 2021CE387B9546EE87FA4CD5 /* MachineEventMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineEventMonitor.swift; sourceTree = ""; }; 2022AA30CA121A0876D0B9D5 /* ImagesViewModel+Docker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagesViewModel+Docker.swift"; sourceTree = ""; }; 209E974D66F4B2D9B5CDA2D9 /* VolumesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumesListView.swift; sourceTree = ""; }; - 20DE3993B3F888A667AA988F /* OIDCClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCClientTests.swift; sourceTree = ""; }; 2110402B2013A53E306B4E43 /* MainWindowControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowControllerTests.swift; sourceTree = ""; }; 21565AF3B3606525539AE506 /* SandboxesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxesListView.swift; sourceTree = ""; }; + 21EDC6099569584471CD2FEC /* RunnerJobRuntimeTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobRuntimeTab.swift; sourceTree = ""; }; + 239658775BBC68EB492EF715 /* FleetViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetViewModelTests.swift; sourceTree = ""; }; 23C69EC561EEA79ADE5A9000 /* ContainersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainersViewModel.swift; sourceTree = ""; }; 24D2CE72E40718D37B0293D1 /* StatePlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatePlaceholderView.swift; sourceTree = ""; }; 269B74A3C7704AB81139FA52 /* ContainerLogsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerLogsModels.swift; sourceTree = ""; }; 27130DDA57D2645C7E81D75D /* GuestDataMountTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestDataMountTests.swift; sourceTree = ""; }; + 2759FD2E220699E0BB1153A3 /* RunnerHostJobSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostJobSummary.swift; sourceTree = ""; }; 297FCAB334AF289575D13F72 /* NetworksListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworksListView.swift; sourceTree = ""; }; 2D053B8542825698996A16FE /* ArcBoxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ArcBoxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2D2C529811ECBAD82E110BC4 /* AuthTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTestSupport.swift; sourceTree = ""; }; @@ -330,6 +383,7 @@ 3DAC9EE4F067CB69F022C1E0 /* ActivityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityViewModel.swift; sourceTree = ""; }; 3E30F5FC430804013B58C6F1 /* com.arcboxlabs.desktop.daemon.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = com.arcboxlabs.desktop.daemon.plist; sourceTree = ""; }; 3ED1683EF0A3490B8B7B5CF6 /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = ""; }; + 3F3DBF7CBDB3176071B996D3 /* RunnerHostStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostStatus.swift; sourceTree = ""; }; 4009DCC81EA64B23A77FB0C4 /* ServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesViewModel.swift; sourceTree = ""; }; 420516961E57589A91B445DA /* VolumesViewModel+Docker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VolumesViewModel+Docker.swift"; sourceTree = ""; }; 43B8FF992E687C114ADB8E32 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; @@ -337,9 +391,13 @@ 454ED6CBDC3B851667C70AF8 /* TerminalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalViewController.swift; sourceTree = ""; }; 46D7B7F6B349A602A7BA973E /* SandboxSnapshotsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxSnapshotsTab.swift; sourceTree = ""; }; 472F864720948B01C9F5D5A0 /* K8sClient */ = {isa = PBXFileReference; lastKnownFileType = folder; name = K8sClient; path = Packages/K8sClient; sourceTree = SOURCE_ROOT; }; + 474FD439D2B872416A0CC116 /* RunnersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnersViewModel.swift; sourceTree = ""; }; 47B33A34931421365A5BCAE6 /* StatsFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsFormat.swift; sourceTree = ""; }; + 496FD50DEB793E0F91A70DA4 /* RunnerHostSettingsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostSettingsTab.swift; sourceTree = ""; }; 49A8DDE58F61A6B736A63E11 /* SandboxMonitoringView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxMonitoringView.swift; sourceTree = ""; }; 4A4B5E95EA5A894B19867570 /* LocalRootFSOutlineCoordinator+Columns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocalRootFSOutlineCoordinator+Columns.swift"; sourceTree = ""; }; + 4AB846BC41EE075896CD5598 /* FleetRunnerJobStatus+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FleetRunnerJobStatus+Display.swift"; sourceTree = ""; }; + 4B51BA58BAD575480118B2D2 /* FleetViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetViewModel.swift; sourceTree = ""; }; 4CC37601A89C4ED758AC6C45 /* ContainerTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerTypes.swift; sourceTree = ""; }; 4CEDB1A7E6E5F0EEA2214D21 /* VolumeTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeTypes.swift; sourceTree = ""; }; 4DD7F5E0115C839BB364D59F /* ContainerLogsTab+Formatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContainerLogsTab+Formatting.swift"; sourceTree = ""; }; @@ -366,6 +424,8 @@ 61EE316315F864BA1BF0FFED /* MachinesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachinesViewModel.swift; sourceTree = ""; }; 61F15E941FF769135D0AA218 /* ActivityMetricStrip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityMetricStrip.swift; sourceTree = ""; }; 6218E84D6C2A8595FE308B5E /* ImageLayerChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLayerChain.swift; sourceTree = ""; }; + 62D737D532B4BB4169914591 /* RunnerJobListItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobListItemTests.swift; sourceTree = ""; }; + 63E327BEE2A1A04A55A730BF /* RunnerEmptyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerEmptyState.swift; sourceTree = ""; }; 657D7543F1D47988AEC3339D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 66F91CF47E53375781959EC9 /* ServicesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesListView.swift; sourceTree = ""; }; 68009FEC4A2F0EB046ACCA4C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -375,9 +435,10 @@ 699A57392F1D9366CFA78BAF /* VolumesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumesViewModel.swift; sourceTree = ""; }; 6AC42E91F0BCACABF173B87F /* DockerTerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockerTerminalSession.swift; sourceTree = ""; }; 6ADF9F8FFB694C45042664E8 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; + 6B0888CBDEF33130735BA648 /* RunnerJobLogsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobLogsTab.swift; sourceTree = ""; }; 6DFE1C88F20E40C1A36A384C /* CreateOperationErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateOperationErrorTests.swift; sourceTree = ""; }; 7058C8975F3F2684714E24E2 /* SleepWakeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepWakeManager.swift; sourceTree = ""; }; - 71DF8E5C8AADE1AD5C60F457 /* OIDCClientConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCClientConfigurationTests.swift; sourceTree = ""; }; + 725E693E00BCEB4D0ADE7A25 /* FleetControlClient */ = {isa = PBXFileReference; lastKnownFileType = folder; name = FleetControlClient; path = Packages/FleetControlClient; sourceTree = SOURCE_ROOT; }; 738233A82C7BAA825F73EE2F /* GuestExportFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestExportFixture.swift; sourceTree = ""; }; 747AB46D7603C23FB9112444 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 7607A876F6C88A440DA53BE1 /* SidebarAccountButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAccountButton.swift; sourceTree = ""; }; @@ -390,31 +451,39 @@ 7AB1B1CD1D5E0E961CD17118 /* MachinesViewModel+GRPC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MachinesViewModel+GRPC.swift"; sourceTree = ""; }; 7B6FDB677AE01E62877A17AC /* K8sModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = K8sModelsTests.swift; sourceTree = ""; }; 7BC24B8A6E422D834614E5D5 /* ArcBoxAuth */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ArcBoxAuth; path = Packages/ArcBoxAuth; sourceTree = SOURCE_ROOT; }; + 7BCD10A967FC56763AAAD3AD /* RunnerJobListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobListItem.swift; sourceTree = ""; }; + 7BDFC648740B4664FCC1E5E5 /* RunnersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnersView.swift; sourceTree = ""; }; 7CD5F80A65CA7E59F453286D /* VolumeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeDetailView.swift; sourceTree = ""; }; 7D5BDCAAF11D1A6B52990E28 /* ContainerViewModel+Mapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContainerViewModel+Mapping.swift"; sourceTree = ""; }; 7DDFA2EDA7A3AC8448696F91 /* VolumeFilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeFilesTab.swift; sourceTree = ""; }; + 7EFC35C028AEC068D062C12F /* RunnerHostCapacityTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostCapacityTab.swift; sourceTree = ""; }; 80010452354615CAC08FAF1D /* ContainersListViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainersListViewControllerTests.swift; sourceTree = ""; }; - 8071725DD08666599A15CE65 /* OIDCAuthorizationURLBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCAuthorizationURLBuilderTests.swift; sourceTree = ""; }; 81FCCED6F39D73E946107243 /* MachineEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineEmptyStateView.swift; sourceTree = ""; }; 823F87591055C314EC29BF67 /* ToolbarSeparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarSeparator.swift; sourceTree = ""; }; 829D8DC499E6339D13A2ADE7 /* SandboxesViewModel+GRPC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SandboxesViewModel+GRPC.swift"; sourceTree = ""; }; 8357344FFAC2AB6F56EE95EC /* MachinesListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachinesListViewController.swift; sourceTree = ""; }; 83FBE0C82E0EB430C69EB944 /* GuestDataMount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestDataMount.swift; sourceTree = ""; }; + 844E4C199E51918262A8C14F /* RunnerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerSelection.swift; sourceTree = ""; }; 85296C05DC9D33C7930366D7 /* ServicesListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesListViewController.swift; sourceTree = ""; }; 85321B230A582D1C0AF76B5C /* LocalRootFSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalRootFSService.swift; sourceTree = ""; }; + 8586B73ACB03595FB3074B38 /* RunnerHostOverviewTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostOverviewTab.swift; sourceTree = ""; }; 85EB02E7DC54A70CDFD0C694 /* SheetErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetErrorMessage.swift; sourceTree = ""; }; 85F6CFF90B9BC6AE2836414C /* LocalRootFSOutlineCoordinator+Loading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocalRootFSOutlineCoordinator+Loading.swift"; sourceTree = ""; }; + 8619720F1A99E2E6A431585C /* FleetEnrollmentCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetEnrollmentCoordinator.swift; sourceTree = ""; }; 867A6CA0B4DD07BE74610FCB /* VolumeViewModel+Docker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VolumeViewModel+Docker.swift"; sourceTree = ""; }; 88EB2359E678AC8E1EEEB83A /* VolumesListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumesListViewController.swift; sourceTree = ""; }; 8AD5CF45B00B80D85A778AB4 /* MachineModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineModel.swift; sourceTree = ""; }; + 8B81497897F5306643CC2AD1 /* FleetBackend+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FleetBackend+DisplayName.swift"; sourceTree = ""; }; + 8B99162EF318CE94662C54BA /* RunnerJobDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobDetailView.swift; sourceTree = ""; }; 8DB22F8A7AA46286F186A73F /* DockerEventMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockerEventMonitor.swift; sourceTree = ""; }; - 8E780C92770118BBD052882E /* WebAuthenticationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthenticationController.swift; sourceTree = ""; }; 8ECCF18CC36CC3579EE2B5B9 /* MachinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachinesView.swift; sourceTree = ""; }; + 8F9A6D7A71C8A071AB76AE2C /* RunnerImagePreparationStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerImagePreparationStatusView.swift; sourceTree = ""; }; 903FA72CEF39142ADA5CA378 /* NewVolumeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewVolumeSheet.swift; sourceTree = ""; }; 9101DCB26B0BB5B8DE4CAE70 /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; 915561BEEE49F5E27F4B0D40 /* OnboardingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindowController.swift; sourceTree = ""; }; 920C9CA81449FA23CEC17EF6 /* SandboxFilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxFilesTab.swift; sourceTree = ""; }; 9234FC5BCCDDCBF441A94880 /* UpdaterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterDelegate.swift; sourceTree = ""; }; + 92FD51B5947EFEEFD73D00F9 /* FleetEnrollmentCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetEnrollmentCoordinatorTests.swift; sourceTree = ""; }; 93968609F8E058EAC838DD18 /* UpdaterSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterSettingsModel.swift; sourceTree = ""; }; 9467D1D1EA60DDF80414B20B /* ContainerListModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerListModels.swift; sourceTree = ""; }; 95F313C9C7AA84A964728D44 /* AppDelegate+Telemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Telemetry.swift"; sourceTree = ""; }; @@ -424,8 +493,10 @@ 9A04AFE137230F2CE0DD9DBB /* DiagnosticBundleExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticBundleExporter.swift; sourceTree = ""; }; 9AA5247E3096D7A9D54A270A /* AccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsView.swift; sourceTree = ""; }; 9E73318FC9D8EBCB39E5C0AE /* IconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButton.swift; sourceTree = ""; }; + 9F1CB07080BD71955DCF864A /* RunnerJobDetailTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobDetailTab.swift; sourceTree = ""; }; 9F60CF8B8E81089BA2E8DC20 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; A10ED4BA65D6B9CC56B6B329 /* ChangelogParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangelogParserTests.swift; sourceTree = ""; }; + A1FE07EA71512ACEE609B537 /* RunnerHostCapability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostCapability.swift; sourceTree = ""; }; A2B6587D59B9EFA18E51A3F3 /* ImagesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesViewModelTests.swift; sourceTree = ""; }; A2B92BFA23F4133B8488DDD8 /* Local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Local.xcconfig; sourceTree = ""; }; A37BB3605830EBE3A1625452 /* PodModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodModel.swift; sourceTree = ""; }; @@ -434,6 +505,7 @@ A70BE1C3890C5F6A96C6BCBD /* ContainersViewModel+GRPC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContainersViewModel+GRPC.swift"; sourceTree = ""; }; A764F03F9EA5C3149B777338 /* SandboxesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxesViewModel.swift; sourceTree = ""; }; A80B0A0F4FC2E7ECA0B844DD /* ContainerFilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerFilesTab.swift; sourceTree = ""; }; + AA0656F14604E5C2380BFA93 /* FleetRunnerImageReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetRunnerImageReadiness.swift; sourceTree = ""; }; AA0C5A43740FFD68902A2F77 /* AboutView+SystemInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AboutView+SystemInfo.swift"; sourceTree = ""; }; AA88097CAC1929269BE210C5 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; ABC372134FDA259C40503AE1 /* ArcBox.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ArcBox.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -445,17 +517,21 @@ B050286A2450880AC2C95342 /* ActivityContainerTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityContainerTable.swift; sourceTree = ""; }; B0841C2969EC1A1BC07489C8 /* SandboxTerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxTerminalSession.swift; sourceTree = ""; }; B084B3E5BD66E2DA018764E8 /* QuitWindowControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuitWindowControllerTests.swift; sourceTree = ""; }; + B2387BC90EBBF4477B65367E /* RunnerJobsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobsView.swift; sourceTree = ""; }; B2CC2B117E020C62C1E9952E /* ContainerLogsTab+Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContainerLogsTab+Views.swift"; sourceTree = ""; }; B38661BA5859384BCCE61B0C /* PodDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodDetailView.swift; sourceTree = ""; }; B3BE02225F61EF92333916B1 /* ImageFilesTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilesTab.swift; sourceTree = ""; }; B4BAE047B96D10B4716BA1A2 /* VolumesListViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumesListViewControllerTests.swift; sourceTree = ""; }; B52400B734764C05E50D6FFC /* ResourceStatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceStatsTests.swift; sourceTree = ""; }; + B53A3591FEDEA055FFA59367 /* RunnerJobInfoTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobInfoTab.swift; sourceTree = ""; }; B5A780F10224ACC8C1B53D1E /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; B65698313C3204F5F23E099B /* CommandHint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandHint.swift; sourceTree = ""; }; B69A867E81CB7E281682A24D /* ContainerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerModel.swift; sourceTree = ""; }; + B6BC28A842D0A91FA84517CC /* RunnerHostViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostViewModel.swift; sourceTree = ""; }; B71D28721E41F8303DBF7232 /* MachineImageCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineImageCatalog.swift; sourceTree = ""; }; B8254D67C0D80B99FC18DAD5 /* PullImageSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullImageSheet.swift; sourceTree = ""; }; B8F932C1467A5FB5CFBD0444 /* LocalRootFSOutlineCoordinator+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocalRootFSOutlineCoordinator+DataSource.swift"; sourceTree = ""; }; + B96EF490DB110369883A43D6 /* RunnerHostOverviewStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostOverviewStatusSection.swift; sourceTree = ""; }; B9C7ADBBD83C5F92EEBAAD88 /* MachineDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineDetailView.swift; sourceTree = ""; }; B9EDD0CCB1C4698A31FC1ED1 /* LocalRootFSOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalRootFSOutlineView.swift; sourceTree = ""; }; BA69DFAC30647687529C962A /* ContainersListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainersListView.swift; sourceTree = ""; }; @@ -472,33 +548,43 @@ C396CA9355FFB5ACB7DD1D32 /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; C3AAFC5E642995054B983811 /* ContainersListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainersListViewController.swift; sourceTree = ""; }; C83FBFE31C0C7B96918BFCC6 /* KubernetesListViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KubernetesListViewControllerTests.swift; sourceTree = ""; }; + C8FE218992C260511CE601B8 /* FleetControlServicing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetControlServicing.swift; sourceTree = ""; }; C964312D5336EE55A8E4680B /* ImageLayerChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLayerChainTests.swift; sourceTree = ""; }; C9F4E99670A28483DBAA6CC8 /* LayerStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayerStackTests.swift; sourceTree = ""; }; CC7024634F15938CC362E861 /* CommandEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEmptyStateView.swift; sourceTree = ""; }; CC98017408ED56AFF734125A /* AboutView+Sections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AboutView+Sections.swift"; sourceTree = ""; }; CDF5B0B3EC77FB12D8B4EA6A /* PodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodsViewModel.swift; sourceTree = ""; }; CE1AFAED2CB7429B714C8A06 /* ContainerTerminalTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerTerminalTab.swift; sourceTree = ""; }; + D02391823A10B16B12627E6C /* RunnerHostStatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostStatusBar.swift; sourceTree = ""; }; D12C2D038DE6CC49702F85F0 /* ContainersViewModel+Docker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContainersViewModel+Docker.swift"; sourceTree = ""; }; - D3DB0D1F23DD6976DA73A33C /* PKCETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCETests.swift; sourceTree = ""; }; + D1F4663B38FDFB64EBE74EAC /* RunnerJobDetailModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerJobDetailModelTests.swift; sourceTree = ""; }; + D3F1497910C2DE7F04BA5528 /* RunnerHostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostDetailView.swift; sourceTree = ""; }; D4ED3EC883A488801DE03195 /* NetworksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworksViewModel.swift; sourceTree = ""; }; D6F75C788ADEDBC9B1F007E3 /* QuitWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuitWindowController.swift; sourceTree = ""; }; + DA8937BC21E9813E2F8AA7F2 /* RunnerPlatformStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerPlatformStoreTests.swift; sourceTree = ""; }; DAE5A29026FA64A7C0A47122 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; DB1AD9099DE0BF8CCBC35667 /* DetailTabPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailTabPicker.swift; sourceTree = ""; }; DB9FDA2F6F3036772AC0B23C /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = ""; }; DBEBBF08F0E4AC45D9C0BB83 /* ChangelogParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangelogParser.swift; sourceTree = ""; }; - DE25BFAD7CCE499E2AA55EE9 /* IDTokenClaimsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDTokenClaimsTests.swift; sourceTree = ""; }; DE564AAFEC0EF6D094409728 /* SandboxRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxRowView.swift; sourceTree = ""; }; DE7E377F9D8C89E24989DE3D /* ContentViewColumnLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewColumnLayoutTests.swift; sourceTree = ""; }; DF34CA0688377F7956F2FD3D /* NewSandboxSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSandboxSheet.swift; sourceTree = ""; }; E06B22BB8B92AF78C2B87ADC /* AboutView+LinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AboutView+LinkButton.swift"; sourceTree = ""; }; + E0C6C10DE353C0ED6E2E85DF /* RunnerHostOverviewJobsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostOverviewJobsSection.swift; sourceTree = ""; }; E18EB65626D97DF7604580DF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E1C6B2F85E39321931CEC3ED /* ContainerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerDetailView.swift; sourceTree = ""; }; + E1F4B62DCEFF676208ACD162 /* FleetMachineStatus+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FleetMachineStatus+DisplayName.swift"; sourceTree = ""; }; E217254D0827A011E01C3F05 /* StatusItemController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItemController.swift; sourceTree = ""; }; E37F8C85EB27987F81FFB5F4 /* KubernetesState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KubernetesState.swift; sourceTree = ""; }; E38681FE9A98E55B1608E662 /* SandboxEventsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxEventsTab.swift; sourceTree = ""; }; E3A8FF41635473729BF0A36E /* StatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadge.swift; sourceTree = ""; }; + E53A873988FE80AD4424FE13 /* RunnerHostOverviewPoolsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostOverviewPoolsSection.swift; sourceTree = ""; }; + E5758E839E4363E54EEECD99 /* RunnerHostJobSummaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostJobSummaryTests.swift; sourceTree = ""; }; E610512C9106BB92A47DC044 /* ContainersViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainersViewModelTests.swift; sourceTree = ""; }; + E69E766A830CB3A26231BA11 /* FleetSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FleetSettingsView.swift; sourceTree = ""; }; E8632512053E9C750CAA97F9 /* MachineTerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineTerminalSession.swift; sourceTree = ""; }; + E902A97C32D4034C22684BBD /* RunnersViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnersViewModelTests.swift; sourceTree = ""; }; + E992C1D278E4591238514552 /* FleetPlatformClient */ = {isa = PBXFileReference; lastKnownFileType = folder; name = FleetPlatformClient; path = Packages/FleetPlatformClient; sourceTree = SOURCE_ROOT; }; EA6DBFBB43078F295AFE54C0 /* PerformanceTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceTracing.swift; sourceTree = ""; }; EB477EF803A15EDFF2685A23 /* ImageTerminalTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTerminalTab.swift; sourceTree = ""; }; EBD1F83B573C40DFA09E5633 /* StatePlaceholderTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatePlaceholderTestSupport.swift; sourceTree = ""; }; @@ -506,14 +592,18 @@ ECF57E2C2B0281B8CEA6132C /* MenuBarView+Metrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MenuBarView+Metrics.swift"; sourceTree = ""; }; EE6C627A5BC34C783DAEAC31 /* LocalRootFSOutlineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalRootFSOutlineModels.swift; sourceTree = ""; }; EF45523D23D3F96612423943 /* HelperInstallErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperInstallErrorTests.swift; sourceTree = ""; }; + EF537351518DBAB4EC8BAE61 /* RunnerHostDetailTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerHostDetailTab.swift; sourceTree = ""; }; EFB03A05EDADB4F1FA6E10A1 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; F0647CCB67160C2509345B5E /* MachineModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineModelTests.swift; sourceTree = ""; }; + F12EE31052572ABF8B455044 /* BetterAuthClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BetterAuthClientTests.swift; sourceTree = ""; }; F238F21169A97B94193454CF /* ServiceDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceDetailView.swift; sourceTree = ""; }; F4705819BA2C07E7EADAC37E /* MachineTerminalTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineTerminalTab.swift; sourceTree = ""; }; F4E1595F793A3AD5691E191D /* LayerMergeBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayerMergeBadge.swift; sourceTree = ""; }; F5B3B3C54B6D49C6198E9E26 /* DeepLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkTests.swift; sourceTree = ""; }; + F5D8365238BDE73993270477 /* AuthClientConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClientConfigurationTests.swift; sourceTree = ""; }; F5F318A121B0C8EA1A05BFE5 /* InfoRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoRow.swift; sourceTree = ""; }; F62FD6244C56EE8E45DF0EC7 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = ""; }; + F6ABF0A0B6218A3FE504C7DA /* RunnersViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnersViewState.swift; sourceTree = ""; }; F707491EC9A47AE534822664 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; F734677F8F8EFD08CB32E8B3 /* SystemSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsView.swift; sourceTree = ""; }; F893B8994BA04A6813FCB819 /* TerminalAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppearance.swift; sourceTree = ""; }; @@ -539,12 +629,14 @@ files = ( FA631E839B38C6D0DBDBA928 /* ArcBoxAuth in Frameworks */, 82ECF33C9637C7C65B527336 /* ArcBoxClient in Frameworks */, - 4D2FB362066EF19A8C5983F1 /* DockerClient in Frameworks */, - 0AF01BD1D2FC14E2F99E9B4C /* K8sClient in Frameworks */, - E92C8245EE6AE51D99035E4D /* SwiftTerm in Frameworks */, - C12978695EDC917704747269 /* Sparkle in Frameworks */, - FBDBF39F818DDE35E0B7D0DF /* Sentry in Frameworks */, - CC2502232C562D3167B2F3D2 /* PostHog in Frameworks */, + 4D2FB362066EF19A8C5983F1 /* FleetControlClient in Frameworks */, + 0AF01BD1D2FC14E2F99E9B4C /* FleetPlatformClient in Frameworks */, + E92C8245EE6AE51D99035E4D /* DockerClient in Frameworks */, + C12978695EDC917704747269 /* K8sClient in Frameworks */, + FBDBF39F818DDE35E0B7D0DF /* SwiftTerm in Frameworks */, + CC2502232C562D3167B2F3D2 /* Sparkle in Frameworks */, + D7149FCB6052803F6900926B /* Sentry in Frameworks */, + 7DB0F638AAD7DCB4F8349402 /* PostHog in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -622,6 +714,7 @@ isa = PBXGroup; children = ( B69A867E81CB7E281682A24D /* ContainerModel.swift */, + AA0656F14604E5C2380BFA93 /* FleetRunnerImageReadiness.swift */, BD8BD8A4B6EEE9722D54739D /* ImageModel.swift */, 5A4AA61258823B0E929F4520 /* LoadPhase.swift */, 8AD5CF45B00B80D85A778AB4 /* MachineModel.swift */, @@ -629,6 +722,16 @@ 69038D73EF8AC0CC09D4B39C /* NavItem.swift */, 0B8670CF269C853BD2039B34 /* NetworkModel.swift */, A37BB3605830EBE3A1625452 /* PodModel.swift */, + A1FE07EA71512ACEE609B537 /* RunnerHostCapability.swift */, + EF537351518DBAB4EC8BAE61 /* RunnerHostDetailTab.swift */, + 2759FD2E220699E0BB1153A3 /* RunnerHostJobSummary.swift */, + 3F3DBF7CBDB3176071B996D3 /* RunnerHostStatus.swift */, + B6BC28A842D0A91FA84517CC /* RunnerHostViewModel.swift */, + 06F86AD3D61B9D443086AA78 /* RunnerJobDetailModel.swift */, + 9F1CB07080BD71955DCF864A /* RunnerJobDetailTab.swift */, + 7BCD10A967FC56763AAAD3AD /* RunnerJobListItem.swift */, + 844E4C199E51918262A8C14F /* RunnerSelection.swift */, + F6ABF0A0B6218A3FE504C7DA /* RunnersViewState.swift */, BA990161E9F1709745BE5796 /* SandboxModel.swift */, B0841C2969EC1A1BC07489C8 /* SandboxTerminalSession.swift */, 13203BD67479A8191C1A3BD6 /* ServiceModel.swift */, @@ -744,6 +847,7 @@ isa = PBXGroup; children = ( 9AA5247E3096D7A9D54A270A /* AccountSettingsView.swift */, + E69E766A830CB3A26231BA11 /* FleetSettingsView.swift */, 9F60CF8B8E81089BA2E8DC20 /* GeneralSettingsView.swift */, DAE5A29026FA64A7C0A47122 /* SettingsView.swift */, F915D676AC7EDF66A67B77A9 /* StorageSettingsView.swift */, @@ -766,6 +870,7 @@ 0519B44E047B9175695BEB9D /* MenuBar */, 1B2068276E193C5409B45A9A /* Networks */, 7D67538045DC5ABA446A6F68 /* Onboarding */, + 8F4B93C32E5781E0D3DE5A52 /* Runners */, EAC6176638594B6D1458C90D /* Sandboxes */, 36146B8720F5A0DE35FA0026 /* Settings */, 1953A7C4A7DE99DBA4B29185 /* Shared */, @@ -827,14 +932,24 @@ children = ( 9A04AFE137230F2CE0DD9DBB /* DiagnosticBundleExporter.swift */, 8DB22F8A7AA46286F186A73F /* DockerEventMonitor.swift */, + 1A8DB8D7DB7317B7B3E4775C /* FleetAgentConnection.swift */, 2021CE387B9546EE87FA4CD5 /* MachineEventMonitor.swift */, B71D28721E41F8303DBF7232 /* MachineImageCatalog.swift */, 50C68609B67AC82FF386CB92 /* SandboxEventMonitor.swift */, - 8E780C92770118BBD052882E /* WebAuthenticationController.swift */, ); path = Services; sourceTree = ""; }; + 4EC21FABED29FDC78DC34D1C /* Fleet */ = { + isa = PBXGroup; + children = ( + C8FE218992C260511CE601B8 /* FleetControlServicing.swift */, + 8619720F1A99E2E6A431585C /* FleetEnrollmentCoordinator.swift */, + 4B51BA58BAD575480118B2D2 /* FleetViewModel.swift */, + ); + path = Fleet; + sourceTree = ""; + }; 5F282325419E9444ADBEA66D /* Support */ = { isa = PBXGroup; children = ( @@ -895,11 +1010,14 @@ 7AB1B1CD1D5E0E961CD17118 /* MachinesViewModel+GRPC.swift */, D4ED3EC883A488801DE03195 /* NetworksViewModel.swift */, CDF5B0B3EC77FB12D8B4EA6A /* PodsViewModel.swift */, + 062988A69CDF42259286245B /* RunnerPlatformStore.swift */, + 474FD439D2B872416A0CC116 /* RunnersViewModel.swift */, 4009DCC81EA64B23A77FB0C4 /* ServicesViewModel.swift */, C0BC46E06601AA9D330AF049 /* SystemVmBackendModel.swift */, 9234FC5BCCDDCBF441A94880 /* UpdaterDelegate.swift */, 93968609F8E058EAC838DD18 /* UpdaterSettingsModel.swift */, 3DCBC0E44C2DBB3064D4A759 /* Containers */, + 4EC21FABED29FDC78DC34D1C /* Fleet */, 7D89C26DD9B6BEB015B28923 /* Images */, 46C6E77D6FA08BC8D86F3C3B /* Sandboxes */, FAA50F82805A12EB07DC7083 /* Volumes */, @@ -926,6 +1044,8 @@ 7BC24B8A6E422D834614E5D5 /* ArcBoxAuth */, 00AE70F2EF523D6E02FFF371 /* ArcBoxClient */, C33F6D43F3CE2C79F072C7F3 /* DockerClient */, + 725E693E00BCEB4D0ADE7A25 /* FleetControlClient */, + E992C1D278E4591238514552 /* FleetPlatformClient */, 472F864720948B01C9F5D5A0 /* K8sClient */, ); path = Packages; @@ -943,6 +1063,35 @@ path = Activity; sourceTree = ""; }; + 8F4B93C32E5781E0D3DE5A52 /* Runners */ = { + isa = PBXGroup; + children = ( + 8B81497897F5306643CC2AD1 /* FleetBackend+DisplayName.swift */, + E1F4B62DCEFF676208ACD162 /* FleetMachineStatus+DisplayName.swift */, + 4AB846BC41EE075896CD5598 /* FleetRunnerJobStatus+Display.swift */, + 63E327BEE2A1A04A55A730BF /* RunnerEmptyState.swift */, + 7EFC35C028AEC068D062C12F /* RunnerHostCapacityTab.swift */, + D3F1497910C2DE7F04BA5528 /* RunnerHostDetailView.swift */, + 19C60393B474CFC92B46386C /* RunnerHostIdentityTab.swift */, + E0C6C10DE353C0ED6E2E85DF /* RunnerHostOverviewJobsSection.swift */, + E53A873988FE80AD4424FE13 /* RunnerHostOverviewPoolsSection.swift */, + B96EF490DB110369883A43D6 /* RunnerHostOverviewStatusSection.swift */, + 8586B73ACB03595FB3074B38 /* RunnerHostOverviewTab.swift */, + 496FD50DEB793E0F91A70DA4 /* RunnerHostSettingsTab.swift */, + D02391823A10B16B12627E6C /* RunnerHostStatusBar.swift */, + 8F9A6D7A71C8A071AB76AE2C /* RunnerImagePreparationStatusView.swift */, + 8B99162EF318CE94662C54BA /* RunnerJobDetailView.swift */, + B53A3591FEDEA055FFA59367 /* RunnerJobInfoTab.swift */, + 6B0888CBDEF33130735BA648 /* RunnerJobLogsTab.swift */, + 080B7568EE5A517F8CC343FE /* RunnerJobRow.swift */, + 21EDC6099569584471CD2FEC /* RunnerJobRuntimeTab.swift */, + B2387BC90EBBF4477B65367E /* RunnerJobsView.swift */, + 16087ADE0B07FDD6E3D719F4 /* RunnerSelectionDetailView.swift */, + 7BDFC648740B4664FCC1E5E5 /* RunnersView.swift */, + ); + path = Runners; + sourceTree = ""; + }; 93FCB887D6304AC5E61E4D2B /* Containers */ = { isa = PBXGroup; children = ( @@ -983,14 +1132,11 @@ AC4AAD074C3A39ADAB6FAD7B /* ArcBoxAuthTests */ = { isa = PBXGroup; children = ( + F5D8365238BDE73993270477 /* AuthClientConfigurationTests.swift */, FBA33C724456FCDF5A3E52AD /* AuthSessionTests.swift */, 2D2C529811ECBAD82E110BC4 /* AuthTestSupport.swift */, - DE25BFAD7CCE499E2AA55EE9 /* IDTokenClaimsTests.swift */, + F12EE31052572ABF8B455044 /* BetterAuthClientTests.swift */, 18DFEA7126F89956523019B4 /* KeychainTokenStoreTests.swift */, - 8071725DD08666599A15CE65 /* OIDCAuthorizationURLBuilderTests.swift */, - 71DF8E5C8AADE1AD5C60F457 /* OIDCClientConfigurationTests.swift */, - 20DE3993B3F888A667AA988F /* OIDCClientTests.swift */, - D3DB0D1F23DD6976DA73A33C /* PKCETests.swift */, ); name = ArcBoxAuthTests; path = Packages/ArcBoxAuth/Tests/ArcBoxAuthTests; @@ -1129,6 +1275,8 @@ 6DFE1C88F20E40C1A36A384C /* CreateOperationErrorTests.swift */, F5B3B3C54B6D49C6198E9E26 /* DeepLinkTests.swift */, 06DFFB59D72F27F14A233B81 /* DockerEventMonitorTests.swift */, + 92FD51B5947EFEEFD73D00F9 /* FleetEnrollmentCoordinatorTests.swift */, + 239658775BBC68EB492EF715 /* FleetViewModelTests.swift */, 27130DDA57D2645C7E81D75D /* GuestDataMountTests.swift */, 738233A82C7BAA825F73EE2F /* GuestExportFixture.swift */, C964312D5336EE55A8E4680B /* ImageLayerChainTests.swift */, @@ -1147,6 +1295,11 @@ FA27F4AC273B14038EDB6593 /* NetworksListViewControllerTests.swift */, A517313E1085B62A40075622 /* OnboardingWindowControllerTests.swift */, B084B3E5BD66E2DA018764E8 /* QuitWindowControllerTests.swift */, + E5758E839E4363E54EEECD99 /* RunnerHostJobSummaryTests.swift */, + D1F4663B38FDFB64EBE74EAC /* RunnerJobDetailModelTests.swift */, + 62D737D532B4BB4169914591 /* RunnerJobListItemTests.swift */, + DA8937BC21E9813E2F8AA7F2 /* RunnerPlatformStoreTests.swift */, + E902A97C32D4034C22684BBD /* RunnersViewModelTests.swift */, EBD1F83B573C40DFA09E5633 /* StatePlaceholderTestSupport.swift */, B4BAE047B96D10B4716BA1A2 /* VolumesListViewControllerTests.swift */, ); @@ -1195,6 +1348,8 @@ packageProductDependencies = ( 1EEA05F99067212B54970FA5 /* ArcBoxAuth */, CFE468A82BAC1B4D9AE25368 /* ArcBoxClient */, + 0631A4C3410E8D6E9CFDDA2E /* FleetControlClient */, + 1143C225F6C8A5BF7366588B /* FleetPlatformClient */, 984278376FF4860E6FADC66F /* DockerClient */, A8DA5BF5FF6E9CABB1BAAD3A /* K8sClient */, B23ADDA7527EDFFFD6615B12 /* SwiftTerm */, @@ -1262,6 +1417,8 @@ 82C744580F93EFBCA71607AA /* XCLocalSwiftPackageReference "Packages/ArcBoxAuth" */, 52B6A9B00A6EABBBFA08E62D /* XCLocalSwiftPackageReference "Packages/ArcBoxClient" */, 5FB2872C43BFD0CA00D1FF5E /* XCLocalSwiftPackageReference "Packages/DockerClient" */, + 5943466D13147838B8BE6D69 /* XCLocalSwiftPackageReference "Packages/FleetControlClient" */, + 2E3DD7E31B54E2BB5117816E /* XCLocalSwiftPackageReference "Packages/FleetPlatformClient" */, 3D19705D19A4ADF65F4A7A45 /* XCLocalSwiftPackageReference "Packages/K8sClient" */, ); preferredProjectObjectVersion = 77; @@ -1394,6 +1551,15 @@ 519C116D9CC62268CE4859D4 /* ExternalTerminalApp.swift in Sources */, 4B14BB17843A552B8D1B51BA /* ExternalTerminalDiscovery.swift in Sources */, 97E56AA0BEC7024FEBBE1B75 /* ExternalTerminalLauncher.swift in Sources */, + DAA8D86963F68C3BE402619B /* FleetAgentConnection.swift in Sources */, + 45BB87866AFBADAF6F07CA76 /* FleetBackend+DisplayName.swift in Sources */, + E9DA27A7255C72A3FE6E5599 /* FleetControlServicing.swift in Sources */, + 0E1E08820992C2853A6F23F3 /* FleetEnrollmentCoordinator.swift in Sources */, + 975051DCB1D5B9738AEF3661 /* FleetMachineStatus+DisplayName.swift in Sources */, + C38303D18080C9AED2BD8D20 /* FleetRunnerImageReadiness.swift in Sources */, + 50BAFC716B373FD97055B801 /* FleetRunnerJobStatus+Display.swift in Sources */, + C1CEE635A115E843DF66A65B /* FleetSettingsView.swift in Sources */, + FF70A2FA2732211E1C4F7297 /* FleetViewModel.swift in Sources */, F319F8722F145CCFA01650B6 /* GeneralSettingsView.swift in Sources */, BA5C4C0A98D9650B14A2ECB8 /* GuestDataMount.swift in Sources */, FBDC1F10288E1448184A91E3 /* IconButton.swift in Sources */, @@ -1467,6 +1633,37 @@ 0C6B829AD5010FDB0E749471 /* PullImageSheet.swift in Sources */, 3FA536EBD8D183064D0627E2 /* QuitWindowController.swift in Sources */, A0AA341C4E3000ED0828F01B /* ResourceListRowView.swift in Sources */, + A2FA1D274C5DADFC199EE437 /* RunnerEmptyState.swift in Sources */, + 7D6BA204C7E5B022B6267289 /* RunnerHostCapability.swift in Sources */, + E809752CD798B29AFF096BD3 /* RunnerHostCapacityTab.swift in Sources */, + FF140AF8CD86CF1FD3AAAAAC /* RunnerHostDetailTab.swift in Sources */, + 1E07B971374B4CE76D32DBC2 /* RunnerHostDetailView.swift in Sources */, + D2E52E2552B8D7E96A843632 /* RunnerHostIdentityTab.swift in Sources */, + 3F06D58F1B4041869FEC4F19 /* RunnerHostJobSummary.swift in Sources */, + CF0180F7923FEC8E8C5FE917 /* RunnerHostOverviewJobsSection.swift in Sources */, + D8E28DEE224DA7D44727E216 /* RunnerHostOverviewPoolsSection.swift in Sources */, + AD65518905804CCB175FD6CA /* RunnerHostOverviewStatusSection.swift in Sources */, + 7B283BBB6B3C8DA7B650328A /* RunnerHostOverviewTab.swift in Sources */, + E8A6AABEC56C79575194CAAF /* RunnerHostSettingsTab.swift in Sources */, + 3B817A9A7D75BB17FF688B75 /* RunnerHostStatus.swift in Sources */, + DC8E940905BADCF4888BEA0A /* RunnerHostStatusBar.swift in Sources */, + 510B8922C820BE4607F7817B /* RunnerHostViewModel.swift in Sources */, + D555C507200C70946F381C91 /* RunnerImagePreparationStatusView.swift in Sources */, + F3B97C8B5BDF2AF98BB2F0B3 /* RunnerJobDetailModel.swift in Sources */, + 30E9F74DC5FA96D1B1023EC2 /* RunnerJobDetailTab.swift in Sources */, + DF8E90AA48D1B41916F87C5E /* RunnerJobDetailView.swift in Sources */, + BF0046600965F7A52FCDEB8F /* RunnerJobInfoTab.swift in Sources */, + F9DFD7D1A67ECB96BEEC3D6F /* RunnerJobListItem.swift in Sources */, + 7DD53D9600A91363E177DE2D /* RunnerJobLogsTab.swift in Sources */, + 4F2B260760140AEAEDBACBC2 /* RunnerJobRow.swift in Sources */, + 90CB65EA852B32711B9418B0 /* RunnerJobRuntimeTab.swift in Sources */, + 096533539D74E8FA5A322C0A /* RunnerJobsView.swift in Sources */, + A32149C2AC3A6B3A7BE1B8EC /* RunnerPlatformStore.swift in Sources */, + F9B2E0EFEECE4F44B659E8D5 /* RunnerSelection.swift in Sources */, + 592084F442E916A94A8B9CBA /* RunnerSelectionDetailView.swift in Sources */, + 6FBDCCAB7F2960C0A0330A04 /* RunnersView.swift in Sources */, + BA69D54C021A974036846623 /* RunnersViewModel.swift in Sources */, + C19B191AF0DE9179608BF40A /* RunnersViewState.swift in Sources */, 74488A22CBA34633776AC5D2 /* SandboxDetailView.swift in Sources */, 8C3C03A50C1B7D97829F9DA5 /* SandboxEmptyState.swift in Sources */, 442FF72709337D1BCAA0643D /* SandboxEventMonitor.swift in Sources */, @@ -1522,7 +1719,6 @@ 46B6C9A7C600F539CFF338FE /* VolumesListViewController.swift in Sources */, 8ED7BA2FEE7010BDBD4BAEDC /* VolumesViewModel+Docker.swift in Sources */, ADE462B8234F0D1F9D8165B3 /* VolumesViewModel.swift in Sources */, - 456A5157DDC9968B75F853BD /* WebAuthenticationController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1534,8 +1730,10 @@ 11168BDC9BB01B20733A9063 /* AppPreferencesTests.swift in Sources */, C94515858BC220B73B93EC5D /* ArcBoxClientErrorTests.swift in Sources */, 6276E31C6E2F736A7B5F41E0 /* ArcBoxClientTests.swift in Sources */, + BECE57281BC047E471893209 /* AuthClientConfigurationTests.swift in Sources */, 2EFFFF0AEC16386829FAC491 /* AuthSessionTests.swift in Sources */, 699F9908BD6DBCD8244DCEE5 /* AuthTestSupport.swift in Sources */, + AF8784321B0BF66F476749C5 /* BetterAuthClientTests.swift in Sources */, 70996419B03971A57B1DCF0F /* ByteFormattingTests.swift in Sources */, 31474CB365691C57A5568D14 /* ChangelogParserTests.swift in Sources */, DB19F0073D84ED7A7C937D49 /* ContainersListViewControllerTests.swift in Sources */, @@ -1544,10 +1742,11 @@ 853F2D06A878BE9A867BE3FD /* CreateOperationErrorTests.swift in Sources */, EE2D38BC76500BF768EEE20F /* DeepLinkTests.swift in Sources */, 8466B000B68A87CEECA6756F /* DockerEventMonitorTests.swift in Sources */, + 868F62A98F4BD1023DD79167 /* FleetEnrollmentCoordinatorTests.swift in Sources */, + 0992A10AFBEFE1BC32E351DE /* FleetViewModelTests.swift in Sources */, D2AB4B6BF0723EE12A0DBC83 /* GuestDataMountTests.swift in Sources */, D4FFF718B8B6D79B469D2C3C /* GuestExportFixture.swift in Sources */, 466BD76F6F334FBAEA392BD5 /* HelperInstallErrorTests.swift in Sources */, - 16C56B60D5654AB3F14B41A9 /* IDTokenClaimsTests.swift in Sources */, 8BBBA54130403D89D35986F7 /* ImageLayerChainTests.swift in Sources */, E0C997A42E6F8D8B4D9C26FF /* ImagesListViewControllerTests.swift in Sources */, 0BCE503CC2BF6C2BEC9DC4EA /* ImagesViewModelTests.swift in Sources */, @@ -1563,13 +1762,14 @@ 0EA623FAB4206D834C87DD75 /* MainWindowControllerTests.swift in Sources */, 7F8FB0C66C04AEF7895A85AD /* NetworkDetailViewControllerTests.swift in Sources */, 2C12FA161C343D99BD2E8AFD /* NetworksListViewControllerTests.swift in Sources */, - F6386D2A578AD21E751CFC57 /* OIDCAuthorizationURLBuilderTests.swift in Sources */, - D6C6DEA33D73FF969786C018 /* OIDCClientConfigurationTests.swift in Sources */, - B6AB384B22968963098E96F3 /* OIDCClientTests.swift in Sources */, DB5CE5663EE824474F0739FF /* OnboardingWindowControllerTests.swift in Sources */, - 98E7A55FC92F874A28D762FD /* PKCETests.swift in Sources */, D4ECBBCAC3D4631041F292D5 /* QuitWindowControllerTests.swift in Sources */, 1CB8AE8C16D0C333B7C0B32F /* ResourceStatsTests.swift in Sources */, + 7B40CC08EA615C5CB027380A /* RunnerHostJobSummaryTests.swift in Sources */, + 1E08F7DA3B549BAE7F5ECBFC /* RunnerJobDetailModelTests.swift in Sources */, + FD81DD803F100451034B3384 /* RunnerJobListItemTests.swift in Sources */, + A45901889EB62F97532D443E /* RunnerPlatformStoreTests.swift in Sources */, + 977BE039C964F896AB613634 /* RunnersViewModelTests.swift in Sources */, 927458F82097ECF1A07412FD /* StatePlaceholderTestSupport.swift in Sources */, 17ED6FFFD9A61998178E38B9 /* VolumesListViewControllerTests.swift in Sources */, ); @@ -1899,6 +2099,10 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ + 2E3DD7E31B54E2BB5117816E /* XCLocalSwiftPackageReference "Packages/FleetPlatformClient" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/FleetPlatformClient; + }; 3D19705D19A4ADF65F4A7A45 /* XCLocalSwiftPackageReference "Packages/K8sClient" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/K8sClient; @@ -1907,6 +2111,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/ArcBoxClient; }; + 5943466D13147838B8BE6D69 /* XCLocalSwiftPackageReference "Packages/FleetControlClient" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/FleetControlClient; + }; 5FB2872C43BFD0CA00D1FF5E /* XCLocalSwiftPackageReference "Packages/DockerClient" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/DockerClient; @@ -1953,6 +2161,14 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0631A4C3410E8D6E9CFDDA2E /* FleetControlClient */ = { + isa = XCSwiftPackageProductDependency; + productName = FleetControlClient; + }; + 1143C225F6C8A5BF7366588B /* FleetPlatformClient */ = { + isa = XCSwiftPackageProductDependency; + productName = FleetPlatformClient; + }; 1EEA05F99067212B54970FA5 /* ArcBoxAuth */ = { isa = XCSwiftPackageProductDependency; productName = ArcBoxAuth; diff --git a/ArcBox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ArcBox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bcfde852..dae2f67e 100644 --- a/ArcBox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ArcBox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f0f7b55edd95ed87303e2996a1304bd840405310e11144ee1f67d0f6907c558d", + "originHash" : "90eed21bfd9161d96ecd39ddff52ada87b95c3c4dab1323188cc9916827861d0", "pins" : [ { "identity" : "async-http-client", diff --git a/ArcBox/App/ApplicationCoordinator.swift b/ArcBox/App/ApplicationCoordinator.swift index fe15a439..873aae59 100644 --- a/ArcBox/App/ApplicationCoordinator.swift +++ b/ArcBox/App/ApplicationCoordinator.swift @@ -2,6 +2,7 @@ import AppKit import ArcBoxAuth import ArcBoxClient import DockerClient +import FleetPlatformClient import Foundation import OSLog import Observation @@ -18,15 +19,19 @@ final class ApplicationCoordinator: NSObject { let networksVM = NetworksViewModel() let volumesVM = VolumesViewModel() let systemVmBackendVM = SystemVmBackendModel() + // App-scoped so the Fleet Watch survives closing the main window. + let runnersVM = RunnersViewModel() private let eventMonitor = DockerEventMonitor() private let sandboxEventMonitor = SandboxEventMonitor() private let machineEventMonitor = MachineEventMonitor() private let sleepWakeManager = SleepWakeManager() private let deepLinkRouter = DeepLinkRouter() + private let fleetAgentConnection = FleetAgentConnection() private let updaterDelegate = UpdaterDelegate() private let updaterController: SPUStandardUpdaterController private let updaterSettings: UpdaterSettingsModel + private var fleetPlatformClient: FleetPlatformClient? private(set) var arcboxClient: ArcBoxClient? private(set) var dockerClient: DockerClient? @@ -99,8 +104,30 @@ final class ApplicationCoordinator: NSObject { } } + fleetAgentConnection.start() Task { [weak self] in - await self?.authSession.loadUserInfo() + guard let self else { return } + await authSession.restoreSession() + initFleetPlatformClientIfNeeded() + runnersVM.start( + controlClient: fleetAgentConnection.controlClient, + platformClient: fleetPlatformClient, + authentication: authSession, + agentReadiness: fleetAgentConnection + ) + await authSession.refreshSession() + } + Task { [weak self] in + guard let self else { return } + do { + _ = try await fleetAgentConnection.ensureReady() + } catch is CancellationError { + Log.fleet.info("Fleet Agent readiness probe cancelled") + } catch { + Log.fleet.info( + "Fleet Agent is not ready: \(error.localizedDescription, privacy: .private)" + ) + } } if !isOnboarding { @@ -197,7 +224,7 @@ final class ApplicationCoordinator: NSObject { guard !isTerminating else { return false } isTerminating = true - WebAuthenticationController.shared.cancelForTermination() + authSession.cancelSignIn() statusItemController?.closePopover() statusItemController?.setVisible(false) let screen = NSApp.keyWindow?.screen ?? NSApp.mainWindow?.screen ?? NSScreen.main @@ -224,6 +251,19 @@ final class ApplicationCoordinator: NSObject { } func shutdown() async { + let enrollmentSettled = await runnersVM.prepareForTermination() + if !enrollmentSettled { + Log.fleet.warning( + "Fleet enrollment did not settle before the application termination deadline" + ) + } + let connectionClosedGracefully = await fleetAgentConnection.shutdown() + if !connectionClosedGracefully { + Log.fleet.warning( + "Fleet client transport required forced shutdown during application termination" + ) + } + startupTask?.cancel() await startupOrchestrator?.cancelForTermination() await startupTask?.value @@ -263,11 +303,7 @@ final class ApplicationCoordinator: NSObject { imagesVM: imagesVM, networksVM: networksVM, openMainWindow: { [weak self] in self?.showMainWindow() }, - openSettingsWindow: { [weak self] in self?.showSettings() }, - oauthCallbackScheme: OIDCClientConfiguration.redirectURI.scheme, - onOAuthCallback: { [weak self] url in - Task { await self?.authSession.handleAuthorizationCallback(url) } - } + openSettingsWindow: { [weak self] in self?.showSettings() } )) } @@ -449,8 +485,11 @@ final class ApplicationCoordinator: NSObject { .environment(volumesVM) .environment(sandboxEventMonitor) .environment(authSession) + .environment(runnersVM) .environment(\.arcboxClient, arcboxClient) .environment(\.dockerClient, dockerClient) + .environment(\.fleetControlClient, fleetAgentConnection.controlClient) + .environment(\.fleetPlatformClient, fleetPlatformClient) .environment(\.startupOrchestrator, startupOrchestrator) .environment(\.accessTokenProvider, authSession) .frame(minWidth: 900, minHeight: 600) @@ -467,8 +506,11 @@ final class ApplicationCoordinator: NSObject { .environment(authSession) .environment(systemVmBackendVM) .environment(updaterSettings) + .environment(runnersVM.fleet) .environment(\.arcboxClient, arcboxClient) .environment(\.dockerClient, dockerClient) + .environment(\.fleetControlClient, fleetAgentConnection.controlClient) + .environment(\.fleetPlatformClient, fleetPlatformClient) .environment(\.accessTokenProvider, authSession) ) } @@ -496,14 +538,28 @@ final class ApplicationCoordinator: NSObject { showSettings(tab: .account) return } - guard authSession.status != .signingIn, !authSession.configuration.isPlaceholder else { - return - } + guard authSession.status != .restoring, authSession.status != .signingIn, + !authSession.configuration.isPlaceholder + else { return } Task { - await authSession.signIn(using: WebAuthenticationController.shared.authenticate) + await authSession.signIn() } } + /// Create the authenticated Platform REST client without starting network work. + private func initFleetPlatformClientIfNeeded() { + guard fleetPlatformClient == nil else { return } + + let configuration = FleetPlatformConfiguration.current + Log.fleet.info( + "Creating FleetPlatformClient for \(configuration.baseURL.absoluteString, privacy: .public)" + ) + fleetPlatformClient = FleetPlatformClient( + configuration: configuration, + accessTokenProvider: authSession + ) + } + private func activate() { NSApp.unhide(nil) NSApp.activate(ignoringOtherApps: true) diff --git a/ArcBox/App/DeepLinkRouter.swift b/ArcBox/App/DeepLinkRouter.swift index fbeb8575..161c9eca 100644 --- a/ArcBox/App/DeepLinkRouter.swift +++ b/ArcBox/App/DeepLinkRouter.swift @@ -14,11 +14,6 @@ final class DeepLinkRouter { let networksVM: NetworksViewModel let openMainWindow: () -> Void let openSettingsWindow: () -> Void - /// URL scheme of the OAuth redirect (e.g. `com.arcboxlabs.desktop`). - /// Callbacks with this scheme are forwarded to `onOAuthCallback` rather - /// than parsed as `arcbox://` deep links. - let oauthCallbackScheme: String? - let onOAuthCallback: (URL) -> Void } private var target: Target? @@ -40,14 +35,6 @@ final class DeepLinkRouter { } private func dispatch(_ url: URL) { - guard let target else { return } - if let scheme = target.oauthCallbackScheme, - url.scheme?.caseInsensitiveCompare(scheme) == .orderedSame - { - Log.deepLink.info("Handling OAuth redirect callback") - target.onOAuthCallback(url) - return - } guard let link = DeepLink(url) else { Log.deepLink.warning("Ignoring unrecognized deep link: \(url.absoluteString, privacy: .private)") return @@ -81,7 +68,7 @@ final class DeepLinkRouter { case .volumes: target.volumesVM.selectedID = id case .images: target.imagesVM.selectedID = id case .networks: target.networksVM.selectedID = id - case .activity, .pods, .services, .machines, .sandboxes: + case .activity, .pods, .services, .machines, .sandboxes, .runner: Log.deepLink.info("Item selection unsupported for \(item.rawValue, privacy: .public)") } } diff --git a/ArcBox/App/EnvironmentValues+Clients.swift b/ArcBox/App/EnvironmentValues+Clients.swift index 0133f6a4..7942a48d 100644 --- a/ArcBox/App/EnvironmentValues+Clients.swift +++ b/ArcBox/App/EnvironmentValues+Clients.swift @@ -1,6 +1,8 @@ import ArcBoxAuth import ArcBoxClient import DockerClient +import FleetControlClient +import FleetPlatformClient import SwiftUI private struct ArcBoxClientKey: EnvironmentKey { @@ -15,6 +17,14 @@ private struct DockerClientKey: EnvironmentKey { static let defaultValue: DockerClient? = nil } +private struct FleetControlClientKey: EnvironmentKey { + static let defaultValue: FleetControlClient? = nil +} + +private struct FleetPlatformClientKey: EnvironmentKey { + static let defaultValue: FleetPlatformClient? = nil +} + private struct StartupOrchestratorKey: EnvironmentKey { static let defaultValue: StartupOrchestrator? = nil } @@ -30,6 +40,16 @@ extension EnvironmentValues { set { self[DockerClientKey.self] = newValue } } + var fleetControlClient: FleetControlClient? { + get { self[FleetControlClientKey.self] } + set { self[FleetControlClientKey.self] = newValue } + } + + var fleetPlatformClient: FleetPlatformClient? { + get { self[FleetPlatformClientKey.self] } + set { self[FleetPlatformClientKey.self] = newValue } + } + var startupOrchestrator: StartupOrchestrator? { get { self[StartupOrchestratorKey.self] } set { self[StartupOrchestratorKey.self] = newValue } diff --git a/ArcBox/Components/EmptyStateView.swift b/ArcBox/Components/EmptyStateView.swift index c1892314..28779c1f 100644 --- a/ArcBox/Components/EmptyStateView.swift +++ b/ArcBox/Components/EmptyStateView.swift @@ -27,10 +27,11 @@ struct EmptyStateView: View { Image(systemName: icon) .font(.system(size: 26)) .foregroundStyle(AppColors.textMuted) + .accessibilityHidden(true) } Text(title) - .font(.system(size: 13)) + .font(.headline) .foregroundStyle(AppColors.textSecondary) content diff --git a/ArcBox/Components/StatusBadge.swift b/ArcBox/Components/StatusBadge.swift index a5f5cb26..2244cf8e 100644 --- a/ArcBox/Components/StatusBadge.swift +++ b/ArcBox/Components/StatusBadge.swift @@ -11,7 +11,7 @@ struct StatusBadge: View { .fill(color) .frame(width: AppMetrics.statusDot, height: AppMetrics.statusDot) Text(label) - .font(.system(size: 13)) + .font(.callout) .foregroundStyle(color) } .accessibilityElement(children: .combine) diff --git a/ArcBox/Info.plist b/ArcBox/Info.plist index 274aa979..145d1a58 100644 --- a/ArcBox/Info.plist +++ b/ArcBox/Info.plist @@ -33,6 +33,8 @@ $(OIDC_CLIENT_ID) OIDCIssuerURL $(OIDC_ISSUER_URL) + FleetPlatformBaseURL + $(FLEET_PLATFORM_BASE_URL) ArcBoxProfile $(ARCBOX_PROFILE) PostHogAPIKey diff --git a/ArcBox/Logging.swift b/ArcBox/Logging.swift index 52cb6e80..e4a2749f 100644 --- a/ArcBox/Logging.swift +++ b/ArcBox/Logging.swift @@ -12,6 +12,7 @@ nonisolated enum Log { static let network = Logger(subsystem: subsystem, category: "network") static let sandbox = Logger(subsystem: subsystem, category: "sandbox") static let machine = Logger(subsystem: subsystem, category: "machine") + static let fleet = Logger(subsystem: subsystem, category: "fleet") static let pods = Logger(subsystem: subsystem, category: "pods") static let services = Logger(subsystem: subsystem, category: "services") static let context = Logger(subsystem: subsystem, category: "context") diff --git a/ArcBox/Models/FleetRunnerImageReadiness.swift b/ArcBox/Models/FleetRunnerImageReadiness.swift new file mode 100644 index 00000000..ebd022f7 --- /dev/null +++ b/ArcBox/Models/FleetRunnerImageReadiness.swift @@ -0,0 +1,9 @@ +/// macOS runner image readiness presented on the runner page. +enum FleetRunnerImageReadiness: Equatable { + case hidden + case pending(reference: String) + case preparing(FleetImagePreparationProgress) + case restartRequired + case completed(reference: String) + case failed(String) +} diff --git a/ArcBox/Models/NavItem.swift b/ArcBox/Models/NavItem.swift index 1a879bad..0b9552c6 100644 --- a/ArcBox/Models/NavItem.swift +++ b/ArcBox/Models/NavItem.swift @@ -9,6 +9,7 @@ enum NavItem: String, CaseIterable, Identifiable { case services case machines case sandboxes + case runner var id: String { rawValue } @@ -23,6 +24,7 @@ enum NavItem: String, CaseIterable, Identifiable { case .services: "Services" case .machines: "Machines" case .sandboxes: "Sandboxes" + case .runner: "This Mac" } } @@ -37,6 +39,7 @@ enum NavItem: String, CaseIterable, Identifiable { case .services: "gearshape.2" case .machines: "desktopcomputer" case .sandboxes: "server.rack" + case .runner: "hammer" } } @@ -47,6 +50,7 @@ enum NavItem: String, CaseIterable, Identifiable { case kubernetes = "KUBERNETES" case linux = "LINUX" case sandbox = "SANDBOX" + case runners = "RUNNERS" var id: String { rawValue } @@ -57,6 +61,7 @@ enum NavItem: String, CaseIterable, Identifiable { case .kubernetes: [.pods, .services] case .linux: [.machines] case .sandbox: [.sandboxes] + case .runners: [.runner] } } } diff --git a/ArcBox/Models/RunnerHostCapability.swift b/ArcBox/Models/RunnerHostCapability.swift new file mode 100644 index 00000000..c2cf78eb --- /dev/null +++ b/ArcBox/Models/RunnerHostCapability.swift @@ -0,0 +1,18 @@ +import Darwin +import Foundation + +/// Local hardware facts available before this Mac enrolls in a Fleet. +enum RunnerHostCapability { + static let chipName: String = { + var size = 0 + guard sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) == 0, size > 0 else { + return "Apple Silicon" + } + var brand = [CChar](repeating: 0, count: size) + guard sysctlbyname("machdep.cpu.brand_string", &brand, &size, nil, 0) == 0 else { + return "Apple Silicon" + } + let bytes = brand.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) } + return String(bytes: bytes, encoding: .utf8) ?? "Apple Silicon" + }() +} diff --git a/ArcBox/Models/RunnerHostDetailTab.swift b/ArcBox/Models/RunnerHostDetailTab.swift new file mode 100644 index 00000000..ffdc6d0d --- /dev/null +++ b/ArcBox/Models/RunnerHostDetailTab.swift @@ -0,0 +1,8 @@ +enum RunnerHostDetailTab: String, CaseIterable, Identifiable { + case overview = "Overview" + case capacity = "Capacity" + case settings = "Settings" + case identity = "Identity" + + var id: Self { self } +} diff --git a/ArcBox/Models/RunnerHostJobSummary.swift b/ArcBox/Models/RunnerHostJobSummary.swift new file mode 100644 index 00000000..4a534788 --- /dev/null +++ b/ArcBox/Models/RunnerHostJobSummary.swift @@ -0,0 +1,35 @@ +import FleetPlatformClient +import Foundation + +nonisolated struct RunnerHostJobSummary: Equatable { + let recordedCount: Int + let todayCount: Int + let completedCount: Int + let unsuccessfulCount: Int + let hasMoreHistory: Bool + + init( + jobs: [FleetRunnerJob], + hasMoreHistory: Bool, + now: Date = .now, + calendar: Calendar = .current + ) { + recordedCount = jobs.count + todayCount = jobs.count { calendar.isDate($0.createdAt, inSameDayAs: now) } + completedCount = jobs.count { $0.status == .completed } + unsuccessfulCount = jobs.count { + $0.status == .failed || $0.status == .canceled + } + self.hasMoreHistory = hasMoreHistory + } + + var recordedCountDescription: String { + hasMoreHistory ? "\(recordedCount)+" : "\(recordedCount)" + } + + var successRate: Double? { + let finishedCount = completedCount + unsuccessfulCount + guard finishedCount > 0 else { return nil } + return Double(completedCount) / Double(finishedCount) + } +} diff --git a/ArcBox/Models/RunnerHostStatus.swift b/ArcBox/Models/RunnerHostStatus.swift new file mode 100644 index 00000000..5d209df7 --- /dev/null +++ b/ArcBox/Models/RunnerHostStatus.swift @@ -0,0 +1,65 @@ +import FleetControlClient +import SwiftUI + +/// User-facing state derived from the local Fleet Agent watch snapshot. +enum RunnerHostStatus: Equatable { + case attaching + case online + case draining + case updating + case detached + case credentialRejected + case unknown + + init(snapshot: FleetAgentSnapshot) { + switch snapshot.enrollment { + case .attaching: + self = .attaching + case .attached: + self = snapshot.isDraining ? .draining : .online + case .updating: + self = .updating + case .detached: + self = .detached + case .credentialRejected: + self = .credentialRejected + case .unspecified, .unenrolled, .unrecognized: + self = .unknown + } + } + + var label: String { + switch self { + case .attaching: "Attaching" + case .online: "Online" + case .draining: "Draining" + case .updating: "Updating" + case .detached: "Detached" + case .credentialRejected: "Credential Rejected" + case .unknown: "Unknown" + } + } + + var color: Color { + switch self { + case .online: AppColors.running + case .attaching, .draining, .updating: AppColors.warning + case .detached, .credentialRejected, .unknown: AppColors.stopped + } + } + + var canChangeDrainState: Bool { + self == .online || self == .draining + } + + var recoveryMessage: String? { + switch self { + case .credentialRejected: + "The Fleet gateway rejected this Agent credential. Unenroll this Mac from Actions before enrolling again." + case .detached: + "This Mac is detached from Fleet. Unenroll this Mac from Actions before enrolling again." + case .attaching, .online, .draining, .updating, .unknown: + nil + } + } +} diff --git a/ArcBox/Models/RunnerHostViewModel.swift b/ArcBox/Models/RunnerHostViewModel.swift new file mode 100644 index 00000000..53c96e92 --- /dev/null +++ b/ArcBox/Models/RunnerHostViewModel.swift @@ -0,0 +1,55 @@ +import FleetControlClient +import Foundation + +enum RunnerPoolOS: Equatable { + case macOS + case linux + + func matches(_ value: String) -> Bool { + switch (self, value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) { + case (.macOS, "darwin"), (.macOS, "macos"), (.linux, "linux"): + true + default: + false + } + } +} + +/// Presentation model for this Mac, derived entirely from Fleet Agent state. +struct RunnerHostViewModel: Identifiable, Equatable { + let machineID: String? + let status: RunnerHostStatus + let capabilities: [FleetCapability] + let inFlightJobs: [FleetInFlightJob] + let telemetry: FleetHostTelemetry? + let agentVersion: String? + let chip: String + let isDraining: Bool + + init(snapshot: FleetAgentSnapshot, agentInfo: FleetAgentInfo?) { + machineID = snapshot.machineID + status = RunnerHostStatus(snapshot: snapshot) + capabilities = snapshot.capabilities + inFlightJobs = snapshot.inFlightJobs + telemetry = snapshot.telemetry + agentVersion = agentInfo?.agentVersion + chip = RunnerHostCapability.chipName + isDraining = snapshot.isDraining + } + + var id: String { + machineID ?? "local-fleet-agent" + } + + var activeJobCount: Int { + inFlightJobs.count + } + + func capabilities(for pool: RunnerPoolOS) -> [FleetCapability] { + capabilities.filter { pool.matches($0.os) } + } + + func activeJobCount(for pool: RunnerPoolOS) -> Int { + inFlightJobs.count { pool.matches($0.os) } + } +} diff --git a/ArcBox/Models/RunnerJobDetailModel.swift b/ArcBox/Models/RunnerJobDetailModel.swift new file mode 100644 index 00000000..9679b00d --- /dev/null +++ b/ArcBox/Models/RunnerJobDetailModel.swift @@ -0,0 +1,111 @@ +import FleetControlClient +import FleetPlatformClient +import Foundation + +nonisolated struct RunnerJobDetailModel: Equatable { + let id: String + let repository: String? + let status: FleetRunnerJobStatus + let os: String + let arch: String + let githubRunID: Int64? + let githubJobID: Int64? + let labels: [String] + let machineID: String? + let jitRunnerName: String? + let createdAt: Date? + let startedAt: Date? + let finishedAt: Date? + + static func resolve( + id: String, + platformJobs: [FleetRunnerJob], + liveJobs: [FleetInFlightJob] + ) -> RunnerJobDetailModel? { + let platformJob = platformJobs.first { $0.id == id } + let liveJob = liveJobs.first { $0.id == id } + + if let platformJob { + return RunnerJobDetailModel( + id: platformJob.id, + repository: platformJob.repo, + status: liveJob == nil ? platformJob.status : .running, + os: platformJob.os.rawValue, + arch: platformJob.arch.rawValue, + githubRunID: platformJob.githubRunID, + githubJobID: platformJob.githubJobID, + labels: platformJob.labels, + machineID: platformJob.machineID, + jitRunnerName: platformJob.jitRunnerName, + createdAt: platformJob.createdAt, + startedAt: platformJob.startedAt, + finishedAt: platformJob.finishedAt + ) + } + + guard let liveJob else { return nil } + return RunnerJobDetailModel( + id: liveJob.id, + repository: nil, + status: .running, + os: liveJob.os, + arch: liveJob.arch, + githubRunID: nil, + githubJobID: nil, + labels: [], + machineID: nil, + jitRunnerName: nil, + createdAt: nil, + startedAt: nil, + finishedAt: nil + ) + } + + var githubURL: URL? { + guard + let repository, + let githubRunID, + let githubJobID + else { + return nil + } + + let repositoryComponents = repository.split(separator: "/", omittingEmptySubsequences: false) + guard + repositoryComponents.count == 2, + repositoryComponents.allSatisfy({ !$0.isEmpty }) + else { + return nil + } + + var components = URLComponents() + components.scheme = "https" + components.host = "github.com" + components.path = + "/\(repositoryComponents[0])/\(repositoryComponents[1])" + + "/actions/runs/\(githubRunID)/job/\(githubJobID)" + return components.url + } + + var runtimeKind: String { + switch os { + case "darwin", "macos": + "Virtual machine" + case "linux": + "Docker container" + default: + "Runtime" + } + } + + var runtimeUnavailableDescription: String { + switch os { + case "darwin", "macos": + "The current job contract does not report the VZ virtual machine ID, so ArcBox cannot open this runtime in Machines." + case "linux": + "The current job contract does not report the Docker container ID, so ArcBox cannot open this runtime in Containers." + default: + "The current job contract does not report a runtime resource ID." + } + } +} diff --git a/ArcBox/Models/RunnerJobDetailTab.swift b/ArcBox/Models/RunnerJobDetailTab.swift new file mode 100644 index 00000000..7fc7d781 --- /dev/null +++ b/ArcBox/Models/RunnerJobDetailTab.swift @@ -0,0 +1,7 @@ +enum RunnerJobDetailTab: String, CaseIterable, Identifiable { + case logs = "Logs" + case info = "Info" + case runtime = "Runtime" + + var id: Self { self } +} diff --git a/ArcBox/Models/RunnerJobListItem.swift b/ArcBox/Models/RunnerJobListItem.swift new file mode 100644 index 00000000..0062fcba --- /dev/null +++ b/ArcBox/Models/RunnerJobListItem.swift @@ -0,0 +1,42 @@ +import FleetControlClient +import FleetPlatformClient + +nonisolated struct RunnerJobListItem: Identifiable, Equatable { + let id: String + let repository: String? + let os: String + let arch: String + let status: FleetRunnerJobStatus + + static func merge( + platformJobs: [FleetRunnerJob], + liveJobs: [FleetInFlightJob] + ) -> [RunnerJobListItem] { + let liveIDs = Set(liveJobs.map(\.id)) + let platformIDs = Set(platformJobs.map(\.id)) + + let liveOnly = + liveJobs + .filter { !platformIDs.contains($0.id) } + .map { + RunnerJobListItem( + id: $0.id, + repository: nil, + os: $0.os, + arch: $0.arch, + status: .running + ) + } + let platformItems = platformJobs.map { + RunnerJobListItem( + id: $0.id, + repository: $0.repo, + os: $0.os.rawValue, + arch: $0.arch.rawValue, + status: liveIDs.contains($0.id) ? .running : $0.status + ) + } + + return liveOnly + platformItems + } +} diff --git a/ArcBox/Models/RunnerSelection.swift b/ArcBox/Models/RunnerSelection.swift new file mode 100644 index 00000000..1915a045 --- /dev/null +++ b/ArcBox/Models/RunnerSelection.swift @@ -0,0 +1,4 @@ +enum RunnerSelection: Equatable { + case host + case job(String) +} diff --git a/ArcBox/Models/RunnersViewState.swift b/ArcBox/Models/RunnersViewState.swift new file mode 100644 index 00000000..57c6b934 --- /dev/null +++ b/ArcBox/Models/RunnersViewState.swift @@ -0,0 +1,66 @@ +/// Explicit UI state for the Fleet Agent connection and enrollment lifecycle. +enum RunnersViewState: Equatable { + case connecting + case unavailable(String) + case signedOut + case unenrolled + case enrolling(RunnerEnrollmentProgress) + case enrollmentFailed(String, recovery: RunnerEnrollmentRecovery) + case failed(String) + case enrolled(RunnerHostViewModel, freshness: RunnerHostFreshness) +} + +enum RunnerEnrollmentRecovery: Equatable { + case retry + case waitForAgent + case unenroll +} + +enum RunnerHostFreshness: Equatable { + case live + case reconnecting(String) +} + +/// User-facing progress derived from the enrollment coordinator. +enum RunnerEnrollmentProgress: Equatable { + case checkingAgent + case requestingToken + case enrollingAgent + case reconciling + case attaching + case synchronizing + + var title: String { + switch self { + case .checkingAgent: + "Checking Fleet Agent" + case .requestingToken: + "Requesting enrollment token" + case .enrollingAgent: + "Enrolling this Mac" + case .reconciling: + "Confirming enrollment" + case .attaching: + "Connecting to Fleet" + case .synchronizing: + "Finishing setup" + } + } + + var message: String { + switch self { + case .checkingAgent: + "Waiting for the local Fleet Agent endpoint." + case .requestingToken: + "ArcBox is requesting a short-lived token for the selected workspace." + case .enrollingAgent: + "ArcBox is handing the short-lived token to the local Fleet Agent." + case .reconciling: + "Waiting for the Fleet Agent to report the enrollment result." + case .attaching: + "The Fleet Agent is attaching this Mac to the workspace." + case .synchronizing: + "Enrollment succeeded. Waiting for the live Agent state." + } + } +} diff --git a/ArcBox/Models/SettingsTab.swift b/ArcBox/Models/SettingsTab.swift index 910ea531..6bff62f4 100644 --- a/ArcBox/Models/SettingsTab.swift +++ b/ArcBox/Models/SettingsTab.swift @@ -2,6 +2,9 @@ enum SettingsTab: String, CaseIterable, Identifiable { case general = "General" case account = "Account" case system = "System" + case fleet = "Fleet" + // TODO: Implement network settings (ABXD-88) + // case network = "Network" case storage = "Storage" var id: String { rawValue } @@ -11,6 +14,8 @@ enum SettingsTab: String, CaseIterable, Identifiable { case .general: "gearshape" case .account: "person.circle" case .system: "square.grid.2x2" + case .fleet: "server.rack" + // case .network: "globe" case .storage: "externaldrive" } } diff --git a/ArcBox/Services/FleetAgentConnection.swift b/ArcBox/Services/FleetAgentConnection.swift new file mode 100644 index 00000000..8927ad5f --- /dev/null +++ b/ArcBox/Services/FleetAgentConnection.swift @@ -0,0 +1,222 @@ +import FleetControlClient +import Foundation +import Observation +import os + +enum FleetAgentConnectionState: Equatable { + case idle + case connecting + case ready(FleetAgentInfo) + case unavailable(String) +} + +enum FleetAgentConnectionError: LocalizedError { + case clientUnavailable + case handshakeFailed(String) + + var errorDescription: String? { + switch self { + case .clientUnavailable: + "Fleet Agent control client is unavailable." + case .handshakeFailed(let message): + message + } + } +} + +/// Owns the app-wide client transport to the independently managed Fleet Agent. +/// +/// This type never installs, launches, stops, or updates the Agent process. It +/// only maintains the local gRPC channel and proves readiness with GetAgentInfo. +@MainActor +@Observable +final class FleetAgentConnection { + private struct ReadinessProbe { + let id: UUID + let generation: UInt + let task: Task + } + + private(set) var state: FleetAgentConnectionState = .idle + + @ObservationIgnored + private(set) var controlClient: FleetControlClient? + + @ObservationIgnored + private var connectionTask: Task? + + @ObservationIgnored + private var connectionTaskID: UUID? + + @ObservationIgnored + private var connectionTaskFinished = false + + @ObservationIgnored + private var readinessProbe: ReadinessProbe? + + @ObservationIgnored + private var generation: UInt = 0 + + @ObservationIgnored + private var isShuttingDown = false + + @ObservationIgnored + private let socketPath: String + + init(socketPath: String = FleetControlClient.defaultSocketPath) { + self.socketPath = socketPath + } + + /// Starts the single app-wide client transport. This method is idempotent. + func start() { + guard controlClient == nil, !isShuttingDown else { return } + + generation &+= 1 + let currentGeneration = generation + state = .connecting + do { + let client = try FleetControlClient(socketPath: socketPath) + let taskID = UUID() + controlClient = client + connectionTaskID = taskID + connectionTaskFinished = false + connectionTask = Task { [weak self] in + defer { + self?.markConnectionTaskFinished( + id: taskID, + generation: currentGeneration, + client: client + ) + } + do { + Log.fleet.info("Fleet control runConnections starting") + try await client.runConnections() + Log.fleet.info("Fleet control runConnections ended") + } catch is CancellationError { + Log.fleet.info("Fleet control runConnections cancelled") + } catch { + Log.fleet.error( + "Fleet control runConnections failed: \(error.localizedDescription, privacy: .private)" + ) + } + } + } catch { + state = .unavailable(FleetControlClient.userMessage(for: error)) + } + } + + /// Returns the shared client after a bounded, single-flight readiness probe. + func ensureReady() async throws -> any FleetAgentEnrollmentControlling { + start() + guard !isShuttingDown, let controlClient else { + throw FleetAgentConnectionError.clientUnavailable + } + let currentGeneration = generation + + let probe: ReadinessProbe + if let readinessProbe, readinessProbe.generation == currentGeneration { + probe = readinessProbe + } else { + state = .connecting + let newProbe = ReadinessProbe( + id: UUID(), + generation: currentGeneration, + task: Task { + try await controlClient.getAgentInfo( + timeout: .seconds(10), + waitForReady: true + ) + } + ) + readinessProbe = newProbe + probe = newProbe + } + + do { + let info = try await probe.task.value + try Task.checkCancellation() + guard generation == currentGeneration, self.controlClient === controlClient else { + throw FleetAgentConnectionError.clientUnavailable + } + if clearReadinessProbe(id: probe.id, generation: currentGeneration) { + state = .ready(info) + } + return controlClient + } catch is CancellationError { + throw CancellationError() + } catch { + guard generation == currentGeneration, self.controlClient === controlClient else { + throw FleetAgentConnectionError.clientUnavailable + } + let message = FleetControlClient.userMessage(for: error) + if clearReadinessProbe(id: probe.id, generation: currentGeneration) { + state = .unavailable(message) + } + throw FleetAgentConnectionError.handshakeFailed(message) + } + } + + /// Gracefully closes only Desktop's gRPC transport. + /// + /// The Agent service keeps running. If in-flight calls do not settle within + /// the grace period, the transport task is cancelled as a bounded fallback. + @discardableResult + func shutdown(gracePeriod: Duration = .seconds(5)) async -> Bool { + isShuttingDown = true + generation &+= 1 + readinessProbe?.task.cancel() + readinessProbe = nil + + let client = controlClient + let task = connectionTask + let taskID = connectionTaskID + controlClient = nil + client?.close() + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: gracePeriod) + while taskID == connectionTaskID, !connectionTaskFinished, clock.now < deadline { + do { + try await Task.sleep(for: .milliseconds(50)) + } catch { + break + } + } + + let finishedGracefully = taskID == nil || connectionTaskFinished + if !finishedGracefully { + task?.cancel() + } + + connectionTask = nil + connectionTaskID = nil + connectionTaskFinished = false + state = .idle + isShuttingDown = false + return finishedGracefully + } + + private func clearReadinessProbe(id: UUID, generation: UInt) -> Bool { + guard readinessProbe?.id == id, readinessProbe?.generation == generation else { + return false + } + readinessProbe = nil + return true + } + + private func markConnectionTaskFinished( + id: UUID, + generation: UInt, + client: FleetControlClient + ) { + guard connectionTaskID == id else { return } + connectionTaskFinished = true + + guard self.generation == generation, controlClient === client, !isShuttingDown else { + return + } + state = .unavailable("Fleet Agent connection stopped.") + } +} + +extension FleetAgentConnection: FleetAgentReadying {} diff --git a/ArcBox/Services/WebAuthenticationController.swift b/ArcBox/Services/WebAuthenticationController.swift deleted file mode 100644 index 13d7bce7..00000000 --- a/ArcBox/Services/WebAuthenticationController.swift +++ /dev/null @@ -1,115 +0,0 @@ -import AppKit -import AuthenticationServices -import Foundation - -@MainActor -final class WebAuthenticationController: NSObject, - ASWebAuthenticationPresentationContextProviding -{ - static let shared = WebAuthenticationController() - - private var session: ASWebAuthenticationSession? - private var presentationAnchor: ASPresentationAnchor? - private var continuation: CheckedContinuation? - private var isTerminating = false - - func authenticate(using url: URL, callbackURLScheme: String) async throws -> URL { - guard !isTerminating else { - throw Self.canceledLoginError - } - guard session == nil else { - throw WebAuthenticationError.sessionAlreadyInProgress - } - guard - let window = - NSApp.keyWindow ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - else { - throw WebAuthenticationError.noPresentationAnchor - } - presentationAnchor = window - let controller: WebAuthenticationController = self - - return try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation - let session = ASWebAuthenticationSession( - url: url, - callback: .customScheme(callbackURLScheme) - ) { callbackURL, error in - Task { @MainActor in - if let error { - let nsError = error as NSError - if nsError.domain == ASWebAuthenticationSessionError.errorDomain { - controller.finish( - with: .failure( - ASWebAuthenticationSessionError(_nsError: nsError))) - } else { - controller.finish(with: .failure(error)) - } - } else if let callbackURL { - controller.finish(with: .success(callbackURL)) - } else { - controller.finish( - with: .failure(WebAuthenticationError.missingCallbackURL)) - } - } - } - session.presentationContextProvider = self - self.session = session - - guard session.start() else { - finish(with: .failure(WebAuthenticationError.failedToStart)) - return - } - } - } - - func cancelForTermination() { - isTerminating = true - session?.cancel() - finish(with: .failure(Self.canceledLoginError)) - } - - func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { - guard let presentationAnchor else { - preconditionFailure("Web authentication started without a presentation anchor") - } - return presentationAnchor - } - - private func finish(with result: Result) { - guard let continuation else { return } - self.continuation = nil - session = nil - presentationAnchor = nil - continuation.resume(with: result) - } - - private static var canceledLoginError: ASWebAuthenticationSessionError { - ASWebAuthenticationSessionError( - _nsError: NSError( - domain: ASWebAuthenticationSessionError.errorDomain, - code: ASWebAuthenticationSessionError.Code.canceledLogin.rawValue - )) - } -} - -private enum WebAuthenticationError: LocalizedError { - case sessionAlreadyInProgress - case noPresentationAnchor - case failedToStart - case missingCallbackURL - - var errorDescription: String? { - switch self { - case .sessionAlreadyInProgress: - "A sign-in session is already in progress." - case .noPresentationAnchor: - "No window is available to present sign-in." - case .failedToStart: - "The sign-in browser could not be started." - case .missingCallbackURL: - "The sign-in browser finished without a callback URL." - } - } -} diff --git a/ArcBox/ViewModels/Fleet/FleetControlServicing.swift b/ArcBox/ViewModels/Fleet/FleetControlServicing.swift new file mode 100644 index 00000000..a1bb6446 --- /dev/null +++ b/ArcBox/ViewModels/Fleet/FleetControlServicing.swift @@ -0,0 +1,25 @@ +import FleetControlClient + +/// The local Agent operations consumed by `FleetViewModel`. +/// +/// Keeping this boundary narrower than the concrete gRPC client makes the +/// long-running watch and image-preparation workflows independently testable. +protocol FleetControlServicing: Sendable { + func fetchAgentInfo() async throws -> FleetAgentInfo + func getStatus() async throws -> FleetAgentStatus + func watchSnapshots() -> AsyncThrowingStream + func drain() async throws + func resume() async throws + func unenroll() async throws + func prepareImages( + _ kinds: [FleetImageKind] + ) -> AsyncThrowingStream + func getSettings() async throws -> FleetAgentSettings + func updateSettings(_ update: FleetSettingsUpdate) async throws -> FleetAgentSettings +} + +extension FleetControlClient: FleetControlServicing { + func fetchAgentInfo() async throws -> FleetAgentInfo { + try await getAgentInfo() + } +} diff --git a/ArcBox/ViewModels/Fleet/FleetEnrollmentCoordinator.swift b/ArcBox/ViewModels/Fleet/FleetEnrollmentCoordinator.swift new file mode 100644 index 00000000..9b76e3d2 --- /dev/null +++ b/ArcBox/ViewModels/Fleet/FleetEnrollmentCoordinator.swift @@ -0,0 +1,740 @@ +import ArcBoxAuth +import FleetControlClient +import FleetPlatformClient +import Foundation +import Observation + +@MainActor +protocol FleetAuthenticationChecking: Sendable { + var isSignedIn: Bool { get } +} + +extension AuthSession: FleetAuthenticationChecking { + var isSignedIn: Bool { + status == .signedIn + } +} + +@MainActor +protocol FleetEnrollmentTokenIssuing: Sendable { + func createEnrollmentToken(workspaceID: String) async throws -> FleetEnrollmentToken +} + +extension FleetPlatformClient: FleetEnrollmentTokenIssuing {} + +@MainActor +protocol FleetAgentEnrollmentControlling: Sendable { + func enroll(token: String, controlPlane: String?) async throws -> String + func watchSnapshots() -> AsyncThrowingStream +} + +extension FleetControlClient: FleetAgentEnrollmentControlling {} + +@MainActor +protocol FleetAgentReadying: Sendable { + func ensureReady() async throws -> any FleetAgentEnrollmentControlling +} + +/// Coordinates Platform-issued and manually supplied Agent enrollment handoffs. +@MainActor +@Observable +final class FleetEnrollmentCoordinator { + enum State: Equatable, Sendable { + case idle + case requiresSignIn + case preparingAgent + case requestingEnrollmentToken + case enrolling + case reconcilingEnrollment + case attaching(machineID: String) + case ready(machineID: String) + case failed(Failure) + + fileprivate var isInProgress: Bool { + switch self { + case .preparingAgent, .requestingEnrollmentToken, .enrolling, + .reconcilingEnrollment, .attaching: + true + case .idle, .requiresSignIn, .ready, .failed: + false + } + } + } + + enum Failure: Error, Equatable, Sendable { + case workspaceRequired + case enrollmentTokenRequired + case agentPreparationFailed(message: String) + case enrollmentTokenRequestFailed(message: String) + case enrollmentOutcomeUnknown + case credentialRejected(machineID: String) + case detached(machineID: String) + case stateStreamEnded(machineID: String) + case stateStreamFailed(machineID: String) + case attachmentTimedOut(machineID: String) + case cancelled(machineID: String?) + } + + private(set) var state: State = .idle + + private struct ActiveEnrollment { + let id: UUID + let task: Task + } + + private enum EnrollmentSource: Sendable { + case workspace(String) + case token(String) + } + + private enum SnapshotBaselineError: LocalizedError { + case streamEnded + case timedOut + + var errorDescription: String? { + switch self { + case .streamEnded: + "Fleet Agent stopped reporting state before its initial snapshot." + case .timedOut: + "Fleet Agent did not report its initial state in time." + } + } + } + + @ObservationIgnored + private var activeEnrollment: ActiveEnrollment? + + @ObservationIgnored + private var canCancelActiveEnrollment = false + + @ObservationIgnored + private var activeEnrollmentSettled = false + + @ObservationIgnored + private var pendingReconciliation: FleetAgentSnapshot? + + private var enrollmentLocked = false + private var hasUnresolvedOutcome = false + private var awaitingUnenrolledSnapshot = false + + var isBusy: Bool { + state.isInProgress + } + + var canBeginEnrollment: Bool { + activeEnrollment == nil && !enrollmentLocked && !hasUnresolvedOutcome + && !awaitingUnenrolledSnapshot + } + + var isSignedIn: Bool { + authentication.isSignedIn + } + + var errorMessage: String? { + switch state { + case .requiresSignIn: + "Sign in to ArcBox before connecting this Mac." + case .failed(let failure): + failure.localizedDescription + case .idle, .preparingAgent, .requestingEnrollmentToken, .enrolling, + .reconcilingEnrollment, .attaching, .ready: + nil + } + } + + @ObservationIgnored + private let authentication: any FleetAuthenticationChecking + + @ObservationIgnored + private let agentReadiness: any FleetAgentReadying + + @ObservationIgnored + private let tokenIssuer: (any FleetEnrollmentTokenIssuing)? + + @ObservationIgnored + private let attachmentTimeout: Duration + + @ObservationIgnored + private let snapshotBaselineTimeout: Duration + + @ObservationIgnored + private let sleeper: @Sendable (Duration) async throws -> Void + + init( + authentication: any FleetAuthenticationChecking, + agentReadiness: any FleetAgentReadying, + tokenIssuer: (any FleetEnrollmentTokenIssuing)?, + snapshotBaselineTimeout: Duration = .seconds(5), + attachmentTimeout: Duration = .seconds(30), + sleeper: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + } + ) { + self.authentication = authentication + self.agentReadiness = agentReadiness + self.tokenIssuer = tokenIssuer + self.snapshotBaselineTimeout = snapshotBaselineTimeout + self.attachmentTimeout = attachmentTimeout + self.sleeper = sleeper + } + + /// Publishes the signed-out state before any Platform request is made. + func requireSignedIn() -> Bool { + guard authentication.isSignedIn else { + state = .requiresSignIn + return false + } + if state == .requiresSignIn { + state = .idle + } + return true + } + + /// Enroll this Mac into a selected workspace and wait until its Agent is attached. + @discardableResult + func enroll(workspaceID: String, controlPlane: String? = nil) async -> Bool { + await enroll(source: .workspace(workspaceID), controlPlane: controlPlane) + } + + /// Enroll this Mac with a user-supplied Fleet enrollment token. + @discardableResult + func enroll(token: String, controlPlane: String? = nil) async -> Bool { + await enroll(source: .token(token), controlPlane: controlPlane) + } + + private func enroll(source: EnrollmentSource, controlPlane: String?) async -> Bool { + guard canBeginEnrollment else { return false } + + let id = UUID() + let task = Task { [weak self] in + guard let self else { return false } + let result = await self.performEnrollment( + source: source, + controlPlane: controlPlane + ) + self.markEnrollmentSettled(id: id) + return result + } + activeEnrollment = ActiveEnrollment(id: id, task: task) + canCancelActiveEnrollment = true + activeEnrollmentSettled = false + pendingReconciliation = nil + + // The app-scoped coordinator owns this operation. Caller cancellation + // (for example, closing a window) must not cancel a post-handoff RPC. + let succeeded = await task.value + + var reconciledResult: Bool? + if activeEnrollment?.id == id { + activeEnrollment = nil + canCancelActiveEnrollment = false + reconciledResult = applyPendingReconciliation() + } + return reconciledResult ?? succeeded + } + + /// Cancels the app-owned enrollment operation without stopping the Agent. + func cancel() { + guard canCancelActiveEnrollment else { return } + activeEnrollment?.task.cancel() + } + + /// Reconciles app-wide Agent snapshots after an enrollment attempt ends. + /// + /// An empty machine ID never proves a post-handoff failure or explicit + /// unenrollment. A non-empty ID is positive evidence that the Agent + /// persisted its credential. + func reconcile(_ snapshot: FleetAgentSnapshot) { + if awaitingUnenrolledSnapshot { + guard snapshot.enrollment == .unenrolled, + Self.normalizedMachineID(snapshot.machineID) == nil + else { return } + + awaitingUnenrolledSnapshot = false + pendingReconciliation = nil + state = .idle + return + } + + switch snapshot.enrollment { + case .unenrolled: + return + case .unspecified, .unrecognized: + return + case .attaching, .attached, .updating, .credentialRejected, .detached: + break + } + + guard let machineID = Self.normalizedMachineID(snapshot.machineID) else { return } + guard activeEnrollment == nil else { + pendingReconciliation = snapshot + return + } + applyReconciliation(snapshot, machineID: machineID) + } + + /// Releases the retry lock only after an explicit Unenroll RPC succeeds. + func confirmUnenrolled() { + guard activeEnrollment == nil else { return } + pendingReconciliation = nil + hasUnresolvedOutcome = false + enrollmentLocked = false + awaitingUnenrolledSnapshot = true + state = .idle + } + + /// Gives a pre-handoff cancellation or post-handoff reconciliation a + /// bounded chance to settle before Desktop closes its client transport. + func settleForTermination(gracePeriod: Duration) async -> Bool { + cancel() + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: gracePeriod) + while !isSettledForTermination, clock.now < deadline { + do { + try await Task.sleep(for: .milliseconds(50)) + } catch { + break + } + } + return isSettledForTermination + } + + private func performEnrollment(source: EnrollmentSource, controlPlane: String?) async -> Bool { + guard !Task.isCancelled else { + return fail(.cancelled(machineID: nil)) + } + + guard let source = normalizedEnrollmentSource(source) else { return false } + + state = .preparingAgent + let agent: any FleetAgentEnrollmentControlling + do { + agent = try await agentReadiness.ensureReady() + try Task.checkCancellation() + } catch is CancellationError { + return fail(.cancelled(machineID: nil)) + } catch { + return fail(.agentPreparationFailed(message: error.localizedDescription)) + } + + // Watch begins with a full current snapshot. Consume that first value + // before obtaining or handing off a token, then keep the same buffered + // stream for post-handoff reconciliation. + let snapshots = agent.watchSnapshots() + do { + let baseline = try await firstSnapshot(in: snapshots) + try Task.checkCancellation() + guard acceptEnrollmentBaseline(baseline) else { return false } + } catch is CancellationError { + return fail(.cancelled(machineID: nil)) + } catch { + return fail(.agentPreparationFailed(message: error.localizedDescription)) + } + + guard let enrollmentToken = await enrollmentToken(from: source) else { return false } + + // Another local client may have enrolled while this attempt was being + // prepared. Positive Agent evidence must stop this non-idempotent handoff; + // the Agent's enrollment admission gate remains the atomic authority. + if let reconciledResult = applyPendingBeforeHandoff() { + return reconciledResult + } + + state = .enrolling + // Enroll is not idempotent. From this point onward, cancellation and + // transport errors cannot prove whether the Agent persisted a credential. + canCancelActiveEnrollment = false + enrollmentLocked = true + + let machineID: String + do { + let returnedMachineID = try await agent.enroll( + token: enrollmentToken, + controlPlane: Self.normalizedControlPlane(controlPlane) + ) + guard let normalizedMachineID = Self.normalizedMachineID(returnedMachineID) else { + return await reconcileUnknownOutcome(snapshots: snapshots) + } + machineID = normalizedMachineID + } catch is CancellationError { + return await reconcileUnknownOutcome(snapshots: snapshots) + } catch { + return await reconcileUnknownOutcome(snapshots: snapshots) + } + + state = .attaching(machineID: machineID) + do { + let attachedMachineID = try await waitUntilAttached( + snapshots: snapshots, + expectedMachineID: machineID + ) + state = .ready(machineID: attachedMachineID) + return true + } catch is CancellationError { + return fail(.cancelled(machineID: machineID)) + } catch let failure as Failure { + return fail(failure) + } catch { + return fail(.stateStreamFailed(machineID: machineID)) + } + } + + private func normalizedEnrollmentSource(_ source: EnrollmentSource) -> EnrollmentSource? { + switch source { + case .workspace(let workspaceID): + guard requireSignedIn() else { return nil } + let workspaceID = workspaceID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !workspaceID.isEmpty else { + _ = fail(.workspaceRequired) + return nil + } + return .workspace(workspaceID) + case .token(let token): + let token = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + _ = fail(.enrollmentTokenRequired) + return nil + } + return .token(token) + } + } + + private func enrollmentToken(from source: EnrollmentSource) async -> String? { + switch source { + case .token(let token): + return token + case .workspace(let workspaceID): + guard let tokenIssuer else { + _ = fail( + .enrollmentTokenRequestFailed( + message: "Fleet Platform client is unavailable." + )) + return nil + } + + state = .requestingEnrollmentToken + do { + let enrollment = try await tokenIssuer.createEnrollmentToken( + workspaceID: workspaceID + ) + try Task.checkCancellation() + return enrollment.token + } catch is CancellationError { + _ = fail(.cancelled(machineID: nil)) + } catch { + _ = fail( + .enrollmentTokenRequestFailed( + message: FleetPlatformClient.userMessage(for: error) + )) + } + return nil + } + } + + private func reconcileUnknownOutcome( + snapshots: AsyncThrowingStream + ) async -> Bool { + hasUnresolvedOutcome = true + state = .reconcilingEnrollment + + do { + let machineID = try await waitUntilAttached( + snapshots: snapshots, + expectedMachineID: nil + ) + hasUnresolvedOutcome = false + state = .ready(machineID: machineID) + return true + } catch let failure as Failure { + if failure.hasMachineID { + hasUnresolvedOutcome = false + return fail(failure) + } + return fail(.enrollmentOutcomeUnknown) + } catch { + return fail(.enrollmentOutcomeUnknown) + } + } + + private func waitUntilAttached( + snapshots: AsyncThrowingStream, + expectedMachineID: String? + ) async throws -> String { + let observation = EnrollmentObservation(machineID: expectedMachineID) + let timeout = attachmentTimeout + let sleeper = sleeper + + return try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + do { + for try await snapshot in snapshots { + guard + let machineID = Self.normalizedMachineID(snapshot.machineID), + await observation.accept(machineID) + else { continue } + + switch snapshot.enrollment { + case .attached: + return machineID + case .credentialRejected: + throw Failure.credentialRejected(machineID: machineID) + case .detached: + throw Failure.detached(machineID: machineID) + case .unspecified, .unenrolled, .attaching, .updating, .unrecognized: + continue + } + } + if let machineID = await observation.machineID { + throw Failure.stateStreamEnded(machineID: machineID) + } + throw Failure.enrollmentOutcomeUnknown + } catch is CancellationError { + throw CancellationError() + } catch let failure as Failure { + throw failure + } catch { + if let machineID = await observation.machineID { + throw Failure.stateStreamFailed(machineID: machineID) + } + throw Failure.enrollmentOutcomeUnknown + } + } + + group.addTask { + try await sleeper(timeout) + try Task.checkCancellation() + if let machineID = await observation.machineID { + throw Failure.attachmentTimedOut(machineID: machineID) + } + throw Failure.enrollmentOutcomeUnknown + } + + defer { group.cancelAll() } + guard let machineID = try await group.next() else { + throw Failure.enrollmentOutcomeUnknown + } + return machineID + } + } + + private func fail(_ failure: Failure) -> Bool { + state = .failed(failure) + return false + } + + private var isSettledForTermination: Bool { + (activeEnrollment == nil || activeEnrollmentSettled) && !hasUnresolvedOutcome + && pendingReconciliation == nil + } + + private func firstSnapshot( + in snapshots: AsyncThrowingStream + ) async throws -> FleetAgentSnapshot { + let timeout = snapshotBaselineTimeout + + return try await withThrowingTaskGroup(of: FleetAgentSnapshot.self) { group in + group.addTask { + var iterator = snapshots.makeAsyncIterator() + guard let snapshot = try await iterator.next() else { + throw SnapshotBaselineError.streamEnded + } + return snapshot + } + + group.addTask { + try await Task.sleep(for: timeout) + throw SnapshotBaselineError.timedOut + } + + defer { group.cancelAll() } + guard let snapshot = try await group.next() else { + throw SnapshotBaselineError.streamEnded + } + return snapshot + } + } + + private func acceptEnrollmentBaseline(_ snapshot: FleetAgentSnapshot) -> Bool { + let machineID = Self.normalizedMachineID(snapshot.machineID) + switch snapshot.enrollment { + case .unenrolled where machineID == nil: + return true + case .attaching, .updating: + guard let machineID else { return failUnknownOutcome() } + enrollmentLocked = true + state = .attaching(machineID: machineID) + case .attached: + guard let machineID else { return failUnknownOutcome() } + enrollmentLocked = true + state = .ready(machineID: machineID) + case .detached: + guard let machineID else { return failUnknownOutcome() } + enrollmentLocked = true + state = .failed(.detached(machineID: machineID)) + case .credentialRejected: + guard let machineID else { return failUnknownOutcome() } + enrollmentLocked = true + state = .failed(.credentialRejected(machineID: machineID)) + case .unenrolled: + return failUnknownOutcome() + case .unspecified, .unrecognized: + if machineID != nil { + return failUnknownOutcome() + } + return fail( + .agentPreparationFailed( + message: "Fleet Agent returned an invalid enrollment state." + )) + } + return false + } + + private func failUnknownOutcome() -> Bool { + hasUnresolvedOutcome = true + enrollmentLocked = true + return fail(.enrollmentOutcomeUnknown) + } + + private func applyPendingReconciliation() -> Bool? { + guard let snapshot = pendingReconciliation else { return nil } + pendingReconciliation = nil + + // Independent Watch streams have no shared sequence. Preserve a known + // terminal state, and let a queued terminal state conservatively win + // over ready rather than reporting a potentially revoked credential. + switch state { + case .ready where !snapshot.enrollment.isTerminal: + return nil + case .failed(.credentialRejected), .failed(.detached): + return nil + default: + break + } + + guard let machineID = Self.normalizedMachineID(snapshot.machineID) else { return nil } + applyReconciliation(snapshot, machineID: machineID) + if case .ready = state { return true } + return false + } + + private func applyPendingBeforeHandoff() -> Bool? { + guard let snapshot = pendingReconciliation else { return nil } + pendingReconciliation = nil + guard let machineID = Self.normalizedMachineID(snapshot.machineID) else { return nil } + + applyReconciliation(snapshot, machineID: machineID) + if case .ready = state { return true } + return false + } + + private func applyReconciliation( + _ snapshot: FleetAgentSnapshot, + machineID: String + ) { + hasUnresolvedOutcome = false + enrollmentLocked = true + + switch snapshot.enrollment { + case .attaching, .updating: + state = .attaching(machineID: machineID) + case .attached: + state = .ready(machineID: machineID) + case .credentialRejected: + state = .failed(.credentialRejected(machineID: machineID)) + case .detached: + state = .failed(.detached(machineID: machineID)) + case .unenrolled, .unspecified, .unrecognized: + break + } + } + + private func markEnrollmentSettled(id: UUID) { + guard activeEnrollment?.id == id else { return } + activeEnrollmentSettled = true + } + + nonisolated private static func normalizedControlPlane(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { return nil } + return trimmed + } + + nonisolated private static func normalizedMachineID(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { return nil } + return trimmed + } +} + +extension FleetEnrollmentState { + fileprivate var isTerminal: Bool { + switch self { + case .credentialRejected, .detached: + true + case .unenrolled, .attaching, .attached, .updating, .unspecified, .unrecognized: + false + } + } +} + +private actor EnrollmentObservation { + private(set) var machineID: String? + + init(machineID: String?) { + self.machineID = machineID + } + + func accept(_ candidate: String) -> Bool { + if let machineID { + return candidate == machineID + } + machineID = candidate + return true + } +} + +extension FleetEnrollmentCoordinator.Failure { + fileprivate var hasMachineID: Bool { + switch self { + case .credentialRejected, .detached, .stateStreamEnded, .stateStreamFailed, + .attachmentTimedOut, .cancelled(machineID: .some): + true + case .workspaceRequired, .enrollmentTokenRequired, .agentPreparationFailed, + .enrollmentTokenRequestFailed, + .enrollmentOutcomeUnknown, .cancelled(machineID: nil): + false + } + } +} + +extension FleetEnrollmentCoordinator.Failure: LocalizedError { + var errorDescription: String? { + switch self { + case .workspaceRequired: + "An ArcBox workspace is required for enrollment." + case .enrollmentTokenRequired: + "A Fleet enrollment token is required." + case .agentPreparationFailed(let message): + message + case .enrollmentTokenRequestFailed(let message): + message + case .enrollmentOutcomeUnknown: + "The enrollment result is unknown. ArcBox will keep reconciling the Fleet Agent state." + case .credentialRejected: + "The Fleet gateway rejected this Mac's credential." + case .detached: + "This Mac is enrolled, but Fleet participation is disabled." + case .stateStreamEnded: + "The Fleet Agent stopped reporting enrollment state." + case .stateStreamFailed: + "The Fleet Agent enrollment state could not be observed." + case .attachmentTimedOut: + "This Mac enrolled, but did not attach to the Fleet gateway in time." + case .cancelled: + "Fleet enrollment was cancelled." + } + } +} diff --git a/ArcBox/ViewModels/Fleet/FleetViewModel.swift b/ArcBox/ViewModels/Fleet/FleetViewModel.swift new file mode 100644 index 00000000..3c832a34 --- /dev/null +++ b/ArcBox/ViewModels/Fleet/FleetViewModel.swift @@ -0,0 +1,656 @@ +import FleetControlClient +import Foundation +import SwiftUI +import os + +/// Fleet runner page loading state. +enum FleetLoadState: Equatable { + case idle + case connecting + case unavailable(String) + case ready + case failed(String) +} + +/// Whether the connected Agent exposes the complete VM settings contract. +enum FleetVMSettingsAvailability: Equatable { + case loading + case unavailable(String) + case unsupported + case missingSettings + case available +} + +/// Progress for the long-running macOS runner image preparation RPC. +struct FleetImagePreparationProgress: Equatable { + let stage: String + let detail: String + let fraction: Double + + var displayDescription: String { + detail.isEmpty ? stage.capitalized : "\(stage.capitalized): \(detail)" + } +} + +/// Observable state for macOS runner image preparation. +enum FleetImagePreparationState: Equatable { + case idle + case preparing(FleetImagePreparationProgress) + case completed(reference: String) + case failed(String) + + var isPreparing: Bool { + if case .preparing = self { return true } + return false + } +} + +private enum FleetAgentFeature { + static let vmSettings = "vm-settings" + static let macOSImagePrepare = "macos-image-prepare" + static let vmBackend = "vm-backend" +} + +private enum FleetImagePreparationError: LocalizedError { + case targetDidNotConverge(String) + + var errorDescription: String? { + switch self { + case .targetDidNotConverge(let reference): + "Fleet Agent finished preparing \(reference), but did not report it as the current image." + } + } +} + +/// View model for the local fleet-agent control surface. +@MainActor +@Observable +final class FleetViewModel { + var loadState: FleetLoadState = .idle + var agentInfo: FleetAgentInfo? + var status: FleetAgentStatus? + var snapshot: FleetAgentSnapshot? + var settings: FleetAgentSettings? + var lastError: String? + var isWatching = false + var isPerformingAction = false + var reconnectAttempt = 0 + var imagePreparationState: FleetImagePreparationState = .idle + + @ObservationIgnored + private var client: (any FleetControlServicing)? + + @ObservationIgnored + private var watchTask: Task? + + @ObservationIgnored + private var imagePreparationTask: Task? + + @ObservationIgnored + private var snapshotObserver: (@MainActor @Sendable (FleetAgentSnapshot) -> Void)? + + @ObservationIgnored + private var watchGeneration = UUID() + + @ObservationIgnored + private var imagePreparationGeneration = UUID() + + var isReady: Bool { + if case .ready = loadState { return true } + return false + } + + var vmSettingsAvailability: FleetVMSettingsAvailability { + Self.resolveVMSettingsAvailability( + agentInfo: agentInfo, + settings: settings, + loadState: loadState + ) + } + + var supportsMacOSImagePreparation: Bool { + agentInfo?.supportsFeature(FleetAgentFeature.macOSImagePrepare) == true + } + + var isVMBackendActive: Bool { + agentInfo?.supportsFeature(FleetAgentFeature.vmBackend) == true + } + + var canBeginMacOSRunnerImagePreparation: Bool { + vmSettingsAvailability == .available + && supportsMacOSImagePreparation + && !isPerformingAction + && !imagePreparationState.isPreparing + && settings?.macosRunnerImage != nil + } + + var runnerImageReadiness: FleetRunnerImageReadiness { + guard vmSettingsAvailability == .available, + supportsMacOSImagePreparation + else { + return .hidden + } + + switch imagePreparationState { + case .preparing(let progress): + return .preparing(progress) + case .failed(let message): + return .failed(message) + case .completed(let reference): + if requiresAgentRestartForVM { + return .restartRequired + } + return .completed(reference: reference) + case .idle: + if requiresAgentRestartForVM { + return .restartRequired + } + guard let image = settings?.macosRunnerImage, + image.isPending + else { + return .hidden + } + return .pending(reference: image.target) + } + } + + var requiresAgentRestartForVM: Bool { + guard let vmMode = settings?.vmMode, + let image = settings?.macosRunnerImage + else { + return false + } + + if vmMode.isPending { + switch vmMode.target { + case .disabled: + return true + case .auto, .enabled: + guard !image.isPending else { return false } + return isVMBackendActive || image.current == image.target + case .unspecified, .unrecognized: + return false + } + } + + guard vmMode.target != .disabled, + image.current == image.target + else { + return false + } + return !isVMBackendActive + } + + var machineID: String? { + snapshot?.machineID ?? status?.machineID + } + + var isEnrolled: Bool { + if let snapshot { + return snapshot.enrollment != .unenrolled && snapshot.enrollment != .unspecified + } + switch status?.state { + case .enrolled, .draining, .detached, .credentialRejected: + return true + case .unspecified, .unenrolled, .unrecognized, nil: + return false + } + } + + /// Begin the handshake and state watch loop. + func start( + client: (any FleetControlServicing)?, + onSnapshot: (@MainActor @Sendable (FleetAgentSnapshot) -> Void)? = nil + ) { + stop() + agentInfo = nil + self.client = client + snapshotObserver = onSnapshot + + guard let client else { + markUnavailable("Fleet control client is unavailable.") + return + } + + loadState = .connecting + let generation = watchGeneration + watchTask = Task { [weak self, client] in + await self?.run(client: client, generation: generation) + } + } + + /// Stop the live watch loop. Existing snapshot data is retained. + func stop() { + watchGeneration = UUID() + watchTask?.cancel() + watchTask = nil + imagePreparationGeneration = UUID() + imagePreparationTask?.cancel() + imagePreparationTask = nil + if imagePreparationState.isPreparing { + imagePreparationState = .idle + } + isWatching = false + reconnectAttempt = 0 + snapshotObserver = nil + } + + /// Refresh agent handshake metadata without restarting the watch loop. + @discardableResult + func getAgentInfo() async -> Bool { + guard let client = requireClient() else { return false } + + do { + agentInfo = try await client.fetchAgentInfo() + lastError = nil + return true + } catch { + handle(error) + return false + } + } + + /// Refresh coarse lifecycle status. + @discardableResult + func getStatus() async -> Bool { + guard let client = requireClient() else { return false } + + do { + status = try await client.getStatus() + lastError = nil + return true + } catch { + handle(error) + return false + } + } + + /// Refresh current settings. + @discardableResult + func getSettings() async -> Bool { + guard let client = requireClient() else { return false } + + do { + applySettings(try await client.getSettings()) + lastError = nil + return true + } catch { + handle(error) + return false + } + } + + /// Drain this host: stop accepting new offers and finish in-flight work. + @discardableResult + func drain() async -> Bool { + guard let client = requireClient() else { return false } + + return await performAction("drain") { + try await client.drain() + await refreshAfterMutation(client: client) + } + } + + /// Resume accepting new offers after draining. + @discardableResult + func resume() async -> Bool { + guard let client = requireClient() else { return false } + + return await performAction("resume") { + try await client.resume() + await refreshAfterMutation(client: client) + } + } + + /// Remove this Mac's persisted fleet enrollment. + @discardableResult + func unenroll() async -> Bool { + guard let client = requireClient() else { return false } + + return await performAction("unenroll") { + try await client.unenroll() + // Watch publishes the authoritative unenrolled snapshot before + // this RPC returns. Retain it for coordinator reconciliation. + settings = nil + status = FleetAgentStatus(state: .unenrolled, machineID: nil) + await refreshAfterMutation(client: client) + } + } + + /// Apply a partial settings update. + @discardableResult + func updateSettings(_ update: FleetSettingsUpdate) async -> Bool { + let updatesVMSettings = update.macosRunnerImage != nil || update.vmMode != nil + if updatesVMSettings { + guard vmSettingsAvailability == .available else { + lastError = "Fleet VM settings are unavailable." + return false + } + guard !imagePreparationState.isPreparing else { + lastError = "Wait for macOS image preparation to finish before changing VM settings." + return false + } + } + + guard let client = requireClient() else { return false } + guard !update.isEmpty else { + return await getSettings() + } + + return await performAction("update settings") { + applySettings(try await client.updateSettings(update)) + } + } + + /// Apply a partial settings update. + @discardableResult + func updateSettings( + loadCeiling: Double? = nil, + memFloorMib: UInt64? = nil, + linuxRunnerImage: String? = nil, + gateway: String? = nil, + dockerMode: FleetDockerMode? = nil, + runnerScript: String? = nil, + participate: Bool? = nil, + macosRunnerImage: String? = nil, + vmMode: FleetVmMode? = nil + ) async -> Bool { + await updateSettings( + FleetSettingsUpdate( + loadCeiling: loadCeiling, + memFloorMib: memFloorMib, + linuxRunnerImage: linuxRunnerImage, + gateway: gateway, + dockerMode: dockerMode, + runnerScript: runnerScript, + participate: participate, + macosRunnerImage: macosRunnerImage, + vmMode: vmMode + ) + ) + } + + /// Begin preparing the configured macOS runner image through the Agent. + /// The Agent owns daemon communication; Desktop only consumes this local + /// Fleet control stream and never manages either process. + func beginMacOSRunnerImagePreparation() { + guard !isPerformingAction, imagePreparationTask == nil else { return } + guard vmSettingsAvailability == .available else { + imagePreparationState = .failed("Fleet VM settings are unavailable.") + return + } + guard supportsMacOSImagePreparation else { + imagePreparationState = .failed( + "This Fleet Agent does not support macOS image preparation." + ) + return + } + guard let reference = settings?.macosRunnerImage?.target else { + imagePreparationState = .failed("Fleet Agent did not report a macOS runner image.") + return + } + guard let client = requireClient() else { + imagePreparationState = .failed("Fleet control client is unavailable.") + return + } + + let generation = UUID() + imagePreparationGeneration = generation + imagePreparationState = .preparing( + FleetImagePreparationProgress( + stage: "starting", + detail: "", + fraction: 0 + ) + ) + imagePreparationTask = Task { [weak self, client] in + await self?.runMacOSRunnerImagePreparation( + client: client, + generation: generation, + reference: reference + ) + } + } + + private func run(client: any FleetControlServicing, generation: UUID) async { + await loadInitialState(client: client, generation: generation) + guard generation == watchGeneration else { return } + await watchSnapshots(client: client, generation: generation) + } + + private func runMacOSRunnerImagePreparation( + client: any FleetControlServicing, + generation: UUID, + reference: String + ) async { + defer { + if generation == imagePreparationGeneration { + imagePreparationTask = nil + } + } + + do { + for try await event in client.prepareImages([.macosRunnerImage]) { + guard generation == imagePreparationGeneration else { return } + guard event.kind == .macosRunnerImage else { continue } + + imagePreparationState = .preparing( + FleetImagePreparationProgress( + stage: event.stage, + detail: event.detail, + fraction: event.fraction + ) + ) + } + + guard generation == imagePreparationGeneration else { return } + try Task.checkCancellation() + let refreshedSettings = try await client.getSettings() + guard generation == imagePreparationGeneration else { return } + applySettings(refreshedSettings) + guard refreshedSettings.macosRunnerImage?.current == reference, + refreshedSettings.macosRunnerImage?.target == reference + else { + throw FleetImagePreparationError.targetDidNotConverge(reference) + } + imagePreparationState = .completed(reference: reference) + lastError = nil + } catch is CancellationError { + guard generation == imagePreparationGeneration else { return } + imagePreparationState = .idle + } catch { + guard generation == imagePreparationGeneration else { return } + let message = FleetControlClient.userMessage(for: error) + Log.fleet.error( + "Fleet macOS image preparation failed: \(error.localizedDescription, privacy: .private)" + ) + imagePreparationState = .failed(message) + lastError = message + } + } + + private func loadInitialState(client: any FleetControlServicing, generation: UUID) async { + do { + let agentInfo = try await client.fetchAgentInfo() + let status = try await client.getStatus() + let settings = try await client.getSettings() + guard generation == watchGeneration else { return } + self.agentInfo = agentInfo + self.status = status + applySettings(settings) + lastError = nil + } catch { + guard generation == watchGeneration else { return } + markUnavailable(FleetControlClient.userMessage(for: error)) + } + } + + private func watchSnapshots(client: any FleetControlServicing, generation: UUID) async { + while !Task.isCancelled, generation == watchGeneration { + do { + if agentInfo == nil || reconnectAttempt > 0 { + let agentInfo = try await client.fetchAgentInfo() + guard generation == watchGeneration else { return } + self.agentInfo = agentInfo + } + isWatching = true + for try await snapshot in client.watchSnapshots() { + guard generation == watchGeneration else { return } + apply(snapshot) + } + + guard !Task.isCancelled, generation == watchGeneration else { return } + markWatchDisconnected("Fleet agent state stream ended.") + } catch is CancellationError { + return + } catch { + guard generation == watchGeneration else { return } + markWatchDisconnected(FleetControlClient.userMessage(for: error)) + } + + guard generation == watchGeneration else { return } + isWatching = false + reconnectAttempt += 1 + try? await Task.sleep(for: .seconds(reconnectDelaySeconds)) + } + } + + private func apply(_ snapshot: FleetAgentSnapshot) { + self.snapshot = snapshot + if let settings = snapshot.settings { + applySettings(settings) + } + self.status = Self.status(from: snapshot) + self.loadState = .ready + self.lastError = nil + self.isWatching = true + self.reconnectAttempt = 0 + snapshotObserver?(snapshot) + } + + private func refreshAfterMutation(client: any FleetControlServicing) async { + do { + status = try await client.getStatus() + applySettings(try await client.getSettings()) + lastError = nil + } catch { + handle(error) + } + } + + private func applySettings(_ newSettings: FleetAgentSettings) { + let previousTarget = settings?.macosRunnerImage?.target + settings = newSettings + + let newTarget = newSettings.macosRunnerImage?.target + if previousTarget != newTarget, !imagePreparationState.isPreparing { + imagePreparationState = .idle + } + + guard case .completed(let reference) = imagePreparationState else { return } + guard newSettings.macosRunnerImage?.current == reference, + newSettings.macosRunnerImage?.target == reference + else { + imagePreparationState = .idle + return + } + } + + private func performAction( + _ label: String, + operation: () async throws -> Void + ) async -> Bool { + guard !isPerformingAction else { return false } + + isPerformingAction = true + lastError = nil + defer { isPerformingAction = false } + + do { + try await operation() + return true + } catch { + Log.fleet.error("Fleet \(label, privacy: .public) failed: \(error.localizedDescription, privacy: .private)") + handle(error) + return false + } + } + + private func requireClient() -> (any FleetControlServicing)? { + guard let client else { + markUnavailable("Fleet control client is unavailable.") + return nil + } + return client + } + + private func handle(_ error: Error) { + let message = FleetControlClient.userMessage(for: error) + lastError = message + if snapshot == nil { + loadState = .unavailable(message) + } + } + + private func markUnavailable(_ message: String) { + lastError = message + isWatching = false + loadState = .unavailable(message) + } + + private func markWatchDisconnected(_ message: String) { + lastError = message + loadState = snapshot == nil ? .unavailable(message) : .failed(message) + } + + private var reconnectDelaySeconds: Int64 { + Int64(min(30, 1 << min(reconnectAttempt, 5))) + } + + private static func status(from snapshot: FleetAgentSnapshot) -> FleetAgentStatus { + let state: FleetConnectionState + switch snapshot.enrollment { + case .unenrolled: + state = .unenrolled + case .attaching, .attached: + state = snapshot.isDraining ? .draining : .enrolled + case .updating: + state = .draining + case .detached: + state = .detached + case .credentialRejected: + state = .credentialRejected + case .unspecified: + state = .unspecified + case .unrecognized(let value): + state = .unrecognized(value) + } + return FleetAgentStatus(state: state, machineID: snapshot.machineID) + } + + static func resolveVMSettingsAvailability( + agentInfo: FleetAgentInfo?, + settings: FleetAgentSettings?, + loadState: FleetLoadState + ) -> FleetVMSettingsAvailability { + switch loadState { + case .idle, .connecting: + return .loading + case .unavailable(let message), .failed(let message): + return .unavailable(message) + case .ready: + break + } + + guard let agentInfo else { + return .unavailable("Fleet Agent capability data is unavailable.") + } + guard agentInfo.supportsFeature(FleetAgentFeature.vmSettings) else { + return .unsupported + } + guard settings?.vmMode != nil, settings?.macosRunnerImage != nil else { + return .missingSettings + } + return .available + } +} diff --git a/ArcBox/ViewModels/RunnerPlatformStore.swift b/ArcBox/ViewModels/RunnerPlatformStore.swift new file mode 100644 index 00000000..beeec951 --- /dev/null +++ b/ArcBox/ViewModels/RunnerPlatformStore.swift @@ -0,0 +1,180 @@ +import FleetPlatformClient +import Foundation +import Observation + +@MainActor +protocol RunnerPlatformLoading: Sendable { + func listWorkspaces() async throws -> [FleetWorkspace] + func listMachines(workspaceID: String) async throws -> [FleetMachine] + func getMachine(id: String, workspaceID: String) async throws -> FleetMachine + func listJobs( + workspaceID: String, + machineID: String?, + status: FleetRunnerJobStatus?, + cursor: String?, + limit: Int? + ) async throws -> FleetRunnerJobPage +} + +extension FleetPlatformClient: RunnerPlatformLoading {} + +enum RunnerPlatformLoadState: Equatable { + case idle + case loading + case loaded + case machineNotFound + case failed(String) +} + +/// Window-scoped Platform history for the local machine reported by Fleet Agent. +@MainActor +@Observable +final class RunnerPlatformStore { + private(set) var loadState: RunnerPlatformLoadState = .idle + private(set) var workspace: FleetWorkspace? + private(set) var machine: FleetMachine? + private(set) var jobs: [FleetRunnerJob] = [] + private(set) var nextCursor: String? + private(set) var isRefreshing = false + private(set) var selection: RunnerSelection? + + @ObservationIgnored + private var workspaceByMachineID: [String: FleetWorkspace] = [:] + + @ObservationIgnored + private var refreshSequence = 0 + + func observe( + client: any RunnerPlatformLoading, + machineID: String, + interval: Duration = .seconds(30) + ) async { + while !Task.isCancelled { + await refresh(client: client, machineID: machineID) + guard !Task.isCancelled else { return } + + do { + try await Task.sleep(for: interval) + } catch { + return + } + } + } + + func refresh(client: any RunnerPlatformLoading, machineID: String) async { + refreshSequence += 1 + let sequence = refreshSequence + isRefreshing = true + if machine == nil || machine?.id != machineID { + selection = nil + loadState = .loading + } + + do { + let snapshot = try await loadSnapshot(client: client, machineID: machineID) + guard sequence == refreshSequence else { return } + + workspaceByMachineID[machineID] = snapshot.workspace + workspace = snapshot.workspace + machine = snapshot.machine + jobs = snapshot.jobs.jobs + nextCursor = snapshot.jobs.nextCursor + loadState = .loaded + isRefreshing = false + } catch is CancellationError { + guard sequence == refreshSequence else { return } + isRefreshing = false + } catch RunnerPlatformStoreError.machineNotFound { + guard sequence == refreshSequence else { return } + + workspaceByMachineID[machineID] = nil + workspace = nil + machine = nil + jobs = [] + nextCursor = nil + loadState = .machineNotFound + isRefreshing = false + } catch { + guard sequence == refreshSequence else { return } + + loadState = .failed(FleetPlatformClient.userMessage(for: error)) + isRefreshing = false + } + } + + func reset() { + refreshSequence += 1 + workspaceByMachineID.removeAll() + workspace = nil + machine = nil + jobs = [] + nextCursor = nil + loadState = .idle + isRefreshing = false + selection = nil + } + + var selectedJobID: String? { + guard case .job(let jobID) = selection else { return nil } + return jobID + } + + func selectHost() { + selection = .host + } + + func selectJob(id: String) { + selection = .job(id) + } + + func reconcileSelection(validJobIDs: Set) { + guard case .job(let jobID) = selection else { return } + if !validJobIDs.contains(jobID) { + selection = nil + } + } + + private func loadSnapshot( + client: any RunnerPlatformLoading, + machineID: String + ) async throws -> RunnerPlatformSnapshot { + if let workspace = workspaceByMachineID[machineID] { + let machine = try await client.getMachine(id: machineID, workspaceID: workspace.id) + let jobs = try await client.listJobs( + workspaceID: workspace.id, + machineID: machineID, + status: nil, + cursor: nil, + limit: 50 + ) + return RunnerPlatformSnapshot(workspace: workspace, machine: machine, jobs: jobs) + } + + for workspace in try await client.listWorkspaces() { + let machines = try await client.listMachines(workspaceID: workspace.id) + guard let machine = machines.first(where: { $0.id == machineID }) else { + continue + } + let jobs = try await client.listJobs( + workspaceID: workspace.id, + machineID: machineID, + status: nil, + cursor: nil, + limit: 50 + ) + return RunnerPlatformSnapshot(workspace: workspace, machine: machine, jobs: jobs) + } + + throw RunnerPlatformStoreError.machineNotFound + } +} + +private struct RunnerPlatformSnapshot { + let workspace: FleetWorkspace + let machine: FleetMachine + let jobs: FleetRunnerJobPage +} + +private enum RunnerPlatformStoreError: Error { + case machineNotFound +} diff --git a/ArcBox/ViewModels/RunnersViewModel.swift b/ArcBox/ViewModels/RunnersViewModel.swift new file mode 100644 index 00000000..f82dc482 --- /dev/null +++ b/ArcBox/ViewModels/RunnersViewModel.swift @@ -0,0 +1,400 @@ +import ArcBoxAuth +import FleetControlClient +import FleetPlatformClient +import Foundation +import Observation + +@MainActor +protocol FleetWorkspaceListing: Sendable { + func listWorkspaces() async throws -> [FleetWorkspace] +} + +extension FleetPlatformClient: FleetWorkspaceListing {} + +@MainActor +@Observable +final class RunnersViewModel { + struct EnrollmentContext: Equatable { + let state: FleetEnrollmentCoordinator.State + let isSignedIn: Bool + let canBeginEnrollment: Bool + } + + let fleet: FleetViewModel + private(set) var workspaces: [FleetWorkspace] = [] + private(set) var platformError: String? + private(set) var isLoadingWorkspaces = false + + @ObservationIgnored + private var platformClient: (any FleetWorkspaceListing)? + + @ObservationIgnored + private var enrollmentCoordinator: FleetEnrollmentCoordinator? + + @ObservationIgnored + private var activeControlClient: FleetControlClient? + + @ObservationIgnored + private var activePlatformClient: FleetPlatformClient? + + @ObservationIgnored + private var hasStarted = false + + init(fleet: FleetViewModel = FleetViewModel()) { + self.fleet = fleet + } + + var viewState: RunnersViewState { + Self.resolveViewState( + snapshot: fleet.snapshot, + agentInfo: fleet.agentInfo, + loadState: fleet.loadState, + enrollmentContext: enrollmentContext + ) + } + + private var enrollmentContext: EnrollmentContext? { + guard let enrollmentCoordinator else { return nil } + return EnrollmentContext( + state: enrollmentCoordinator.state, + isSignedIn: enrollmentCoordinator.isSignedIn, + canBeginEnrollment: enrollmentCoordinator.canBeginEnrollment + ) + } + + var activeJobCount: Int { + fleet.snapshot?.inFlightJobs.count ?? 0 + } + + var errorMessage: String? { + platformError ?? fleet.lastError ?? enrollmentCoordinator?.errorMessage + } + + var isBusy: Bool { + isLoadingWorkspaces || fleet.isPerformingAction || enrollmentCoordinator?.isBusy == true + } + + var canConnect: Bool { + !isBusy && enrollmentCoordinator?.canBeginEnrollment == true + } + + var subtitle: String { + switch viewState { + case .connecting: return "Connecting" + case .unavailable: return "Agent unavailable" + case .signedOut: return "Not connected" + case .unenrolled: return "Not connected" + case .enrolling(let progress): return progress.title + case .enrollmentFailed(_, let recovery): + switch recovery { + case .retry: return "Enrollment failed" + case .waitForAgent: return "Confirming enrollment" + case .unenroll: return "Enrollment needs attention" + } + case .failed: return "Fleet integration unavailable" + case .enrolled(let host, let freshness): + if case .reconnecting = freshness { + return "Reconnecting to Fleet Agent" + } + return host.activeJobCount == 1 + ? "\(host.status.label) · 1 active job" + : "\(host.status.label) · \(host.activeJobCount) active jobs" + } + } + + func start( + controlClient: FleetControlClient?, + platformClient: FleetPlatformClient?, + authentication: (any FleetAuthenticationChecking)? = nil, + agentReadiness: (any FleetAgentReadying)? = nil + ) { + self.platformClient = platformClient + + if enrollmentCoordinator == nil || activePlatformClient !== platformClient { + if let authentication, let agentReadiness { + enrollmentCoordinator = FleetEnrollmentCoordinator( + authentication: authentication, + agentReadiness: agentReadiness, + tokenIssuer: platformClient + ) + } else { + enrollmentCoordinator = nil + } + } + + guard + !hasStarted + || activeControlClient !== controlClient + || activePlatformClient !== platformClient + else { return } + + hasStarted = true + activeControlClient = controlClient + activePlatformClient = platformClient + let coordinator = enrollmentCoordinator + fleet.start( + client: controlClient, + onSnapshot: { [weak coordinator] snapshot in + coordinator?.reconcile(snapshot) + } + ) + } + + func stop() { + enrollmentCoordinator?.cancel() + fleet.stop() + hasStarted = false + activeControlClient = nil + activePlatformClient = nil + } + + func prepareForTermination(gracePeriod: Duration = .seconds(65)) async -> Bool { + let settled = + await enrollmentCoordinator?.settleForTermination( + gracePeriod: gracePeriod + ) ?? true + fleet.stop() + return settled + } + + func prepareEnrollment() async -> Bool { + guard !isBusy else { return false } + + platformError = nil + guard let enrollmentCoordinator else { + platformError = "Fleet enrollment is unavailable." + return false + } + guard enrollmentCoordinator.canBeginEnrollment else { return false } + guard enrollmentCoordinator.requireSignedIn() else { return false } + guard let platformClient else { + platformError = "Fleet Platform client is unavailable." + return false + } + + isLoadingWorkspaces = true + platformError = nil + defer { isLoadingWorkspaces = false } + + do { + workspaces = try await platformClient.listWorkspaces() + } catch { + platformError = FleetPlatformClient.userMessage(for: error) + return false + } + + guard !workspaces.isEmpty else { + platformError = "No ArcBox workspace is available for this account." + return false + } + return true + } + + @discardableResult + func enroll(in workspace: FleetWorkspace) async -> Bool { + platformError = nil + guard let enrollmentCoordinator else { + platformError = "Fleet enrollment is unavailable." + return false + } + + let succeeded = await enrollmentCoordinator.enroll(workspaceID: workspace.id) + if succeeded { + platformError = nil + } + return succeeded + } + + /// Enrolls with a caller-supplied token; the current Desktop UI does not expose this fallback. + @discardableResult + func enroll(withToken token: String) async -> Bool { + platformError = nil + guard let enrollmentCoordinator else { + platformError = "Fleet enrollment is unavailable." + return false + } + + return await enrollmentCoordinator.enroll(token: token) + } + + @discardableResult + func setDraining(_ draining: Bool) async -> Bool { + guard !isBusy else { return false } + + if draining { + return await fleet.drain() + } else { + return await fleet.resume() + } + } + + @discardableResult + func unenroll() async -> Bool { + guard !isBusy else { return false } + let succeeded = await fleet.unenroll() + if succeeded { + enrollmentCoordinator?.confirmUnenrolled() + if let snapshot = fleet.snapshot { + enrollmentCoordinator?.reconcile(snapshot) + } + } + return succeeded + } + + static func resolveViewState( + snapshot: FleetAgentSnapshot?, + agentInfo: FleetAgentInfo?, + loadState: FleetLoadState, + enrollmentContext: EnrollmentContext? + ) -> RunnersViewState { + if let snapshot { + switch snapshot.enrollment { + case .attaching, .attached, .updating: + guard normalizedMachineID(snapshot.machineID) != nil else { + return .failed("Fleet Agent reported an invalid machine identity.") + } + return .enrolled( + RunnerHostViewModel(snapshot: snapshot, agentInfo: agentInfo), + freshness: hostFreshness(loadState: loadState) + ) + case .credentialRejected: + guard let machineID = normalizedMachineID(snapshot.machineID) else { + return .failed("Fleet Agent reported an invalid machine identity.") + } + if let override = connectivityOverride(loadState: loadState) { + return override + } + return .enrollmentFailed( + FleetEnrollmentCoordinator.Failure.credentialRejected(machineID: machineID) + .localizedDescription, + recovery: .unenroll + ) + case .detached: + guard let machineID = normalizedMachineID(snapshot.machineID) else { + return .failed("Fleet Agent reported an invalid machine identity.") + } + if let override = connectivityOverride(loadState: loadState) { + return override + } + return .enrollmentFailed( + FleetEnrollmentCoordinator.Failure.detached(machineID: machineID) + .localizedDescription, + recovery: .unenroll + ) + case .unenrolled: + guard normalizedMachineID(snapshot.machineID) == nil else { + return .failed("Fleet Agent reported an invalid unenrolled state.") + } + if let override = connectivityOverride(loadState: loadState) { + return override + } + return resolveUnenrolledState( + enrollmentContext: enrollmentContext + ) + case .unspecified: + return .failed("Fleet Agent did not report a valid enrollment state.") + case .unrecognized: + return .failed( + "Fleet Agent reported an enrollment state this ArcBox version does not support." + ) + } + } + + switch loadState { + case .idle, .connecting, .ready: + return .connecting + case .unavailable(let message), .failed(let message): + return .unavailable(message) + } + } + + private static func resolveUnenrolledState( + enrollmentContext: EnrollmentContext? + ) -> RunnersViewState { + guard let enrollmentContext else { + return .failed("Fleet enrollment is unavailable for this build.") + } + + switch enrollmentContext.state { + case .preparingAgent: + return .enrolling(.checkingAgent) + case .requestingEnrollmentToken: + return .enrolling(.requestingToken) + case .enrolling: + return .enrolling(.enrollingAgent) + case .reconcilingEnrollment: + return .enrolling(.reconciling) + case .attaching: + return .enrolling(.attaching) + case .ready: + return .enrolling(.synchronizing) + case .failed(let failure): + return .enrollmentFailed( + failure.localizedDescription, + recovery: enrollmentRecovery( + for: failure, + canBeginEnrollment: enrollmentContext.canBeginEnrollment + ) + ) + case .idle, .requiresSignIn: + guard enrollmentContext.isSignedIn else { return .signedOut } + guard enrollmentContext.canBeginEnrollment else { + return .enrollmentFailed( + "ArcBox is waiting for the Fleet Agent to report a conclusive enrollment state.", + recovery: .waitForAgent + ) + } + return .unenrolled + } + } + + /// A non-live `loadState` for a terminal snapshot (unenrolled, credential-rejected, + /// detached) means the watch has disconnected and the snapshot is stale — surface + /// connectivity instead of offering recovery actions the disconnected client can't serve. + private static func connectivityOverride(loadState: FleetLoadState) -> RunnersViewState? { + switch loadState { + case .idle, .connecting: + return .connecting + case .unavailable(let message), .failed(let message): + return .unavailable(message) + case .ready: + return nil + } + } + + private static func hostFreshness(loadState: FleetLoadState) -> RunnerHostFreshness { + switch loadState { + case .ready: + return .live + case .idle, .connecting: + return .reconnecting("Connecting to Fleet Agent.") + case .unavailable(let message), .failed(let message): + return .reconnecting(message) + } + } + + private static func enrollmentRecovery( + for failure: FleetEnrollmentCoordinator.Failure, + canBeginEnrollment: Bool + ) -> RunnerEnrollmentRecovery { + guard !canBeginEnrollment else { return .retry } + + switch failure { + case .enrollmentOutcomeUnknown: + return .waitForAgent + case .credentialRejected, .detached, .stateStreamEnded, .stateStreamFailed, + .attachmentTimedOut, .cancelled(machineID: .some): + return .unenroll + case .workspaceRequired, .enrollmentTokenRequired, .agentPreparationFailed, + .enrollmentTokenRequestFailed, .cancelled(machineID: nil): + return .waitForAgent + } + } + + private static func normalizedMachineID(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { return nil } + return value + } +} diff --git a/ArcBox/Views/ContentView.swift b/ArcBox/Views/ContentView.swift index 553531c8..fb0e4c22 100644 --- a/ArcBox/Views/ContentView.swift +++ b/ArcBox/Views/ContentView.swift @@ -35,6 +35,7 @@ struct ContentView: View { @Environment(VolumesViewModel.self) private var volumesVM @Environment(ImagesViewModel.self) private var imagesVM @Environment(NetworksViewModel.self) private var networksVM + @Environment(RunnersViewModel.self) private var runnersVM // Feature ViewModels -- local to main window @State private var activityVM = ActivityViewModel() @@ -42,6 +43,7 @@ struct ContentView: View { @State private var k8sState = KubernetesState() @State private var machinesVM = MachinesViewModel() @State private var sandboxesVM = SandboxesViewModel() + @State private var runnerPlatformStore = RunnerPlatformStore() @ViewBuilder var body: some View { @@ -90,6 +92,9 @@ struct ContentView: View { Section(section.rawValue.capitalized) { ForEach(section.items) { item in Label(item.label, systemImage: item.sfSymbol) + // A zero badge renders nothing, so only the runner + // row shows its in-flight job count. + .badge(item == .runner ? runnersVM.activeJobCount : 0) .tag(item) } } @@ -142,6 +147,9 @@ struct ContentView: View { case .machines: MachinesView() .environment(machinesVM) + case .runner: + RunnersView() + .environment(runnerPlatformStore) case .sandboxes: SandboxesListView() .environment(sandboxesVM) @@ -183,6 +191,9 @@ struct ContentView: View { case .machines: MachineDetailView() .environment(machinesVM) + case .runner: + RunnerSelectionDetailView() + .environment(runnerPlatformStore) case .sandboxes: SandboxDetailView() .environment(sandboxesVM) diff --git a/ArcBox/Views/Runners/FleetBackend+DisplayName.swift b/ArcBox/Views/Runners/FleetBackend+DisplayName.swift new file mode 100644 index 00000000..1aa66c3c --- /dev/null +++ b/ArcBox/Views/Runners/FleetBackend+DisplayName.swift @@ -0,0 +1,12 @@ +import FleetControlClient + +extension FleetBackend { + var displayName: String { + switch self { + case .hostRunner: "Host" + case .docker: "Docker" + case .vm: "VM" + case .unspecified, .unrecognized: "Unknown" + } + } +} diff --git a/ArcBox/Views/Runners/FleetMachineStatus+DisplayName.swift b/ArcBox/Views/Runners/FleetMachineStatus+DisplayName.swift new file mode 100644 index 00000000..d26f15dd --- /dev/null +++ b/ArcBox/Views/Runners/FleetMachineStatus+DisplayName.swift @@ -0,0 +1,13 @@ +import FleetPlatformClient + +extension FleetMachineStatus { + var displayName: String { + switch self { + case .enrolled: "Enrolled" + case .online: "Online" + case .offline: "Offline" + case .draining: "Draining" + case .decommissioned: "Decommissioned" + } + } +} diff --git a/ArcBox/Views/Runners/FleetRunnerJobStatus+Display.swift b/ArcBox/Views/Runners/FleetRunnerJobStatus+Display.swift new file mode 100644 index 00000000..8f8cbc96 --- /dev/null +++ b/ArcBox/Views/Runners/FleetRunnerJobStatus+Display.swift @@ -0,0 +1,25 @@ +import FleetPlatformClient +import SwiftUI + +extension FleetRunnerJobStatus { + var displayName: String { + switch self { + case .queued: "Queued" + case .provisioning: "Provisioning" + case .running: "Running" + case .completed: "Completed" + case .failed: "Failed" + case .canceled: "Canceled" + } + } + + var color: Color { + switch self { + case .queued: AppColors.stopped + case .provisioning: AppColors.warning + case .running, .completed: AppColors.running + case .failed: AppColors.error + case .canceled: AppColors.stopped + } + } +} diff --git a/ArcBox/Views/Runners/RunnerEmptyState.swift b/ArcBox/Views/Runners/RunnerEmptyState.swift new file mode 100644 index 00000000..d01a97d5 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerEmptyState.swift @@ -0,0 +1,66 @@ +import SwiftUI + +/// Shown when this Mac is not enrolled in any fleet yet. +struct RunnerEmptyState: View { + let isWorking: Bool + let canConnect: Bool + let errorMessage: String? + var actionTitle = "Connect to ArcBox" + var onConnect: () -> Void + + private let chip = RunnerHostCapability.chipName + + var body: some View { + EmptyStateView(icon: "hammer", title: "Turn this Mac into a CI runner") { + VStack(alignment: .leading, spacing: 12) { + Text("Run GitHub Actions jobs for your organization on this machine:") + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + + VStack(alignment: .leading, spacing: 4) { + Text("\u{2022} \(chip)") + Text("\u{2022} Runtime capabilities are detected by the Fleet Agent") + Text("\u{2022} Jobs and settings stay under local agent control") + } + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + + Button(action: onConnect) { + HStack { + if isWorking { + ProgressView() + .controlSize(.small) + } + Text(isWorking ? "Connecting…" : actionTitle) + .frame(maxWidth: .infinity) + } + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + .disabled(!canConnect) + .padding(.top, 8) + .accessibilityLabel(isWorking ? "Connecting this Mac to ArcBox" : actionTitle) + + if let errorMessage { + // No .fixedSize here: on macOS 26, changes inside a + // fixedSize(vertical:) subtree trigger a window-sizing pass + // that grows the window/split view to screen height. + // The label wraps correctly without it. + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(AppColors.warning) + } + } + } + } +} + +#Preview { + RunnerEmptyState( + isWorking: false, + canConnect: true, + errorMessage: nil, + onConnect: {} + ) + .frame(width: 320, height: 520) +} diff --git a/ArcBox/Views/Runners/RunnerHostCapacityTab.swift b/ArcBox/Views/Runners/RunnerHostCapacityTab.swift new file mode 100644 index 00000000..c498e695 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostCapacityTab.swift @@ -0,0 +1,120 @@ +import FleetControlClient +import FleetPlatformClient +import SwiftUI + +struct RunnerHostCapacityTab: View { + let host: RunnerHostViewModel + let machine: FleetMachine? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + poolSection( + title: "macOS runners", + pool: .macOS, + policyLimit: "Up to 2 concurrent VMs" + ) + poolSection( + title: "Linux runners", + pool: .linux + ) + hardwareSection + } + .padding(16) + } + } + + private func poolSection( + title: String, + pool: RunnerPoolOS, + policyLimit: String? = nil + ) -> some View { + let capabilities = host.capabilities(for: pool) + + return VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + + VStack(spacing: 0) { + InfoRow( + label: "Availability", + value: capabilities.isEmpty ? "Not reported" : "Available" + ) + InfoRow( + label: "Active jobs", + value: host.activeJobCount(for: pool).formatted() + ) + InfoRow( + label: policyLimit == nil ? "Concurrency limit" : "Policy limit", + value: policyLimit ?? "Not reported by Agent" + ) + InfoRow( + label: "Backends", + value: capabilities.isEmpty + ? "None" + : unique(capabilities.map { $0.backend.displayName }).joined(separator: ", ") + ) + InfoRow( + label: "Architectures", + value: capabilities.isEmpty + ? "None" + : unique(capabilities.map(\.arch)).joined(separator: ", ") + ) + } + .infoSectionStyle() + } + } + + private func unique(_ values: [String]) -> [String] { + Array(Set(values)).sorted() + } + + private var hardwareSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Host resources") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "CPU", value: cpuDescription) + InfoRow(label: "Memory", value: memoryDescription) + InfoRow(label: "Available memory", value: availableMemoryDescription) + InfoRow(label: "1-minute load", value: loadDescription) + } + .infoSectionStyle() + } + } + + private var cpuDescription: String { + if let cpuCount = host.telemetry?.cpuCount { + return "\(cpuCount) cores" + } + if let cpu = machine?.cpu { + return "\(cpu) cores" + } + return "Unavailable" + } + + private var memoryDescription: String { + if let total = host.telemetry?.memoryTotalMib { + return "\(total.formatted()) MiB" + } + if let total = machine?.memMib { + return "\(total.formatted()) MiB" + } + return "Unavailable" + } + + private var availableMemoryDescription: String { + guard let available = host.telemetry?.memoryAvailableMib else { + return "Unavailable" + } + return "\(available.formatted()) MiB" + } + + private var loadDescription: String { + guard let load = host.telemetry?.loadAverage1Minute else { + return "Unavailable" + } + return load.formatted(.number.precision(.fractionLength(2))) + } +} diff --git a/ArcBox/Views/Runners/RunnerHostDetailView.swift b/ArcBox/Views/Runners/RunnerHostDetailView.swift new file mode 100644 index 00000000..f4bd17bd --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostDetailView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct RunnerHostDetailView: View { + let host: RunnerHostViewModel + + @Environment(RunnerPlatformStore.self) private var platformStore + @Environment(RunnersViewModel.self) private var runners + @State private var activeTab: RunnerHostDetailTab = .overview + + var body: some View { + Group { + switch activeTab { + case .overview: + RunnerHostOverviewTab( + host: host, + machine: platformStore.machine, + workspace: platformStore.workspace, + jobs: platformStore.jobs, + hasMoreJobHistory: platformStore.nextCursor != nil + ) + case .capacity: + RunnerHostCapacityTab( + host: host, + machine: platformStore.machine + ) + case .settings: + RunnerHostSettingsTab( + host: host, + settings: runners.fleet.settings + ) + case .identity: + RunnerHostIdentityTab( + host: host, + machine: platformStore.machine, + workspace: platformStore.workspace + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("Host detail", selection: $activeTab) { + ForEach(RunnerHostDetailTab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 360) + } + } + } +} diff --git a/ArcBox/Views/Runners/RunnerHostIdentityTab.swift b/ArcBox/Views/Runners/RunnerHostIdentityTab.swift new file mode 100644 index 00000000..8263f38d --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostIdentityTab.swift @@ -0,0 +1,93 @@ +import FleetControlClient +import FleetPlatformClient +import SwiftUI + +struct RunnerHostIdentityTab: View { + let host: RunnerHostViewModel + let machine: FleetMachine? + let workspace: FleetWorkspace? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + machineSection + enrollmentSection + credentialSection + } + .padding(16) + } + } + + private var machineSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Machine") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Name", value: machine?.name ?? "This Mac") + InfoRow(label: "Machine ID", value: host.machineID ?? "Unavailable") + InfoRow( + label: "Architecture", + value: machine?.arch ?? host.capabilities.first?.arch ?? "Unavailable" + ) + InfoRow(label: "Agent version", value: host.agentVersion ?? "Unavailable") + InfoRow( + label: "Tags", + value: machine?.tags.isEmpty == false + ? machine?.tags.joined(separator: ", ") ?? "None" + : "None" + ) + } + .infoSectionStyle() + } + } + + private var enrollmentSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Enrollment") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Workspace", value: workspace?.name ?? "Unavailable") + InfoRow(label: "Workspace ID", value: workspace?.id ?? "Unavailable") + InfoRow(label: "Enrolled", value: dateDescription(machine?.enrolledAt)) + InfoRow(label: "Last seen", value: dateDescription(machine?.lastSeen)) + InfoRow(label: "Platform state", value: machine?.status.displayName ?? "Unavailable") + } + .infoSectionStyle() + } + } + + private var credentialSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Credential") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Status", value: credentialStatus) + InfoRow(label: "Storage", value: "Fleet Agent managed") + InfoRow(label: "Desktop access", value: "Credential contents unavailable") + } + .infoSectionStyle() + + Text( + "The Fleet credential stays in the local Agent and is never exposed to ArcBox Desktop." + ) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } + + private var credentialStatus: String { + switch host.status { + case .credentialRejected: "Rejected" + case .detached: "Detached" + case .online, .draining, .attaching, .updating: "Present" + case .unknown: "Unknown" + } + } + + private func dateDescription(_ date: Date?) -> String { + date?.formatted(date: .abbreviated, time: .shortened) ?? "Unavailable" + } +} diff --git a/ArcBox/Views/Runners/RunnerHostOverviewJobsSection.swift b/ArcBox/Views/Runners/RunnerHostOverviewJobsSection.swift new file mode 100644 index 00000000..d3a6f260 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostOverviewJobsSection.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct RunnerHostOverviewJobsSection: View { + let summary: RunnerHostJobSummary + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Job activity") + .font(.headline) + + Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) { + GridRow { + metric(title: "Today", value: summary.todayCount.formatted()) + metric(title: "Recorded", value: summary.recordedCountDescription) + metric(title: "Success rate", value: successRateDescription) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .infoSectionStyle() + + if summary.hasMoreHistory { + Label( + "Metrics use the most recent loaded Platform history.", + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } + } + + private var successRateDescription: String { + guard let successRate = summary.successRate else { return "—" } + return successRate.formatted(.percent.precision(.fractionLength(0))) + } + + private func metric(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(value) + .font(.title2) + .bold() + Text(title) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} diff --git a/ArcBox/Views/Runners/RunnerHostOverviewPoolsSection.swift b/ArcBox/Views/Runners/RunnerHostOverviewPoolsSection.swift new file mode 100644 index 00000000..e4e5d123 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostOverviewPoolsSection.swift @@ -0,0 +1,72 @@ +import FleetControlClient +import SwiftUI + +struct RunnerHostOverviewPoolsSection: View { + let host: RunnerHostViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Runner pools") + .font(.headline) + + VStack(spacing: 0) { + poolRow( + title: "macOS", + systemImage: "macwindow", + pool: .macOS + ) + Divider() + poolRow( + title: "Linux", + systemImage: "shippingbox", + pool: .linux + ) + } + .infoSectionStyle() + } + } + + private func poolRow( + title: String, + systemImage: String, + pool: RunnerPoolOS + ) -> some View { + let capabilities = host.capabilities(for: pool) + let activeJobCount = host.activeJobCount(for: pool) + + return HStack(spacing: 12) { + Image(systemName: systemImage) + .foregroundStyle(AppColors.accent) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + Text(capabilityDescription(capabilities)) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + + Spacer() + + Text(statusDescription(capabilities: capabilities, activeJobCount: activeJobCount)) + .font(.caption) + .foregroundStyle(capabilities.isEmpty ? AppColors.textSecondary : AppColors.running) + } + .padding(12) + } + + private func capabilityDescription(_ capabilities: [FleetCapability]) -> String { + guard !capabilities.isEmpty else { return "No capability reported by the Agent" } + return capabilities.map { "\($0.arch) · \($0.backend.displayName)" } + .joined(separator: ", ") + } + + private func statusDescription( + capabilities: [FleetCapability], + activeJobCount: Int + ) -> String { + guard !capabilities.isEmpty else { return "Unavailable" } + guard activeJobCount > 0 else { return "Available" } + return "\(activeJobCount) active" + } +} diff --git a/ArcBox/Views/Runners/RunnerHostOverviewStatusSection.swift b/ArcBox/Views/Runners/RunnerHostOverviewStatusSection.swift new file mode 100644 index 00000000..63809c9f --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostOverviewStatusSection.swift @@ -0,0 +1,19 @@ +import FleetPlatformClient +import SwiftUI + +struct RunnerHostOverviewStatusSection: View { + let host: RunnerHostViewModel + let machine: FleetMachine? + let workspace: FleetWorkspace? + + var body: some View { + VStack(spacing: 0) { + InfoRow(label: "Status", value: host.status.label) + InfoRow(label: "Workspace", value: workspace?.name ?? "Unavailable") + InfoRow(label: "Machine", value: machine?.name ?? "This Mac") + InfoRow(label: "Agent", value: host.agentVersion ?? "Version unavailable") + InfoRow(label: "Active jobs", value: host.activeJobCount.formatted()) + } + .infoSectionStyle() + } +} diff --git a/ArcBox/Views/Runners/RunnerHostOverviewTab.swift b/ArcBox/Views/Runners/RunnerHostOverviewTab.swift new file mode 100644 index 00000000..3a3f2090 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostOverviewTab.swift @@ -0,0 +1,30 @@ +import FleetPlatformClient +import SwiftUI + +struct RunnerHostOverviewTab: View { + let host: RunnerHostViewModel + let machine: FleetMachine? + let workspace: FleetWorkspace? + let jobs: [FleetRunnerJob] + let hasMoreJobHistory: Bool + + var body: some View { + let summary = RunnerHostJobSummary( + jobs: jobs, + hasMoreHistory: hasMoreJobHistory + ) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + RunnerHostOverviewStatusSection( + host: host, + machine: machine, + workspace: workspace + ) + RunnerHostOverviewJobsSection(summary: summary) + RunnerHostOverviewPoolsSection(host: host) + } + .padding(16) + } + } +} diff --git a/ArcBox/Views/Runners/RunnerHostSettingsTab.swift b/ArcBox/Views/Runners/RunnerHostSettingsTab.swift new file mode 100644 index 00000000..bdf7a79f --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostSettingsTab.swift @@ -0,0 +1,157 @@ +import FleetControlClient +import SwiftUI + +struct RunnerHostSettingsTab: View { + let host: RunnerHostViewModel + let settings: FleetAgentSettings? + + @Environment(RunnersViewModel.self) private var runners + @State private var isConfirmingUnenroll = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + participationSection + networkSection + serviceSection + controlsSection + + if let errorMessage = runners.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(AppColors.warning) + } + } + .padding(16) + } + } + + private var participationSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Participation") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "State", value: host.isDraining ? "Draining" : "Accepting jobs") + InfoRow( + label: "Agent policy", + value: settingDescription(settings?.participate) { $0 ? "Enabled" : "Disabled" } + ) + InfoRow( + label: "Load ceiling", + value: settingDescription(settings?.loadCeiling) { + $0.formatted(.number.precision(.fractionLength(2))) + } + ) + InfoRow( + label: "Memory reserve", + value: settingDescription(settings?.memFloorMib) { + "\($0.formatted()) MiB" + } + ) + } + .infoSectionStyle() + } + } + + private var networkSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Network") + .font(.headline) + + VStack(spacing: 0) { + InfoRow( + label: "Fleet gateway", + value: settingDescription(settings?.gateway) { + $0.isEmpty ? "Not configured" : $0 + } + ) + } + .infoSectionStyle() + } + } + + private var serviceSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Fleet Agent service") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Process manager", value: "launchd / service daemon") + InfoRow(label: "Desktop responsibility", value: "Local gRPC client only") + } + .infoSectionStyle() + + Text( + "Starting, stopping, updating, and installing the Fleet Agent are handled outside ArcBox Desktop." + ) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } + + private var controlsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Controls") + .font(.headline) + + Button( + host.isDraining ? "Resume accepting jobs" : "Drain this runner", + systemImage: host.isDraining ? "play.fill" : "pause.fill", + action: toggleDrainState + ) + .disabled(runners.isBusy || !host.status.canChangeDrainState) + + Divider() + + Button( + "Remove this runner", + systemImage: "trash", + role: .destructive + ) { + isConfirmingUnenroll = true + } + .disabled(runners.isBusy) + .confirmationDialog( + "Remove this runner?", + isPresented: $isConfirmingUnenroll, + titleVisibility: .visible + ) { + Button("Remove Runner", role: .destructive, action: unenroll) + Button("Cancel", role: .cancel) {} + } message: { + Text(unenrollMessage) + } + } + } + + private var unenrollMessage: String { + let credentialMessage = + "This removes the Fleet credential from the local Agent. It does not stop or uninstall the Agent." + guard host.activeJobCount > 0 else { return credentialMessage } + let jobLabel = host.activeJobCount == 1 ? "job" : "jobs" + return "The Agent reports \(host.activeJobCount) active \(jobLabel). \(credentialMessage)" + } + + private func toggleDrainState() { + Task { + await runners.setDraining(!host.isDraining) + } + } + + private func unenroll() { + Task { + await runners.unenroll() + } + } + + private func settingDescription( + _ setting: FleetSetting?, + format: (Value) -> String + ) -> String { + guard let setting else { return "Not reported" } + let target = format(setting.target) + guard setting.isPending else { return target } + return "\(target) (pending; current: \(format(setting.current)))" + } +} diff --git a/ArcBox/Views/Runners/RunnerHostStatusBar.swift b/ArcBox/Views/Runners/RunnerHostStatusBar.swift new file mode 100644 index 00000000..38f6134d --- /dev/null +++ b/ArcBox/Views/Runners/RunnerHostStatusBar.swift @@ -0,0 +1,165 @@ +import FleetControlClient +import SwiftUI + +/// Persistent host header showing live Fleet Agent status, capabilities, telemetry, and controls. +struct RunnerHostStatusBar: View { + let host: RunnerHostViewModel + let isSelected: Bool + let isPerformingAction: Bool + let isReconnecting: Bool + var onSelect: () -> Void + var onSetDraining: (Bool) -> Void + var onUnenroll: () -> Void + @State private var isConfirmingUnenroll = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(action: onSelect) { + StatusBadge( + color: isReconnecting ? AppColors.warning : host.status.color, + label: isReconnecting ? "Reconnecting" : host.status.label + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Show details for this Mac") + Spacer() + Button( + host.isDraining ? "Resume" : "Drain", + systemImage: host.isDraining ? "play.fill" : "pause.fill", + action: toggleDrainState + ) + .controlSize(.small) + .disabled(isPerformingAction || !host.status.canChangeDrainState) + .help( + host.isDraining + ? "Resume accepting new jobs" + : "Finish running jobs but accept no new ones" + ) + Menu("Actions", systemImage: "ellipsis.circle") { + Button( + "Unenroll…", + systemImage: "trash", + role: .destructive, + action: { isConfirmingUnenroll = true } + ) + } + .menuStyle(.borderlessButton) + .controlSize(.small) + .disabled(isPerformingAction) + .confirmationDialog( + "Unenroll this Mac?", + isPresented: $isConfirmingUnenroll, + titleVisibility: .visible + ) { + Button("Unenroll", role: .destructive, action: onUnenroll) + Button("Cancel", role: .cancel) {} + } message: { + Text(unenrollMessage) + } + } + + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(host.machineID ?? "Machine ID pending") + .font(.caption.monospaced()) + .foregroundStyle(AppColors.textSecondary) + .lineLimit(1) + Text(agentDescription) + .font(.caption) + .foregroundStyle(AppColors.textMuted) + } + + if !host.capabilities.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(host.capabilities) { capability in + Label( + "\(capability.os)/\(capability.arch) · \(capability.backend.label)", + systemImage: capability.backend.systemImage + ) + .font(.caption) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(AppColors.surfaceElevated, in: Capsule()) + } + } + } + .scrollIndicators(.hidden) + } + + if let telemetry = host.telemetry { + HStack(spacing: 12) { + Label("\(telemetry.cpuCount) cores", systemImage: "cpu") + Label( + "\(telemetry.memoryAvailableMib.formatted()) MiB free", + systemImage: "memorychip" + ) + Text( + "Load \(telemetry.loadAverage1Minute, format: .number.precision(.fractionLength(2)))" + ) + } + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Show details for this Mac") + } + .padding(10) + .background(isSelected ? AppColors.selection.opacity(0.12) : AppColors.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + isSelected ? AppColors.selection : AppColors.border, + lineWidth: isSelected ? 2 : 0.5 + ) + ) + .padding(8) + } + + private var agentDescription: String { + if let agentVersion = host.agentVersion { + "\(host.chip) · Agent \(agentVersion)" + } else { + host.chip + } + } + + private var unenrollMessage: String { + let credentialMessage = + "This removes the Fleet credential from the local Agent. It does not stop or uninstall the Agent." + guard host.activeJobCount > 0 else { return credentialMessage } + let jobLabel = host.activeJobCount == 1 ? "job" : "jobs" + return "The Agent reports \(host.activeJobCount) active \(jobLabel). \(credentialMessage)" + } + + private func toggleDrainState() { + onSetDraining(!host.isDraining) + } +} + +extension FleetBackend { + fileprivate var label: String { + switch self { + case .hostRunner: "Host" + case .docker: "Docker" + case .vm: "VM" + case .unspecified, .unrecognized: "Unknown" + } + } + + fileprivate var systemImage: String { + switch self { + case .hostRunner: "desktopcomputer" + case .docker: "shippingbox" + case .vm: "macwindow" + case .unspecified, .unrecognized: "questionmark.circle" + } + } +} diff --git a/ArcBox/Views/Runners/RunnerImagePreparationStatusView.swift b/ArcBox/Views/Runners/RunnerImagePreparationStatusView.swift new file mode 100644 index 00000000..fc1a091d --- /dev/null +++ b/ArcBox/Views/Runners/RunnerImagePreparationStatusView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +/// Shared Fleet image preparation state rendered in the runner workflow. +struct RunnerImagePreparationStatusView: View { + let fleet: FleetViewModel + + var body: some View { + let readiness = fleet.runnerImageReadiness + + if readiness != .hidden { + HStack(alignment: .center, spacing: 10) { + Group { + switch readiness { + case .hidden: + EmptyView() + case .pending(let reference): + Label { + VStack(alignment: .leading, spacing: 2) { + Text("macOS runner image needs preparation") + Text(reference) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } icon: { + Image(systemName: "shippingbox") + } + case .preparing(let progress): + VStack(alignment: .leading, spacing: 6) { + Label("Preparing macOS runner image", systemImage: "shippingbox") + ProgressView(value: progress.fraction) + Text(progress.displayDescription) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + case .restartRequired: + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Fleet Agent restart required") + Text( + "The service manager must restart Fleet Agent before the VM backend becomes available. ArcBox will keep watching." + ) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } icon: { + Image(systemName: "arrow.clockwise.circle") + } + case .completed(let reference): + Label { + VStack(alignment: .leading, spacing: 2) { + Text("macOS runner image prepared") + Text(reference) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(AppColors.running) + } + case .failed(let message): + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Image preparation failed") + Text(message) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(AppColors.error) + } + } + } + + Spacer(minLength: 8) + + switch readiness { + case .pending: + Button( + "Prepare Image", + systemImage: "arrow.down.circle", + action: fleet.beginMacOSRunnerImagePreparation + ) + .controlSize(.small) + .disabled(!fleet.canBeginMacOSRunnerImagePreparation) + case .failed: + Button( + "Retry", + systemImage: "arrow.clockwise", + action: fleet.beginMacOSRunnerImagePreparation + ) + .controlSize(.small) + .disabled(!fleet.canBeginMacOSRunnerImagePreparation) + case .hidden, .preparing, .restartRequired, .completed: + EmptyView() + } + } + .padding(10) + .background(AppColors.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border, lineWidth: 0.5)) + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + } +} diff --git a/ArcBox/Views/Runners/RunnerJobDetailView.swift b/ArcBox/Views/Runners/RunnerJobDetailView.swift new file mode 100644 index 00000000..f2cc532c --- /dev/null +++ b/ArcBox/Views/Runners/RunnerJobDetailView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct RunnerJobDetailView: View { + let job: RunnerJobDetailModel + + @State private var activeTab: RunnerJobDetailTab = .info + + var body: some View { + Group { + switch activeTab { + case .info: + RunnerJobInfoTab(job: job) + case .logs: + RunnerJobLogsTab(job: job) + case .runtime: + RunnerJobRuntimeTab(job: job) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbar { + ToolbarItem(placement: .principal) { + Picker("Job detail", selection: $activeTab) { + ForEach(RunnerJobDetailTab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 300) + } + + if let githubURL = job.githubURL { + ToolbarItem { + Link(destination: githubURL) { + Label("Open in GitHub", systemImage: "arrow.up.forward.square") + } + .help("Open this job in GitHub Actions") + } + } + } + } +} diff --git a/ArcBox/Views/Runners/RunnerJobInfoTab.swift b/ArcBox/Views/Runners/RunnerJobInfoTab.swift new file mode 100644 index 00000000..cd8fbc4c --- /dev/null +++ b/ArcBox/Views/Runners/RunnerJobInfoTab.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct RunnerJobInfoTab: View { + let job: RunnerJobDetailModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + overviewSection + assignmentSection + timelineSection + labelsSection + } + .padding(16) + } + } + + private var overviewSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Job") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Status", value: job.status.displayName) + InfoRow(label: "Repository", value: job.repository ?? "Waiting for Platform") + InfoRow(label: "Job ID", value: job.id) + InfoRow(label: "Target", value: "\(job.os)/\(job.arch)") + InfoRow( + label: "GitHub run ID", + value: job.githubRunID?.formatted() ?? "Waiting for Platform", + link: job.githubURL + ) + InfoRow( + label: "GitHub job ID", + value: job.githubJobID?.formatted() ?? "Waiting for Platform", + link: job.githubURL + ) + } + .infoSectionStyle() + } + } + + private var assignmentSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Assignment") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Fleet machine ID", value: job.machineID ?? "Not reported") + InfoRow(label: "JIT runner", value: job.jitRunnerName ?? "Not reported") + InfoRow(label: "Runtime type", value: job.runtimeKind) + } + .infoSectionStyle() + } + } + + private var timelineSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Timeline") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Created", value: dateDescription(job.createdAt)) + InfoRow(label: "Started", value: dateDescription(job.startedAt)) + InfoRow(label: "Finished", value: dateDescription(job.finishedAt)) + } + .infoSectionStyle() + } + } + + @ViewBuilder + private var labelsSection: some View { + if job.labels.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Labels") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Runner labels", value: "None reported") + } + .infoSectionStyle() + } + } else { + InfoTableView( + title: "Labels", + columns: ["Runner label"], + items: job.labels.enumerated().map(RunnerJobLabel.init) + ) { label in + Text(label.value) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + } + + private func dateDescription(_ date: Date?) -> String { + date?.formatted(date: .abbreviated, time: .standard) ?? "Not reported" + } +} + +private struct RunnerJobLabel: Identifiable { + let id: Int + let value: String + + init(_ entry: EnumeratedSequence<[String]>.Element) { + id = entry.offset + value = entry.element + } +} diff --git a/ArcBox/Views/Runners/RunnerJobLogsTab.swift b/ArcBox/Views/Runners/RunnerJobLogsTab.swift new file mode 100644 index 00000000..d127fc97 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerJobLogsTab.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct RunnerJobLogsTab: View { + let job: RunnerJobDetailModel + + var body: some View { + ContentUnavailableView { + Label("Job logs unavailable", systemImage: "terminal") + } description: { + Text( + "Fleet Platform and the local Fleet Agent do not currently expose a job log stream to ArcBox Desktop." + ) + } actions: { + if let githubURL = job.githubURL { + Link(destination: githubURL) { + Label("View logs in GitHub", systemImage: "arrow.up.forward.square") + } + .buttonStyle(.borderedProminent) + } + } + } +} diff --git a/ArcBox/Views/Runners/RunnerJobRow.swift b/ArcBox/Views/Runners/RunnerJobRow.swift new file mode 100644 index 00000000..1f7d44e1 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerJobRow.swift @@ -0,0 +1,43 @@ +import FleetPlatformClient +import SwiftUI + +struct RunnerJobRow: View { + let job: RunnerJobListItem + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 10) { + Image( + systemName: ["darwin", "macos"].contains(job.os) ? "macwindow" : "shippingbox" + ) + .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.accent) + + VStack(alignment: .leading, spacing: 2) { + Text(job.repository ?? job.id) + .font(job.repository == nil ? .body.monospaced() : .body) + .lineLimit(1) + Text("\(job.os)/\(job.arch) · \(job.id)") + .font(.caption) + .foregroundStyle( + isSelected ? AppColors.onAccent.opacity(0.75) : AppColors.textSecondary + ) + .lineLimit(1) + } + + Spacer() + + StatusBadge(color: job.status.color, label: job.status.displayName) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.vertical, 4) + .listRowBackground(isSelected ? AppColors.selection : Color.clear) + .foregroundStyle(isSelected ? AppColors.onAccent : AppColors.text) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(job.repository ?? job.id), \(job.status.displayName)") + .accessibilityHint("Shows job details") + } +} diff --git a/ArcBox/Views/Runners/RunnerJobRuntimeTab.swift b/ArcBox/Views/Runners/RunnerJobRuntimeTab.swift new file mode 100644 index 00000000..aba69210 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerJobRuntimeTab.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct RunnerJobRuntimeTab: View { + let job: RunnerJobDetailModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + targetSection + runtimeSection + } + .padding(16) + } + } + + private var targetSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Execution target") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Operating system", value: job.os) + InfoRow(label: "Architecture", value: job.arch) + InfoRow(label: "Runtime type", value: job.runtimeKind) + InfoRow(label: "Fleet machine ID", value: job.machineID ?? "Not reported") + InfoRow(label: "JIT runner", value: job.jitRunnerName ?? "Not reported") + } + .infoSectionStyle() + } + } + + private var runtimeSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Runtime resource") + .font(.headline) + + VStack(spacing: 0) { + InfoRow(label: "Resource ID", value: "Not reported") + InfoRow(label: "ArcBox section", value: destinationSection) + } + .infoSectionStyle() + + Label(job.runtimeUnavailableDescription, systemImage: "info.circle") + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } + + private var destinationSection: String { + switch job.os { + case "darwin", "macos": + "Machines" + case "linux": + "Containers" + default: + "Unavailable" + } + } +} diff --git a/ArcBox/Views/Runners/RunnerJobsView.swift b/ArcBox/Views/Runners/RunnerJobsView.swift new file mode 100644 index 00000000..390d55a2 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerJobsView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct RunnerJobsView: View { + let jobs: [RunnerJobListItem] + let platformLoadState: RunnerPlatformLoadState + let selectedJobID: String? + let onSelect: (String) -> Void + + var body: some View { + if jobs.isEmpty { + emptyContent + } else { + VStack(spacing: 0) { + if case .failed(let message) = platformLoadState { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(AppColors.warning) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + List(jobs) { job in + RunnerJobRow( + job: job, + isSelected: selectedJobID == job.id, + onSelect: { onSelect(job.id) } + ) + } + .listStyle(.inset) + } + } + } + + @ViewBuilder + private var emptyContent: some View { + switch platformLoadState { + case .loading: + ProgressView("Loading job history…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + EmptyStateView(icon: "exclamationmark.triangle", title: "Could not load job history") { + Text(message) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + .multilineTextAlignment(.center) + } + case .machineNotFound: + EmptyStateView(icon: "clock.arrow.circlepath", title: "Waiting for job history") { + Text("This machine has not appeared in the Platform workspace yet.") + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + .multilineTextAlignment(.center) + } + case .idle, .loaded: + EmptyStateView(icon: "play.square.stack", title: "No jobs") { + Text("Workflow jobs dispatched to this Mac will appear here.") + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + } + } + } +} diff --git a/ArcBox/Views/Runners/RunnerSelectionDetailView.swift b/ArcBox/Views/Runners/RunnerSelectionDetailView.swift new file mode 100644 index 00000000..368e70c7 --- /dev/null +++ b/ArcBox/Views/Runners/RunnerSelectionDetailView.swift @@ -0,0 +1,54 @@ +import FleetControlClient +import FleetPlatformClient +import SwiftUI + +struct RunnerSelectionDetailView: View { + @Environment(RunnerPlatformStore.self) private var store + @Environment(RunnersViewModel.self) private var runners + + var body: some View { + switch store.selection { + case .host: + hostDetail + case .job(let jobID): + if let job = selectedJob(id: jobID) { + RunnerJobDetailView(job: job) + } else { + ContentUnavailableView { + Label("Job unavailable", systemImage: "hammer") + } description: { + Text("This job is no longer reported by Platform or the local Fleet Agent.") + } + } + case nil: + ContentUnavailableView("No Selection", systemImage: "square.dashed") + } + } + + @ViewBuilder + private var hostDetail: some View { + if case .enrolled(let host, _) = runners.viewState { + RunnerHostDetailView(host: host) + } else { + ContentUnavailableView { + Label("Host unavailable", systemImage: "desktopcomputer.trianglebadge.exclamationmark") + } description: { + Text("ArcBox is waiting for a conclusive Fleet Agent state.") + } + } + } + + private func selectedJob(id: String) -> RunnerJobDetailModel? { + let liveJobs: [FleetInFlightJob] = + if case .enrolled(let host, _) = runners.viewState { + host.inFlightJobs + } else { + [] + } + return RunnerJobDetailModel.resolve( + id: id, + platformJobs: store.jobs, + liveJobs: liveJobs + ) + } +} diff --git a/ArcBox/Views/Runners/RunnersView.swift b/ArcBox/Views/Runners/RunnersView.swift new file mode 100644 index 00000000..c8814da5 --- /dev/null +++ b/ArcBox/Views/Runners/RunnersView.swift @@ -0,0 +1,338 @@ +import ArcBoxAuth +import FleetPlatformClient +import SwiftUI + +/// This Mac as a Fleet runner host, backed by the local Agent watch stream. +struct RunnersView: View { + @Environment(AuthSession.self) private var authSession + @Environment(RunnersViewModel.self) private var vm + @Environment(RunnerPlatformStore.self) private var platformStore + @Environment(\.fleetPlatformClient) private var platformClient + @State private var isShowingWorkspaceDialog = false + @State private var isShowingEnrollmentResetConfirmation = false + + var body: some View { + Group { + switch vm.viewState { + case .connecting: + ProgressView("Connecting to Fleet Agent…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .unavailable(let message): + EmptyStateView(icon: "exclamationmark.triangle", title: "Fleet Agent unavailable") { + VStack(spacing: 6) { + Text(message) + Text( + "Start or repair the Fleet Agent through its service manager. ArcBox will reconnect automatically." + ) + } + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + .multilineTextAlignment(.center) + } + case .signedOut: + signedOutView + case .unenrolled: + onboardingView(errorMessage: vm.errorMessage) + case .enrolling(let progress): + enrollmentProgressView(progress) + case .enrollmentFailed(let message, let recovery): + switch recovery { + case .retry: + onboardingView(errorMessage: message, actionTitle: "Try Again") + case .waitForAgent: + enrollmentBlockedView(message: message, recovery: recovery) + case .unenroll: + enrollmentBlockedView(message: message, recovery: recovery) + } + case .failed(let message): + EmptyStateView(icon: "exclamationmark.octagon", title: "Fleet integration unavailable") { + VStack(spacing: 6) { + Text(message) + Text("Check the ArcBox configuration and Fleet Agent compatibility.") + } + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + .multilineTextAlignment(.center) + } + case .enrolled(let host, let freshness): + let jobItems = RunnerJobListItem.merge( + platformJobs: platformStore.jobs, + liveJobs: host.inFlightJobs + ) + VStack(spacing: 0) { + RunnerHostStatusBar( + host: host, + isSelected: platformStore.selection == .host, + isPerformingAction: vm.isBusy || freshness.isReconnecting, + isReconnecting: freshness.isReconnecting, + onSelect: platformStore.selectHost, + onSetDraining: setDraining, + onUnenroll: unenroll + ) + if let statusMessage = hostStatusMessage(freshness: freshness) { + Label(statusMessage, systemImage: "arrow.triangle.2.circlepath") + .font(.caption) + .foregroundStyle(AppColors.warning) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } else if let errorMessage = vm.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(AppColors.warning) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + if let recoveryMessage = host.status.recoveryMessage { + Label(recoveryMessage, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(AppColors.warning) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + RunnerImagePreparationStatusView(fleet: vm.fleet) + RunnerJobsView( + jobs: jobItems, + platformLoadState: platformStore.loadState, + selectedJobID: platformStore.selectedJobID, + onSelect: platformStore.selectJob + ) + .onChange(of: jobItems.map(\.id), initial: true) { _, jobIDs in + platformStore.reconcileSelection(validJobIDs: Set(jobIDs)) + } + } + } + } + .background(AppColors.background) + .navigationTitle("This Mac") + .navigationSubtitle(vm.subtitle) + .task(id: platformRefreshContext) { + guard + let platformClient, + let machineID = platformRefreshContext.machineID + else { + platformStore.reset() + return + } + + await platformStore.observe(client: platformClient, machineID: machineID) + } + } + + private var platformRefreshContext: RunnerPlatformRefreshContext { + guard case .enrolled(let host, _) = vm.viewState else { + return RunnerPlatformRefreshContext(machineID: nil, hasClient: platformClient != nil) + } + return RunnerPlatformRefreshContext( + machineID: host.machineID, + hasClient: platformClient != nil + ) + } + + private var signedOutView: some View { + EmptyStateView(icon: "person.crop.circle.badge.exclamationmark", title: "Connect this Mac to Fleet") { + VStack(spacing: 12) { + Text("Sign in to choose an ArcBox workspace.") + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + .multilineTextAlignment(.center) + + Button(action: signIn) { + HStack { + if authSession.status == .signingIn { + ProgressView() + .controlSize(.small) + } + Text(authSession.status == .signingIn ? "Signing In…" : "Sign In") + .frame(maxWidth: .infinity) + } + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + .disabled( + authSession.status == .signingIn + || authSession.configuration.isPlaceholder + ) + .accessibilityLabel( + authSession.status == .signingIn ? "Signing in to ArcBox" : "Sign in to ArcBox" + ) + + if authSession.status == .signingIn, + let prompt = authSession.deviceAuthorization + { + Text("Confirm code \(prompt.userCode) in your browser.") + .font(.caption.monospacedDigit()) + .foregroundStyle(AppColors.textSecondary) + } + + if let authMessage { + Label(authMessage, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(AppColors.warning) + } + } + } + } + + private var authMessage: String? { + if authSession.configuration.isPlaceholder { + return "No authentication provider is configured for this build." + } + if case .error(let message) = authSession.status { + return message + } + return nil + } + + private func enrollmentProgressView(_ progress: RunnerEnrollmentProgress) -> some View { + EmptyStateView(icon: "arrow.triangle.2.circlepath", title: progress.title) { + VStack(spacing: 12) { + ProgressView() + .controlSize(.small) + Text(progress.message) + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + .multilineTextAlignment(.center) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(progress.title). \(progress.message)") + } + } + + private func onboardingView( + errorMessage: String?, + actionTitle: String = "Connect to ArcBox" + ) -> some View { + RunnerEmptyState( + isWorking: vm.isBusy, + canConnect: vm.canConnect, + errorMessage: errorMessage, + actionTitle: actionTitle, + onConnect: prepareEnrollment + ) + .confirmationDialog( + "Connect this Mac to a workspace", + isPresented: $isShowingWorkspaceDialog, + titleVisibility: .visible + ) { + ForEach(vm.workspaces) { workspace in + Button(workspaceButtonTitle(workspace), action: { enroll(in: workspace) }) + .disabled(vm.isBusy) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("ArcBox will issue a short-lived enrollment token and enroll the local Fleet Agent.") + } + } + + private func enrollmentBlockedView( + message: String, + recovery: RunnerEnrollmentRecovery + ) -> some View { + EmptyStateView( + icon: recovery == .waitForAgent ? "arrow.triangle.2.circlepath" : "exclamationmark.triangle", + title: recovery == .waitForAgent ? "Confirming enrollment" : "Enrollment needs attention" + ) { + VStack(spacing: 12) { + VStack(spacing: 6) { + Text(message) + if recovery == .waitForAgent { + Text( + "ArcBox will keep observing the local Fleet Agent. A new enrollment will not start until the Agent reports a conclusive state." + ) + } else { + Text("Unenroll the local Agent before starting another enrollment attempt.") + } + } + .font(.caption) + .foregroundStyle(AppColors.textSecondary) + .multilineTextAlignment(.center) + .accessibilityElement(children: .combine) + + if recovery == .waitForAgent { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Waiting for Fleet Agent enrollment state") + } else { + Button("Unenroll and Start Over…", role: .destructive) { + isShowingEnrollmentResetConfirmation = true + } + .buttonStyle(.bordered) + .disabled(vm.isBusy) + .accessibilityHint("Removes the local Agent enrollment so this Mac can enroll again") + .confirmationDialog( + "Unenroll this Mac and start over?", + isPresented: $isShowingEnrollmentResetConfirmation, + titleVisibility: .visible + ) { + Button("Unenroll", role: .destructive, action: unenroll) + Button("Cancel", role: .cancel) {} + } message: { + Text( + "This removes the local Fleet Agent's enrollment credentials and allows a new " + + "enrollment attempt. It does not stop or uninstall the Agent." + ) + } + } + + if let actionError = vm.errorMessage, actionError != message { + Label(actionError, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(AppColors.warning) + } + } + } + } + + private func prepareEnrollment() { + Task { + if await vm.prepareEnrollment() { + isShowingWorkspaceDialog = true + } + } + } + + private func enroll(in workspace: FleetWorkspace) { + Task { + await vm.enroll(in: workspace) + } + } + + private func setDraining(_ draining: Bool) { + Task { + await vm.setDraining(draining) + } + } + + private func unenroll() { + Task { + await vm.unenroll() + } + } + + private func signIn() { + Task { + await authSession.signIn() + } + } + + private func hostStatusMessage(freshness: RunnerHostFreshness) -> String? { + guard case .reconnecting(let message) = freshness else { return nil } + return "Live Fleet Agent updates are paused. \(message) ArcBox is reconnecting." + } + + private func workspaceButtonTitle(_ workspace: FleetWorkspace) -> String { + "\(workspace.name) · \(workspace.plan)" + } +} + +private struct RunnerPlatformRefreshContext: Equatable { + let machineID: String? + let hasClient: Bool +} + +extension RunnerHostFreshness { + fileprivate var isReconnecting: Bool { + if case .reconnecting = self { return true } + return false + } +} diff --git a/ArcBox/Views/Settings/AccountSettingsView.swift b/ArcBox/Views/Settings/AccountSettingsView.swift index 6b22f08c..460df6f5 100644 --- a/ArcBox/Views/Settings/AccountSettingsView.swift +++ b/ArcBox/Views/Settings/AccountSettingsView.swift @@ -1,3 +1,4 @@ +import AppKit import ArcBoxAuth import SwiftUI @@ -11,14 +12,16 @@ struct AccountSettingsView: View { switch authSession.status { case .signedIn: signedInSections + case .restoring: + restoringSection case .signedOut, .signingIn, .error: signedOutSection } } .formStyle(.grouped) - // Sign-in fetches userinfo itself; this covers sessions restored + // Sign-in verifies the session itself; this covers sessions restored // from the Keychain at launch. - .task { await authSession.loadUserInfo() } + .task { await authSession.refreshSession() } } // MARK: - Signed in @@ -35,6 +38,11 @@ struct AccountSettingsView: View { .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) + // Button("Copy User ID", systemImage: "doc.on.doc", action: copyUserID) + // .labelStyle(.iconOnly) + // .buttonStyle(.borderless) + // .controlSize(.small) + // .help("Copy User ID") } } LabeledContent("Provider", value: authSession.configuration.environmentLabel) @@ -55,6 +63,21 @@ struct AccountSettingsView: View { // MARK: - Signed out / signing in + private var restoringSection: some View { + Section { + ContentUnavailableView { + Label("Restoring Session", systemImage: "person.crop.circle.badge.clock") + } description: { + Text("Checking for a saved ArcBox sign-in.") + } actions: { + ProgressView() + .controlSize(.small) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + } + } + private var signedOutSection: some View { Section { ContentUnavailableView { @@ -62,7 +85,7 @@ struct AccountSettingsView: View { } description: { if authSession.configuration.isPlaceholder { Text( - "No OIDC provider is configured for this build. See Local.xcconfig.example." + "No sign-in service is configured for this build. See Local.xcconfig.example." ) } else { Text("Sign in to your ArcBox account to use platform features.") @@ -73,12 +96,7 @@ struct AccountSettingsView: View { } } actions: { if authSession.status == .signingIn { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Waiting for the browser…") - .foregroundStyle(.secondary) - } + signingInPrompt } else { Button("Sign In to ArcBox…", action: signIn) .buttonStyle(.borderedProminent) @@ -90,17 +108,55 @@ struct AccountSettingsView: View { } } + /// Browser-approval progress: the confirmation code to match in the + /// browser, a way to reopen the page, and an escape hatch. + @ViewBuilder + private var signingInPrompt: some View { + VStack(spacing: 10) { + if let prompt = authSession.deviceAuthorization { + Text(prompt.userCode) + .font(.title2.monospaced().bold()) + .textSelection(.enabled) + .accessibilityLabel("Sign-in confirmation code") + Text("Confirm this code in your browser to finish signing in.") + .foregroundStyle(.secondary) + HStack(spacing: 12) { + Button("Open Browser") { + NSWorkspace.shared.open(prompt.browserURL) + } + Button("Cancel", role: .cancel) { + authSession.cancelSignIn() + } + } + } else { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Contacting the sign-in service…") + .foregroundStyle(.secondary) + } + Button("Cancel", role: .cancel) { + authSession.cancelSignIn() + } + } + } + } + // MARK: - Actions private func signIn() { - Task { - await authSession.signIn(using: WebAuthenticationController.shared.authenticate) - } + Task { await authSession.signIn() } } private func signOut() { Task { await authSession.signOut() } } + + private func copyUserID() { + guard let subject = authSession.identity?.subject else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(subject, forType: .string) + } } /// Hero header for the signed-in account — avatar, display name, and email @@ -133,3 +189,8 @@ private struct AccountIdentityHeader: View { .listRowBackground(Color.clear) } } + +#Preview { + AccountSettingsView() + .environment(AuthSession()) +} diff --git a/ArcBox/Views/Settings/FleetSettingsView.swift b/ArcBox/Views/Settings/FleetSettingsView.swift new file mode 100644 index 00000000..b0238082 --- /dev/null +++ b/ArcBox/Views/Settings/FleetSettingsView.swift @@ -0,0 +1,323 @@ +import FleetControlClient +import SwiftUI + +private enum FleetVMModeChoice: String, CaseIterable, Identifiable { + case automatic = "Automatic" + case enabled = "Enabled" + case disabled = "Disabled" + + var id: String { rawValue } + + var value: FleetVmMode { + switch self { + case .automatic: .auto + case .enabled: .enabled + case .disabled: .disabled + } + } + + init?(_ value: FleetVmMode) { + switch value { + case .auto: self = .automatic + case .enabled: self = .enabled + case .disabled: self = .disabled + case .unspecified, .unrecognized: return nil + } + } +} + +private struct FleetVMSettingsDraft: Equatable { + var mode: FleetVMModeChoice? + var image = "" + + init(settings: FleetAgentSettings? = nil) { + mode = settings?.vmMode.flatMap { FleetVMModeChoice($0.target) } + image = settings?.macosRunnerImage?.target ?? "" + } +} + +struct FleetSettingsView: View { + @Environment(FleetViewModel.self) private var fleet + + @State private var draft = FleetVMSettingsDraft() + + var body: some View { + Form { + agentSection + vmSettingsContent + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .onAppear { + syncDraft(from: fleet.settings) + } + .onChange(of: fleet.settings) { oldSettings, newSettings in + guard draft == FleetVMSettingsDraft(settings: oldSettings) else { return } + syncDraft(from: newSettings) + } + } + + private var agentSection: some View { + Section("Fleet Agent") { + LabeledContent("Version", value: fleet.agentInfo?.agentVersion ?? "Not connected") + LabeledContent("VM backend") { + Label( + fleet.isVMBackendActive ? "Active" : "Inactive", + systemImage: fleet.isVMBackendActive ? "checkmark.circle.fill" : "circle" + ) + .foregroundStyle(fleet.isVMBackendActive ? .green : .secondary) + } + } + } + + @ViewBuilder + private var vmSettingsContent: some View { + switch fleet.vmSettingsAvailability { + case .loading: + Section { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading Fleet VM settings…") + .foregroundStyle(.secondary) + } + } + case .unavailable(let message): + messageSection( + title: "Fleet Agent Unavailable", + message: message, + systemImage: "exclamationmark.triangle" + ) + case .unsupported: + messageSection( + title: "Fleet Agent Update Required", + message: "The connected Fleet Agent does not support VM settings.", + systemImage: "arrow.down.circle" + ) + case .missingSettings: + messageSection( + title: "VM Settings Not Reported", + message: "The Fleet Agent advertised VM settings but did not return their values.", + systemImage: "exclamationmark.triangle" + ) + case .available: + configurationSection + imagePreparationSection + } + } + + private var configurationSection: some View { + Section { + if let vmMode = fleet.settings?.vmMode { + LabeledContent { + if draft.mode != nil { + Picker("VM isolation", selection: modeBinding) { + ForEach(FleetVMModeChoice.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .labelsHidden() + .fixedSize() + } else { + Text(Self.label(for: vmMode.target)) + .foregroundStyle(.secondary) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("VM isolation") + Text("Current: \(Self.label(for: vmMode.current))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if let image = fleet.settings?.macosRunnerImage { + LabeledContent { + TextField("Image reference", text: $draft.image) + .textFieldStyle(.roundedBorder) + .frame(width: 260) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("macOS runner image") + Text("Current: \(Self.currentImageLabel(image.current))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + HStack { + Spacer() + Button("Save") { + saveSettings() + } + .disabled(!canSave) + } + + if let lastError = fleet.lastError { + Label(lastError, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + } + + if fleet.requiresAgentRestartForVM { + Label { + Text( + "The daemon-managed Fleet Agent must restart before this VM configuration becomes active. ArcBox Desktop will not restart it." + ) + } icon: { + Image(systemName: "arrow.clockwise.circle") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("macOS Runner VMs") + } footer: { + Text( + "Fleet Agent provisions disposable runner VMs through arcbox-daemon. Desktop only updates settings and communicates with the Agent." + ) + } + } + + private var imagePreparationSection: some View { + Section("Image Preparation") { + if fleet.settings?.macosRunnerImage?.isPending == true { + Label("The target image must be prepared before it can run jobs.", systemImage: "shippingbox") + .foregroundStyle(.secondary) + } + + preparationStatus + + HStack { + Spacer() + Button("Prepare Image") { + fleet.beginMacOSRunnerImagePreparation() + } + .disabled(!fleet.canBeginMacOSRunnerImagePreparation) + } + + if !fleet.supportsMacOSImagePreparation { + Text("The connected Fleet Agent cannot prepare macOS runner images.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var preparationStatus: some View { + switch fleet.imagePreparationState { + case .idle: + EmptyView() + case .preparing(let progress): + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: progress.fraction) + Text(progress.displayDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + case .completed(let reference): + Label("Runner image \(reference) prepared.", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .failed(let message): + Label(message, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + + private var modeBinding: Binding { + Binding( + get: { draft.mode ?? .automatic }, + set: { draft.mode = $0 } + ) + } + + private var canSave: Bool { + guard fleet.vmSettingsAvailability == .available, + !fleet.isPerformingAction, + !fleet.imagePreparationState.isPreparing, + !draft.image.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return false + } + return draft != FleetVMSettingsDraft(settings: fleet.settings) + } + + private func saveSettings() { + guard let settings = fleet.settings else { return } + + let image = draft.image.trimmingCharacters(in: .whitespacesAndNewlines) + let imageUpdate = image == settings.macosRunnerImage?.target ? nil : image + let modeValue = draft.mode?.value + let modeUpdate = modeValue == settings.vmMode?.target ? nil : modeValue + + Task { + let saved = await fleet.updateSettings( + macosRunnerImage: imageUpdate, + vmMode: modeUpdate + ) + if saved { + syncDraft(from: fleet.settings) + } + } + } + + private func syncDraft(from settings: FleetAgentSettings?) { + draft = FleetVMSettingsDraft(settings: settings) + } + + @ViewBuilder + private func messageSection( + title: String, + message: String, + systemImage: String + ) -> some View { + Section { + Label { + VStack(alignment: .leading, spacing: 4) { + Text(title) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: systemImage) + } + } + } + + private static func label(for mode: FleetVmMode) -> String { + switch mode { + case .auto: "Automatic" + case .enabled: "Enabled" + case .disabled: "Disabled" + case .unspecified: "Unspecified" + case .unrecognized(let value): "Unknown (\(value))" + } + } + + private static func currentImageLabel(_ reference: String) -> String { + reference.isEmpty ? "Not prepared" : reference + } + +} + +#Preview { + let fleet = FleetViewModel() + fleet.loadState = .ready + fleet.agentInfo = FleetAgentInfo( + agentVersion: "0.5.0", + apiVersion: 1, + features: ["vm-settings", "macos-image-prepare"] + ) + fleet.settings = FleetAgentSettings( + macosRunnerImage: FleetSetting(current: "tahoe-base", target: "tahoe-base"), + vmMode: FleetSetting(current: .auto, target: .auto) + ) + + return FleetSettingsView() + .environment(fleet) + .frame(width: 520, height: 580) +} diff --git a/ArcBox/Views/Settings/SettingsView.swift b/ArcBox/Views/Settings/SettingsView.swift index 24e9357e..0e7fc58b 100644 --- a/ArcBox/Views/Settings/SettingsView.swift +++ b/ArcBox/Views/Settings/SettingsView.swift @@ -34,6 +34,8 @@ struct SettingsView: View { AccountSettingsView() case .system: SystemSettingsView() + case .fleet: + FleetSettingsView() case .storage: StorageSettingsView() case nil: diff --git a/ArcBox/Views/SidebarAccountButton.swift b/ArcBox/Views/SidebarAccountButton.swift index 9b646eb8..fbcfa87d 100644 --- a/ArcBox/Views/SidebarAccountButton.swift +++ b/ArcBox/Views/SidebarAccountButton.swift @@ -11,7 +11,13 @@ struct SidebarAccountButton: View { var body: some View { Button(action: action) { HStack(spacing: 8) { - if authSession.status == .signingIn { + if authSession.status == .restoring { + ProgressView() + .controlSize(.small) + .frame(width: 24, height: 24) + Text("Restoring…") + .foregroundStyle(.secondary) + } else if authSession.status == .signingIn { ProgressView() .controlSize(.small) .frame(width: 24, height: 24) @@ -50,12 +56,15 @@ struct SidebarAccountButton: View { } private var isDisabled: Bool { - authSession.status == .signingIn + authSession.status == .restoring + || authSession.status == .signingIn || (authSession.status != .signedIn && authSession.configuration.isPlaceholder) } private var helpText: String { switch authSession.status { + case .restoring: + "Restoring ArcBox session" case .signedIn: "Open account settings" case .signingIn: @@ -64,7 +73,7 @@ struct SidebarAccountButton: View { "Sign-in failed: \(message)" case .signedOut: authSession.configuration.isPlaceholder - ? "No OIDC provider is configured" + ? "No sign-in service is configured" : "Sign in to ArcBox" } } diff --git a/ArcBoxTests/FleetEnrollmentCoordinatorTests.swift b/ArcBoxTests/FleetEnrollmentCoordinatorTests.swift new file mode 100644 index 00000000..1016e6a7 --- /dev/null +++ b/ArcBoxTests/FleetEnrollmentCoordinatorTests.swift @@ -0,0 +1,875 @@ +import FleetControlClient +import FleetPlatformClient +import XCTest + +@testable import ArcBox + +@MainActor +final class FleetEnrollmentCoordinatorTests: XCTestCase { + func testHappyPathCreatesOneTokenBeforeLocalEnrollAndWaitsForAttached() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .success("fltm_123"), + snapshots: [ + snapshot(.updating, machineID: "fltm_123"), + snapshot(.attaching, machineID: "fltm_123"), + snapshot(.attached, machineID: "fltm_123"), + ] + ) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertTrue(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_123")) + XCTAssertEqual(calls.entries, ["ensureReady", "watch", "token", "enroll"]) + XCTAssertEqual(tokenIssuer.requestedWorkspaceIDs, ["ws_123"]) + XCTAssertEqual(agent.receivedTokens, ["flet_test_secret"]) + } + + func testEnrollmentWaitsForInitialWatchSnapshotBeforeRequestingToken() async { + let calls = CallRecorder() + let baselineGate = AsyncGate() + let agent = AgentStub( + calls: calls, + snapshots: [snapshot(.attached, machineID: "fltm_123")], + baselineGate: baselineGate + ) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + let enrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ calls.entries == ["ensureReady", "watch"] }) else { + XCTFail("Enrollment did not open the Agent state stream.") + await baselineGate.open() + _ = await enrollment.value + return + } + + XCTAssertTrue(tokenIssuer.requestedWorkspaceIDs.isEmpty) + await baselineGate.open() + let succeeded = await enrollment.value + + XCTAssertTrue(succeeded) + XCTAssertEqual(calls.entries, ["ensureReady", "watch", "token", "enroll"]) + } + + func testSignedOutRequiresSignInWithoutCallingDependencies() async { + let calls = CallRecorder() + let agent = AgentStub(calls: calls) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer, + isSignedIn: false + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual(coordinator.state, .requiresSignIn) + XCTAssertTrue(calls.entries.isEmpty) + } + + func testManualTokenEnrollmentWorksSignedOutWithoutPlatformTokenIssuer() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .success("fltm_manual"), + snapshots: [snapshot(.attached, machineID: "fltm_manual")] + ) + let coordinator = FleetEnrollmentCoordinator( + authentication: AuthenticationStub(isSignedIn: false), + agentReadiness: AgentReadinessStub(calls: calls, agent: agent), + tokenIssuer: nil, + sleeper: waitForCancellation + ) + + let succeeded = await coordinator.enroll(token: " flet_manual_secret\n") + + XCTAssertTrue(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_manual")) + XCTAssertEqual(calls.entries, ["ensureReady", "watch", "enroll"]) + XCTAssertEqual(agent.receivedTokens, ["flet_manual_secret"]) + } + + func testManualTokenEnrollmentRejectsEmptyInputBeforeCallingAgent() async { + let calls = CallRecorder() + let agent = AgentStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + isSignedIn: false + ) + + let succeeded = await coordinator.enroll(token: " \n ") + + XCTAssertFalse(succeeded) + XCTAssertEqual(coordinator.state, .failed(.enrollmentTokenRequired)) + XCTAssertTrue(calls.entries.isEmpty) + XCTAssertTrue(agent.receivedTokens.isEmpty) + } + + func testTokenFailureDoesNotCallAgent() async { + let calls = CallRecorder() + let agent = AgentStub(calls: calls) + let tokenIssuer = TokenIssuerStub(calls: calls, result: .failure(.expected)) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual( + coordinator.state, + .failed(.enrollmentTokenRequestFailed(message: "Expected test failure.")) + ) + XCTAssertEqual(calls.entries, ["ensureReady", "watch", "token"]) + XCTAssertTrue(agent.receivedTokens.isEmpty) + } + + func testReadinessFailureStopsBeforeTokenRequest() async { + let calls = CallRecorder() + let agent = AgentStub(calls: calls) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer, + readinessFailure: .expected + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual( + coordinator.state, + .failed(.agentPreparationFailed(message: "Expected test failure.")) + ) + XCTAssertEqual(calls.entries, ["ensureReady"]) + XCTAssertTrue(tokenIssuer.requestedWorkspaceIDs.isEmpty) + XCTAssertTrue(agent.receivedTokens.isEmpty) + } + + func testLocalEnrollFailureIsUnknownAndDoesNotRetainToken() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .failure(.expected) + ) + let coordinator = makeCoordinator(calls: calls, agent: agent) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual(coordinator.state, .failed(.enrollmentOutcomeUnknown)) + XCTAssertEqual(calls.entries, ["ensureReady", "watch", "token", "enroll"]) + XCTAssertFalse(String(describing: coordinator.state).contains("flet_test_secret")) + + coordinator.reconcile(snapshot(.unenrolled, machineID: nil)) + XCTAssertEqual(coordinator.state, .failed(.enrollmentOutcomeUnknown)) + XCTAssertFalse(coordinator.canBeginEnrollment) + + coordinator.reconcile(snapshot(.attached, machineID: "fltm_reconciled")) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_reconciled")) + + let retrySucceeded = await coordinator.enroll(workspaceID: "ws_retry") + + XCTAssertFalse(retrySucceeded) + XCTAssertEqual(tokenIssuerCallCount(in: calls.entries), 1) + XCTAssertEqual(agent.receivedTokens.count, 1) + XCTAssertFalse(coordinator.canBeginEnrollment) + } + + func testAlreadyEnrolledBaselineDoesNotRequestToken() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + baseline: snapshot(.attached, machineID: "fltm_existing") + ) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_existing")) + XCTAssertEqual(calls.entries, ["ensureReady", "watch"]) + XCTAssertTrue(tokenIssuer.requestedWorkspaceIDs.isEmpty) + XCTAssertTrue(agent.receivedTokens.isEmpty) + XCTAssertFalse(coordinator.canBeginEnrollment) + + coordinator.reconcile(snapshot(.unenrolled, machineID: nil)) + let retrySucceeded = await coordinator.enroll(workspaceID: "ws_retry") + + XCTAssertFalse(retrySucceeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_existing")) + XCTAssertTrue(tokenIssuer.requestedWorkspaceIDs.isEmpty) + + coordinator.confirmUnenrolled() + + XCTAssertEqual(coordinator.state, .idle) + XCTAssertFalse(coordinator.canBeginEnrollment) + + coordinator.reconcile(snapshot(.attached, machineID: "fltm_existing")) + + XCTAssertEqual(coordinator.state, .idle) + XCTAssertFalse(coordinator.canBeginEnrollment) + + coordinator.reconcile(snapshot(.unenrolled, machineID: nil)) + + XCTAssertTrue(coordinator.canBeginEnrollment) + } + + func testLocalEnrollErrorReconcilesAttachedSnapshotWithoutRetry() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .failure(.expected), + snapshots: [snapshot(.attached, machineID: "fltm_reconciled")] + ) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertTrue(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_reconciled")) + XCTAssertEqual(tokenIssuer.requestedWorkspaceIDs.count, 1) + XCTAssertEqual(agent.receivedTokens.count, 1) + XCTAssertFalse(coordinator.canBeginEnrollment) + + let retrySucceeded = await coordinator.enroll(workspaceID: "ws_retry") + + XCTAssertFalse(retrySucceeded) + XCTAssertEqual(tokenIssuer.requestedWorkspaceIDs.count, 1) + XCTAssertFalse(coordinator.canBeginEnrollment) + } + + func testUpdatingBaselineDoesNotRequestTokenAndConvergesThroughReconciliation() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + baseline: snapshot(.updating, machineID: "fltm_existing") + ) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual(coordinator.state, .attaching(machineID: "fltm_existing")) + XCTAssertEqual(calls.entries, ["ensureReady", "watch"]) + XCTAssertTrue(tokenIssuer.requestedWorkspaceIDs.isEmpty) + XCTAssertTrue(agent.receivedTokens.isEmpty) + XCTAssertFalse(coordinator.canBeginEnrollment) + + coordinator.reconcile(snapshot(.attached, machineID: "fltm_existing")) + + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_existing")) + } + + func testGlobalAttachedSnapshotDuringActiveAttemptIsAppliedAfterInternalUnknown() async { + let calls = CallRecorder() + let enrollGate = AsyncGate() + let agent = AgentStub( + calls: calls, + streamCompletion: .stayOpen, + enrollOperation: { + await enrollGate.wait() + throw TestFailure.expected + } + ) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer, + sleeper: { _ in } + ) + let enrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ agent.receivedTokens.count == 1 }) else { + XCTFail("Enrollment did not hand the token to the Agent.") + await enrollGate.open() + _ = await enrollment.value + return + } + + coordinator.reconcile(snapshot(.attached, machineID: "fltm_global")) + XCTAssertEqual(coordinator.state, .enrolling) + await enrollGate.open() + let succeeded = await enrollment.value + + XCTAssertTrue(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_global")) + XCTAssertFalse(coordinator.canBeginEnrollment) + XCTAssertEqual(tokenIssuer.requestedWorkspaceIDs.count, 1) + } + + func testObservedEnrollmentBeforeHandoffPreventsDuplicateLocalEnroll() async { + let calls = CallRecorder() + let tokenGate = AsyncGate() + let agent = AgentStub(calls: calls) + let tokenIssuer = TokenIssuerStub( + calls: calls, + operation: { await tokenGate.wait() } + ) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + let enrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ tokenIssuer.requestedWorkspaceIDs.count == 1 }) else { + XCTFail("Enrollment did not request its token.") + await tokenGate.open() + _ = await enrollment.value + return + } + + coordinator.reconcile(snapshot(.attached, machineID: "fltm_external")) + await tokenGate.open() + let succeeded = await enrollment.value + + XCTAssertTrue(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_external")) + XCTAssertTrue(agent.receivedTokens.isEmpty) + XCTAssertFalse(coordinator.canBeginEnrollment) + } + + func testQueuedAttachedSnapshotDoesNotOverwriteNewerInternalTerminalState() async { + let calls = CallRecorder() + let enrollGate = AsyncGate() + let agent = AgentStub( + calls: calls, + snapshots: [snapshot(.credentialRejected, machineID: "fltm_123")], + enrollOperation: { await enrollGate.wait() } + ) + let coordinator = makeCoordinator(calls: calls, agent: agent) + let enrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ agent.receivedTokens.count == 1 }) else { + XCTFail("Enrollment did not hand the token to the Agent.") + await enrollGate.open() + _ = await enrollment.value + return + } + + coordinator.reconcile(snapshot(.attached, machineID: "fltm_123")) + await enrollGate.open() + let succeeded = await enrollment.value + + XCTAssertFalse(succeeded) + XCTAssertEqual( + coordinator.state, + .failed(.credentialRejected(machineID: "fltm_123")) + ) + XCTAssertFalse(coordinator.canBeginEnrollment) + } + + func testQueuedTerminalSnapshotOverridesInternalReadyState() async { + let calls = CallRecorder() + let enrollGate = AsyncGate() + let agent = AgentStub( + calls: calls, + snapshots: [snapshot(.attached, machineID: "fltm_123")], + enrollOperation: { await enrollGate.wait() } + ) + let coordinator = makeCoordinator(calls: calls, agent: agent) + let enrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ agent.receivedTokens.count == 1 }) else { + XCTFail("Enrollment did not hand the token to the Agent.") + await enrollGate.open() + _ = await enrollment.value + return + } + + coordinator.reconcile(snapshot(.credentialRejected, machineID: "fltm_123")) + await enrollGate.open() + let succeeded = await enrollment.value + + XCTAssertFalse(succeeded) + XCTAssertEqual( + coordinator.state, + .failed(.credentialRejected(machineID: "fltm_123")) + ) + XCTAssertFalse(coordinator.canBeginEnrollment) + } + + func testCredentialRejectedIsTerminalFailure() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .success("fltm_rejected"), + snapshots: [snapshot(.credentialRejected, machineID: "fltm_rejected")] + ) + let tokenIssuer = TokenIssuerStub(calls: calls) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual( + coordinator.state, + .failed(.credentialRejected(machineID: "fltm_rejected")) + ) + XCTAssertEqual(tokenIssuer.requestedWorkspaceIDs.count, 1) + XCTAssertEqual(agent.receivedTokens.count, 1) + XCTAssertFalse(coordinator.canBeginEnrollment) + + let retrySucceeded = await coordinator.enroll(workspaceID: "ws_retry") + + XCTAssertFalse(retrySucceeded) + XCTAssertEqual(tokenIssuer.requestedWorkspaceIDs.count, 1) + } + + func testAttachmentTimeoutDoesNotSleepInRealTime() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .success("fltm_slow"), + snapshots: [snapshot(.attaching, machineID: "fltm_slow")], + streamCompletion: .stayOpen + ) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + sleeper: { _ in } + ) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual( + coordinator.state, + .failed(.attachmentTimedOut(machineID: "fltm_slow")) + ) + XCTAssertEqual(agent.receivedTokens.count, 1) + XCTAssertFalse(coordinator.canBeginEnrollment) + + let retrySucceeded = await coordinator.enroll(workspaceID: "ws_retry") + + XCTAssertFalse(retrySucceeded) + XCTAssertEqual(tokenIssuerCallCount(in: calls.entries), 1) + } + + func testStaleMachineSnapshotIsIgnored() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .success("fltm_current"), + snapshots: [ + snapshot(.attached, machineID: "fltm_stale"), + snapshot(.attaching, machineID: "fltm_current"), + snapshot(.attached, machineID: "fltm_current"), + ] + ) + let coordinator = makeCoordinator(calls: calls, agent: agent) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertTrue(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_current")) + } + + func testConcurrentEnrollmentDoesNotIssueSecondTokenAndCanBeCancelled() async { + let calls = CallRecorder() + let agent = AgentStub(calls: calls) + let tokenIssuer = TokenIssuerStub( + calls: calls, + operation: { try await waitForCancellation(.zero) } + ) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + let firstEnrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ tokenIssuer.requestedWorkspaceIDs.count == 1 }) else { + XCTFail("The first enrollment did not request its token.") + coordinator.cancel() + _ = await firstEnrollment.value + return + } + + let secondSucceeded = await coordinator.enroll(workspaceID: "ws_456") + let settled = await coordinator.settleForTermination(gracePeriod: .seconds(1)) + let firstSucceeded = await firstEnrollment.value + + XCTAssertTrue(settled) + XCTAssertFalse(firstSucceeded) + XCTAssertFalse(secondSucceeded) + XCTAssertEqual(tokenIssuer.requestedWorkspaceIDs, ["ws_123"]) + XCTAssertTrue(agent.receivedTokens.isEmpty) + XCTAssertEqual(coordinator.state, .failed(.cancelled(machineID: nil))) + } + + func testCancellationDuringLocalEnrollReportsUnknownOutcome() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollOperation: { throw CancellationError() } + ) + let coordinator = makeCoordinator(calls: calls, agent: agent) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + + XCTAssertFalse(succeeded) + XCTAssertEqual(coordinator.state, .failed(.enrollmentOutcomeUnknown)) + } + + func testTerminationDoesNotAbortPostHandoffEnrollAndReportsGraceTimeout() async { + let calls = CallRecorder() + let gate = AsyncGate() + let agent = AgentStub( + calls: calls, + snapshots: [snapshot(.attached, machineID: "fltm_123")], + enrollOperation: { await gate.wait() } + ) + let coordinator = makeCoordinator(calls: calls, agent: agent) + let enrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ agent.receivedTokens.count == 1 }) else { + XCTFail("Enrollment did not hand the token to the Agent.") + await gate.open() + _ = await enrollment.value + return + } + + let settled = await coordinator.settleForTermination(gracePeriod: .zero) + await gate.open() + let succeeded = await enrollment.value + + XCTAssertFalse(settled) + XCTAssertTrue(succeeded) + XCTAssertEqual(coordinator.state, .ready(machineID: "fltm_123")) + } + + func testTerminationDoesNotReportSettledForUnresolvedOutcome() async { + let calls = CallRecorder() + let agent = AgentStub( + calls: calls, + enrollmentResult: .failure(.expected) + ) + let coordinator = makeCoordinator(calls: calls, agent: agent) + + let succeeded = await coordinator.enroll(workspaceID: "ws_123") + let settled = await coordinator.settleForTermination(gracePeriod: .zero) + + XCTAssertFalse(succeeded) + XCTAssertFalse(settled) + XCTAssertEqual(coordinator.state, .failed(.enrollmentOutcomeUnknown)) + } + + func testCancellationBeforeHandoffStopsNonCooperativeTokenIssuer() async { + let calls = CallRecorder() + let gate = AsyncGate() + let agent = AgentStub(calls: calls) + let tokenIssuer = TokenIssuerStub( + calls: calls, + operation: { await gate.wait() } + ) + let coordinator = makeCoordinator( + calls: calls, + agent: agent, + tokenIssuer: tokenIssuer + ) + let enrollment = Task { + await coordinator.enroll(workspaceID: "ws_123") + } + guard await waitUntil({ tokenIssuer.requestedWorkspaceIDs.count == 1 }) else { + XCTFail("Enrollment did not request its token.") + await gate.open() + _ = await enrollment.value + return + } + + coordinator.cancel() + await gate.open() + let succeeded = await enrollment.value + + XCTAssertFalse(succeeded) + XCTAssertEqual(coordinator.state, .failed(.cancelled(machineID: nil))) + XCTAssertTrue(agent.receivedTokens.isEmpty) + XCTAssertTrue(coordinator.canBeginEnrollment) + } + + private func makeCoordinator( + calls: CallRecorder, + agent: AgentStub, + tokenIssuer: TokenIssuerStub? = nil, + isSignedIn: Bool = true, + readinessFailure: TestFailure? = nil, + sleeper: @escaping @Sendable (Duration) async throws -> Void = waitForCancellation + ) -> FleetEnrollmentCoordinator { + FleetEnrollmentCoordinator( + authentication: AuthenticationStub(isSignedIn: isSignedIn), + agentReadiness: AgentReadinessStub( + calls: calls, + agent: agent, + failure: readinessFailure + ), + tokenIssuer: tokenIssuer ?? TokenIssuerStub(calls: calls), + sleeper: sleeper + ) + } + + private func snapshot( + _ enrollment: FleetEnrollmentState, + machineID: String? + ) -> FleetAgentSnapshot { + FleetAgentSnapshot( + enrollment: enrollment, + machineID: machineID, + isDraining: false, + capabilities: [], + inFlightJobs: [], + recentVerdicts: [], + telemetry: nil, + settings: nil + ) + } + + private func waitUntil(_ condition: @MainActor () -> Bool) async -> Bool { + for _ in 0..<1_000 { + if condition() { return true } + await Task.yield() + } + return false + } + + private func tokenIssuerCallCount(in entries: [String]) -> Int { + entries.count(where: { $0 == "token" }) + } +} + +@MainActor +private final class AuthenticationStub: FleetAuthenticationChecking { + let isSignedIn: Bool + + init(isSignedIn: Bool) { + self.isSignedIn = isSignedIn + } +} + +@MainActor +private final class CallRecorder { + private(set) var entries: [String] = [] + + func record(_ entry: String) { + entries.append(entry) + } +} + +@MainActor +private final class TokenIssuerStub: FleetEnrollmentTokenIssuing { + private let calls: CallRecorder + private let result: Result + private let operation: (@MainActor () async throws -> Void)? + private(set) var requestedWorkspaceIDs: [String] = [] + + init( + calls: CallRecorder, + result: Result = .success( + FleetEnrollmentToken( + token: "flet_test_secret", + expiresAt: .now.addingTimeInterval(3600) + )), + operation: (@MainActor () async throws -> Void)? = nil + ) { + self.calls = calls + self.result = result + self.operation = operation + } + + func createEnrollmentToken(workspaceID: String) async throws -> FleetEnrollmentToken { + calls.record("token") + requestedWorkspaceIDs.append(workspaceID) + try await operation?() + return try result.get() + } +} + +@MainActor +private final class AgentReadinessStub: FleetAgentReadying { + private let calls: CallRecorder + private let agent: any FleetAgentEnrollmentControlling + private let failure: TestFailure? + + init( + calls: CallRecorder, + agent: any FleetAgentEnrollmentControlling, + failure: TestFailure? = nil + ) { + self.calls = calls + self.agent = agent + self.failure = failure + } + + func ensureReady() async throws -> any FleetAgentEnrollmentControlling { + calls.record("ensureReady") + if let failure { throw failure } + return agent + } +} + +@MainActor +private final class AgentStub: FleetAgentEnrollmentControlling { + enum StreamCompletion: Sendable { + case finish + case stayOpen + } + + private let calls: CallRecorder + private let enrollmentResult: Result + private let baseline: FleetAgentSnapshot + private let snapshots: [FleetAgentSnapshot] + private let streamCompletion: StreamCompletion + private let baselineGate: AsyncGate? + private let enrollOperation: (@MainActor () async throws -> Void)? + private(set) var receivedTokens: [String] = [] + + init( + calls: CallRecorder, + baseline: FleetAgentSnapshot? = nil, + enrollmentResult: Result = .success("fltm_123"), + snapshots: [FleetAgentSnapshot] = [], + streamCompletion: StreamCompletion = .finish, + baselineGate: AsyncGate? = nil, + enrollOperation: (@MainActor () async throws -> Void)? = nil + ) { + self.calls = calls + self.baseline = + baseline + ?? FleetAgentSnapshot( + enrollment: .unenrolled, + machineID: nil, + isDraining: false, + capabilities: [], + inFlightJobs: [], + recentVerdicts: [], + telemetry: nil, + settings: nil + ) + self.enrollmentResult = enrollmentResult + self.snapshots = snapshots + self.streamCompletion = streamCompletion + self.baselineGate = baselineGate + self.enrollOperation = enrollOperation + } + + func enroll(token: String, controlPlane: String?) async throws -> String { + calls.record("enroll") + receivedTokens.append(token) + try await enrollOperation?() + return try enrollmentResult.get() + } + + func watchSnapshots() -> AsyncThrowingStream { + calls.record("watch") + let baseline = baseline + let snapshots = snapshots + let streamCompletion = streamCompletion + let baselineGate = baselineGate + + return AsyncThrowingStream { continuation in + let task = Task { + if let baselineGate { + await baselineGate.wait() + } + guard !Task.isCancelled else { + continuation.finish() + return + } + + continuation.yield(baseline) + for snapshot in snapshots { + continuation.yield(snapshot) + } + if case .finish = streamCompletion { + continuation.finish() + } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } +} + +private enum TestFailure: Error, Sendable { + case expected +} + +private actor AsyncGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func open() { + guard !isOpen else { return } + isOpen = true + continuation?.resume() + continuation = nil + } +} + +extension TestFailure: LocalizedError { + var errorDescription: String? { + "Expected test failure." + } +} + +private func waitForCancellation(_: Duration) async throws { + while !Task.isCancelled { + await Task.yield() + } + throw CancellationError() +} diff --git a/ArcBoxTests/FleetViewModelTests.swift b/ArcBoxTests/FleetViewModelTests.swift new file mode 100644 index 00000000..96e93b68 --- /dev/null +++ b/ArcBoxTests/FleetViewModelTests.swift @@ -0,0 +1,440 @@ +import FleetControlClient +import XCTest +import os + +@testable import ArcBox + +@MainActor +final class FleetViewModelTests: XCTestCase { + func testVMSettingsAvailabilityRequiresStableFeatureAndBothFields() { + let supported = FleetAgentInfo( + agentVersion: "0.5.0", + apiVersion: 1, + features: ["vm-settings"] + ) + let completeSettings = makeVMSettings() + + XCTAssertEqual( + FleetViewModel.resolveVMSettingsAvailability( + agentInfo: supported, + settings: completeSettings, + loadState: .connecting + ), + .loading + ) + XCTAssertEqual( + FleetViewModel.resolveVMSettingsAvailability( + agentInfo: FleetAgentInfo( + agentVersion: "0.4.0", + apiVersion: 1, + features: [] + ), + settings: completeSettings, + loadState: .ready + ), + .unsupported + ) + XCTAssertEqual( + FleetViewModel.resolveVMSettingsAvailability( + agentInfo: supported, + settings: FleetAgentSettings(vmMode: completeSettings.vmMode), + loadState: .ready + ), + .missingSettings + ) + XCTAssertEqual( + FleetViewModel.resolveVMSettingsAvailability( + agentInfo: supported, + settings: completeSettings, + loadState: .ready + ), + .available + ) + XCTAssertEqual( + FleetViewModel.resolveVMSettingsAvailability( + agentInfo: supported, + settings: completeSettings, + loadState: .failed("Watch disconnected.") + ), + .unavailable("Watch disconnected.") + ) + } + + func testVMRestartRequirementSeparatesSettingsFromRuntimeReadiness() { + let vm = FleetViewModel() + vm.loadState = .ready + vm.agentInfo = makeAgentInfo(features: ["vm-settings", "macos-image-prepare"]) + vm.settings = makeVMSettings( + vmMode: FleetSetting(current: .enabled, target: .disabled) + ) + + XCTAssertTrue(vm.requiresAgentRestartForVM) + + vm.settings = makeVMSettings( + vmMode: FleetSetting(current: .disabled, target: .enabled) + ) + XCTAssertTrue(vm.requiresAgentRestartForVM) + + vm.settings = makeVMSettings() + XCTAssertTrue(vm.requiresAgentRestartForVM) + + vm.agentInfo = makeAgentInfo( + features: ["vm-settings", "macos-image-prepare", "vm-backend"] + ) + XCTAssertFalse(vm.requiresAgentRestartForVM) + + vm.agentInfo = makeAgentInfo(features: ["vm-settings", "macos-image-prepare"]) + vm.settings = makeVMSettings( + vmMode: FleetSetting(current: .disabled, target: .disabled) + ) + XCTAssertFalse(vm.requiresAgentRestartForVM) + + vm.settings = makeVMSettings( + image: FleetSetting(current: "tahoe-base", target: "tahoe-next") + ) + XCTAssertFalse(vm.requiresAgentRestartForVM) + } + + func testVMRestartRequirementSurvivesDesktopReopenOrExternalPreparation() { + let vm = FleetViewModel() + vm.loadState = .ready + vm.agentInfo = makeAgentInfo(features: ["vm-settings", "macos-image-prepare"]) + vm.settings = makeVMSettings( + vmMode: FleetSetting(current: .disabled, target: .enabled) + ) + + XCTAssertEqual(vm.imagePreparationState, .idle) + XCTAssertTrue(vm.requiresAgentRestartForVM) + + vm.settings = makeVMSettings( + image: FleetSetting(current: "", target: "tahoe-base"), + vmMode: FleetSetting(current: .disabled, target: .enabled) + ) + XCTAssertFalse(vm.requiresAgentRestartForVM) + } + + func testMacOSImagePreparationUsesItsOwnCapability() { + let vm = FleetViewModel() + vm.loadState = .ready + vm.settings = makeVMSettings( + image: FleetSetting(current: "", target: "tahoe-base") + ) + vm.agentInfo = makeAgentInfo(features: ["vm-settings"]) + + XCTAssertFalse(vm.supportsMacOSImagePreparation) + XCTAssertFalse(vm.isVMBackendActive) + XCTAssertEqual(vm.runnerImageReadiness, .hidden) + + vm.agentInfo = makeAgentInfo( + features: ["vm-settings", "macos-image-prepare", "vm-backend"] + ) + + XCTAssertTrue(vm.supportsMacOSImagePreparation) + XCTAssertTrue(vm.isVMBackendActive) + XCTAssertTrue(vm.canBeginMacOSRunnerImagePreparation) + XCTAssertEqual(vm.runnerImageReadiness, .pending(reference: "tahoe-base")) + } + + func testRunnerImageReadinessTracksPreparationAndRestartRequirement() { + let vm = FleetViewModel() + vm.loadState = .ready + vm.agentInfo = makeAgentInfo(features: ["vm-settings", "macos-image-prepare"]) + vm.settings = makeVMSettings( + image: FleetSetting(current: "", target: "tahoe-next") + ) + + let progress = FleetImagePreparationProgress( + stage: "pulling", + detail: "Downloading", + fraction: 0.5 + ) + vm.imagePreparationState = .preparing(progress) + XCTAssertEqual(vm.runnerImageReadiness, .preparing(progress)) + + vm.settings = makeVMSettings( + image: FleetSetting(current: "tahoe-next", target: "tahoe-next") + ) + vm.imagePreparationState = .completed(reference: "tahoe-next") + XCTAssertEqual(vm.runnerImageReadiness, .restartRequired) + + vm.agentInfo = makeAgentInfo( + features: ["vm-settings", "macos-image-prepare", "vm-backend"] + ) + XCTAssertEqual(vm.runnerImageReadiness, .completed(reference: "tahoe-next")) + } + + func testMacOSImagePreparationConvergesAgainstAuthoritativeSettings() async { + let initial = makeVMSettings( + image: FleetSetting(current: "", target: "tahoe-next") + ) + let converged = makeVMSettings( + image: FleetSetting(current: "tahoe-next", target: "tahoe-next") + ) + let client = FleetControlStub( + initialSettings: initial, + refreshedSettings: converged, + preparation: .finished([ + FleetImagePreparationEvent( + kind: .macosRunnerImage, + detail: "downloading", + stage: "pulling", + fraction: 0.5 + ) + ]) + ) + let vm = FleetViewModel() + vm.start(client: client) + await waitUntil { vm.isReady } + + vm.beginMacOSRunnerImagePreparation() + await waitUntil { + vm.imagePreparationState == .completed(reference: "tahoe-next") + } + + XCTAssertEqual(vm.settings, converged) + XCTAssertNil(vm.lastError) + vm.stop() + } + + func testMacOSImagePreparationRejectsNonConvergedSettings() async { + let pending = makeVMSettings( + image: FleetSetting(current: "tahoe-base", target: "tahoe-next") + ) + let client = FleetControlStub( + initialSettings: pending, + refreshedSettings: pending, + preparation: .finished([]) + ) + let vm = FleetViewModel() + vm.start(client: client) + await waitUntil { vm.isReady } + + vm.beginMacOSRunnerImagePreparation() + await waitUntil { + if case .failed = vm.imagePreparationState { return true } + return false + } + + guard case .failed(let message) = vm.imagePreparationState else { + return XCTFail("Expected image preparation to fail") + } + XCTAssertTrue(message.contains("tahoe-next")) + XCTAssertEqual(vm.lastError, message) + vm.stop() + } + + func testStopCancelsMacOSImagePreparation() async { + let settings = makeVMSettings( + image: FleetSetting(current: "", target: "tahoe-next") + ) + let client = FleetControlStub( + initialSettings: settings, + refreshedSettings: settings, + preparation: .suspended + ) + let vm = FleetViewModel() + vm.start(client: client) + await waitUntil { vm.isReady } + + vm.beginMacOSRunnerImagePreparation() + await waitUntil { client.preparationStarted } + vm.stop() + await waitUntil { client.preparationTerminated } + + XCTAssertEqual(vm.imagePreparationState, .idle) + } + + func testDrainAndResumeForwardToAgentAndRefreshStatus() async { + let settings = makeVMSettings() + let client = FleetControlStub( + initialSettings: settings, + refreshedSettings: settings, + preparation: .finished([]) + ) + let vm = FleetViewModel() + vm.start(client: client) + await waitUntil { vm.isReady } + + let drained = await vm.drain() + XCTAssertTrue(drained) + XCTAssertEqual(client.drainCallCount, 1) + XCTAssertEqual(vm.status?.state, .draining) + + let resumed = await vm.resume() + XCTAssertTrue(resumed) + XCTAssertEqual(client.resumeCallCount, 1) + XCTAssertEqual(vm.status?.state, .enrolled) + XCTAssertNil(vm.lastError) + vm.stop() + } + + private func makeAgentInfo(features: [String]) -> FleetAgentInfo { + FleetAgentInfo(agentVersion: "0.5.0", apiVersion: 1, features: features) + } + + private func makeVMSettings( + image: FleetSetting = FleetSetting(current: "tahoe-base", target: "tahoe-base"), + vmMode: FleetSetting = FleetSetting(current: .auto, target: .auto) + ) -> FleetAgentSettings { + FleetAgentSettings( + macosRunnerImage: image, + vmMode: vmMode + ) + } + + private func waitUntil( + timeout: Duration = .seconds(2), + condition: @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return } + try? await Task.sleep(for: .milliseconds(10)) + } + XCTFail("Condition was not satisfied before timeout") + } +} + +private final class FleetControlStub: FleetControlServicing, @unchecked Sendable { + enum Preparation: Sendable { + case finished([FleetImagePreparationEvent]) + case suspended + } + + private struct State { + var settingsReadCount = 0 + var connectionState = FleetConnectionState.enrolled + var drainCallCount = 0 + var resumeCallCount = 0 + var watchContinuation: AsyncThrowingStream.Continuation? + var preparationContinuation: AsyncThrowingStream.Continuation? + var preparationStarted = false + var preparationTerminated = false + } + + private let initialSettings: FleetAgentSettings + private let refreshedSettings: FleetAgentSettings + private let preparation: Preparation + private let state = OSAllocatedUnfairLock(initialState: State()) + + init( + initialSettings: FleetAgentSettings, + refreshedSettings: FleetAgentSettings, + preparation: Preparation + ) { + self.initialSettings = initialSettings + self.refreshedSettings = refreshedSettings + self.preparation = preparation + } + + var preparationStarted: Bool { + state.withLock { $0.preparationStarted } + } + + var preparationTerminated: Bool { + state.withLock { $0.preparationTerminated } + } + + var drainCallCount: Int { + state.withLock { $0.drainCallCount } + } + + var resumeCallCount: Int { + state.withLock { $0.resumeCallCount } + } + + func fetchAgentInfo() async throws -> FleetAgentInfo { + FleetAgentInfo( + agentVersion: "test", + apiVersion: 1, + features: ["vm-settings", "macos-image-prepare"] + ) + } + + func getStatus() async throws -> FleetAgentStatus { + state.withLock { + FleetAgentStatus( + state: $0.connectionState, + machineID: $0.connectionState == .unenrolled ? nil : "fltm_test" + ) + } + } + + func watchSnapshots() -> AsyncThrowingStream { + AsyncThrowingStream { [self] continuation in + state.withLock { $0.watchContinuation = continuation } + continuation.yield( + FleetAgentSnapshot( + enrollment: .attached, + machineID: "fltm_test", + isDraining: false, + capabilities: [], + inFlightJobs: [], + recentVerdicts: [], + telemetry: nil, + settings: initialSettings + ) + ) + continuation.onTermination = { [weak self] _ in + self?.state.withLock { $0.watchContinuation = nil } + } + } + } + + func drain() async throws { + state.withLock { + $0.drainCallCount += 1 + $0.connectionState = .draining + } + } + + func resume() async throws { + state.withLock { + $0.resumeCallCount += 1 + $0.connectionState = .enrolled + } + } + + func unenroll() async throws { + state.withLock { + $0.connectionState = .unenrolled + } + } + + func prepareImages( + _ kinds: [FleetImageKind] + ) -> AsyncThrowingStream { + AsyncThrowingStream { [self] continuation in + state.withLock { state in + state.preparationStarted = true + state.preparationContinuation = continuation + } + continuation.onTermination = { [weak self] _ in + self?.state.withLock { state in + state.preparationContinuation = nil + state.preparationTerminated = true + } + } + + if case .finished(let events) = preparation { + for event in events { + continuation.yield(event) + } + continuation.finish() + } + } + } + + func getSettings() async throws -> FleetAgentSettings { + let readCount = state.withLock { state -> Int in + defer { state.settingsReadCount += 1 } + return state.settingsReadCount + } + return readCount == 0 ? initialSettings : refreshedSettings + } + + func updateSettings(_ update: FleetSettingsUpdate) async throws -> FleetAgentSettings { + refreshedSettings + } +} diff --git a/ArcBoxTests/QuitWindowControllerTests.swift b/ArcBoxTests/QuitWindowControllerTests.swift index 23367946..47004f44 100644 --- a/ArcBoxTests/QuitWindowControllerTests.swift +++ b/ArcBoxTests/QuitWindowControllerTests.swift @@ -1,5 +1,4 @@ import AppKit -import AuthenticationServices import XCTest @testable import ArcBox @@ -23,21 +22,3 @@ final class QuitWindowControllerTests: XCTestCase { XCTAssertNil(window.standardWindowButton(.zoomButton)) } } - -@MainActor -final class WebAuthenticationControllerTests: XCTestCase { - func testAuthenticationCannotStartAfterTermination() async throws { - let controller = WebAuthenticationController() - controller.cancelForTermination() - - do { - _ = try await controller.authenticate( - using: XCTUnwrap(URL(string: "https://example.com")), - callbackURLScheme: "arcbox" - ) - XCTFail("Authentication started during app termination") - } catch let error as ASWebAuthenticationSessionError { - XCTAssertEqual(error.code, .canceledLogin) - } - } -} diff --git a/ArcBoxTests/RunnerHostJobSummaryTests.swift b/ArcBoxTests/RunnerHostJobSummaryTests.swift new file mode 100644 index 00000000..8f1995da --- /dev/null +++ b/ArcBoxTests/RunnerHostJobSummaryTests.swift @@ -0,0 +1,66 @@ +import FleetPlatformClient +import XCTest + +@testable import ArcBox + +final class RunnerHostJobSummaryTests: XCTestCase { + func testSummaryCountsTodayAndUsesOnlyFinishedJobsForSuccessRate() throws { + let calendar = Calendar(identifier: .gregorian) + let now = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2026, month: 7, day: 24, hour: 12)) + ) + let yesterday = try XCTUnwrap(calendar.date(byAdding: .day, value: -1, to: now)) + let jobs = [ + job(id: "completed", status: .completed, createdAt: now), + job(id: "failed", status: .failed, createdAt: now), + job(id: "running", status: .running, createdAt: now), + job(id: "old", status: .completed, createdAt: yesterday), + ] + + let summary = RunnerHostJobSummary( + jobs: jobs, + hasMoreHistory: false, + now: now, + calendar: calendar + ) + + XCTAssertEqual(summary.recordedCount, 4) + XCTAssertEqual(summary.todayCount, 3) + XCTAssertEqual(summary.completedCount, 2) + XCTAssertEqual(summary.unsuccessfulCount, 1) + XCTAssertEqual(summary.successRate, 2.0 / 3.0) + XCTAssertEqual(summary.recordedCountDescription, "4") + } + + func testSummaryMarksTruncatedHistoryAndOmitsRateWithoutFinishedJobs() { + let summary = RunnerHostJobSummary( + jobs: [job(id: "queued", status: .queued, createdAt: .now)], + hasMoreHistory: true + ) + + XCTAssertEqual(summary.recordedCountDescription, "1+") + XCTAssertNil(summary.successRate) + } + + private func job( + id: String, + status: FleetRunnerJobStatus, + createdAt: Date + ) -> FleetRunnerJob { + FleetRunnerJob( + id: id, + repo: "arcboxlabs/arcbox", + status: status, + os: .darwin, + arch: .arm64, + githubRunID: 1, + githubJobID: 2, + labels: [], + machineID: "fltm_local", + jitRunnerName: nil, + createdAt: createdAt, + startedAt: nil, + finishedAt: nil + ) + } +} diff --git a/ArcBoxTests/RunnerJobDetailModelTests.swift b/ArcBoxTests/RunnerJobDetailModelTests.swift new file mode 100644 index 00000000..e1cb96a3 --- /dev/null +++ b/ArcBoxTests/RunnerJobDetailModelTests.swift @@ -0,0 +1,104 @@ +import FleetControlClient +import FleetPlatformClient +import XCTest + +@testable import ArcBox + +final class RunnerJobDetailModelTests: XCTestCase { + func testResolveUsesPlatformMetadataAndLiveRunningState() throws { + let platformJob = job(id: "job_current", status: .provisioning) + let liveJob = FleetInFlightJob(jobID: "job_current", os: "darwin", arch: "arm64") + + let detail = try XCTUnwrap( + RunnerJobDetailModel.resolve( + id: "job_current", + platformJobs: [platformJob], + liveJobs: [liveJob] + ) + ) + + XCTAssertEqual(detail.repository, "arcboxlabs/arcbox") + XCTAssertEqual(detail.status, .running) + XCTAssertEqual(detail.machineID, "fltm_local") + XCTAssertEqual( + detail.githubURL?.absoluteString, + "https://github.com/arcboxlabs/arcbox/actions/runs/123/job/456" + ) + } + + func testResolveSupportsLiveOnlyJobWithoutInventingPlatformMetadata() throws { + let liveJob = FleetInFlightJob(jobID: "job_live", os: "linux", arch: "arm64") + + let detail = try XCTUnwrap( + RunnerJobDetailModel.resolve( + id: "job_live", + platformJobs: [], + liveJobs: [liveJob] + ) + ) + + XCTAssertEqual(detail.status, .running) + XCTAssertEqual(detail.runtimeKind, "Docker container") + XCTAssertNil(detail.repository) + XCTAssertNil(detail.githubURL) + XCTAssertNil(detail.machineID) + } + + func testLiveMacOSJobUsesVirtualMachineRuntime() throws { + let liveJob = FleetInFlightJob(jobID: "job_live", os: "macos", arch: "arm64") + + let detail = try XCTUnwrap( + RunnerJobDetailModel.resolve( + id: "job_live", + platformJobs: [], + liveJobs: [liveJob] + ) + ) + + XCTAssertEqual(detail.runtimeKind, "Virtual machine") + } + + func testResolveReturnsNilForUnknownJob() { + XCTAssertNil( + RunnerJobDetailModel.resolve( + id: "missing", + platformJobs: [], + liveJobs: [] + ) + ) + } + + func testGitHubURLRejectsMalformedRepository() throws { + let detail = try XCTUnwrap( + RunnerJobDetailModel.resolve( + id: "job", + platformJobs: [job(id: "job", status: .completed, repo: "not-a-repo")], + liveJobs: [] + ) + ) + + XCTAssertNil(detail.githubURL) + } + + private func job( + id: String, + status: FleetRunnerJobStatus, + repo: String = "arcboxlabs/arcbox" + ) -> FleetRunnerJob { + FleetRunnerJob( + id: id, + repo: repo, + status: status, + os: .darwin, + arch: .arm64, + githubRunID: 123, + githubJobID: 456, + labels: ["self-hosted", "macOS"], + machineID: "fltm_local", + jitRunnerName: "arcbox-jit-123", + createdAt: .distantPast, + startedAt: .distantPast, + finishedAt: status == .completed ? .now : nil + ) + } +} diff --git a/ArcBoxTests/RunnerJobListItemTests.swift b/ArcBoxTests/RunnerJobListItemTests.swift new file mode 100644 index 00000000..2ceaf149 --- /dev/null +++ b/ArcBoxTests/RunnerJobListItemTests.swift @@ -0,0 +1,55 @@ +import FleetControlClient +import FleetPlatformClient +import XCTest + +@testable import ArcBox + +final class RunnerJobListItemTests: XCTestCase { + func testMergeKeepsLiveOnlyJobsAheadOfPlatformHistory() { + let platformJob = job(id: "job_finished", status: .completed) + let liveJob = FleetInFlightJob(jobID: "job_live", os: "darwin", arch: "arm64") + + let items = RunnerJobListItem.merge( + platformJobs: [platformJob], + liveJobs: [liveJob] + ) + + XCTAssertEqual(items.map(\.id), ["job_live", "job_finished"]) + XCTAssertEqual(items.first?.status, .running) + XCTAssertNil(items.first?.repository) + XCTAssertEqual(items.last?.repository, "arcboxlabs/arcbox") + } + + func testMergeDoesNotDuplicatePlatformJobReportedLiveByAgent() { + let platformJob = job(id: "job_current", status: .provisioning) + let liveJob = FleetInFlightJob(jobID: "job_current", os: "darwin", arch: "arm64") + + let items = RunnerJobListItem.merge( + platformJobs: [platformJob], + liveJobs: [liveJob] + ) + + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items.first?.id, "job_current") + XCTAssertEqual(items.first?.status, .running) + XCTAssertEqual(items.first?.repository, "arcboxlabs/arcbox") + } + + private func job(id: String, status: FleetRunnerJobStatus) -> FleetRunnerJob { + FleetRunnerJob( + id: id, + repo: "arcboxlabs/arcbox", + status: status, + os: .darwin, + arch: .arm64, + githubRunID: 123, + githubJobID: 456, + labels: [], + machineID: "fltm_local", + jitRunnerName: nil, + createdAt: .distantPast, + startedAt: nil, + finishedAt: nil + ) + } +} diff --git a/ArcBoxTests/RunnerPlatformStoreTests.swift b/ArcBoxTests/RunnerPlatformStoreTests.swift new file mode 100644 index 00000000..a23e2aeb --- /dev/null +++ b/ArcBoxTests/RunnerPlatformStoreTests.swift @@ -0,0 +1,284 @@ +import FleetPlatformClient +import XCTest + +@testable import ArcBox + +@MainActor +final class RunnerPlatformStoreTests: XCTestCase { + func testResolvesAgentMachineAcrossWorkspacesAndLoadsItsJobs() async throws { + let personal = workspace(id: "ws_personal", name: "Personal") + let team = workspace(id: "ws_team", name: "Team") + let machine = machine(id: "fltm_local", name: "This Mac") + let job = job(id: "job_current", machineID: machine.id) + let client = StubRunnerPlatformClient( + workspaces: [personal, team], + machinesByWorkspace: [ + personal.id: [self.machine(id: "fltm_other", name: "Other Mac")], + team.id: [machine], + ], + jobPage: FleetRunnerJobPage(jobs: [job], nextCursor: "older") + ) + let store = RunnerPlatformStore() + + await store.refresh(client: client, machineID: machine.id) + + XCTAssertEqual(store.loadState, .loaded) + XCTAssertEqual(store.workspace, team) + XCTAssertEqual(store.machine, machine) + XCTAssertEqual(store.jobs, [job]) + XCTAssertEqual(store.nextCursor, "older") + XCTAssertEqual(client.listedMachineWorkspaces, [personal.id, team.id]) + XCTAssertEqual(client.jobMachineIDs, [machine.id]) + } + + func testRefreshReusesResolvedWorkspaceWithoutScanningAgain() async { + let workspace = workspace(id: "ws_team", name: "Team") + let machine = machine(id: "fltm_local", name: "This Mac") + let client = StubRunnerPlatformClient( + workspaces: [workspace], + machinesByWorkspace: [workspace.id: [machine]], + jobPage: FleetRunnerJobPage(jobs: [], nextCursor: nil) + ) + let store = RunnerPlatformStore() + + await store.refresh(client: client, machineID: machine.id) + await store.refresh(client: client, machineID: machine.id) + + XCTAssertEqual(client.listWorkspacesCallCount, 1) + XCTAssertEqual(client.listedMachineWorkspaces, [workspace.id]) + XCTAssertEqual(client.requestedMachineIDs, [machine.id]) + XCTAssertEqual(client.jobMachineIDs, [machine.id, machine.id]) + } + + func testMissingMachineHasExplicitState() async { + let workspace = workspace(id: "ws_team", name: "Team") + let client = StubRunnerPlatformClient( + workspaces: [workspace], + machinesByWorkspace: [workspace.id: []], + jobPage: FleetRunnerJobPage(jobs: [], nextCursor: nil) + ) + let store = RunnerPlatformStore() + + await store.refresh(client: client, machineID: "fltm_missing") + + XCTAssertEqual(store.loadState, .machineNotFound) + XCTAssertNil(store.workspace) + XCTAssertNil(store.machine) + XCTAssertTrue(store.jobs.isEmpty) + } + + func testLateRefreshCannotOverwriteNewMachine() async { + let oldWorkspace = workspace(id: "ws_old", name: "Old") + let oldMachine = machine(id: "fltm_old", name: "Old Mac") + let slowClient = SuspendedRunnerPlatformClient( + workspace: oldWorkspace, + machine: oldMachine + ) + let newWorkspace = workspace(id: "ws_new", name: "New") + let newMachine = machine(id: "fltm_new", name: "New Mac") + let fastClient = StubRunnerPlatformClient( + workspaces: [newWorkspace], + machinesByWorkspace: [newWorkspace.id: [newMachine]], + jobPage: FleetRunnerJobPage( + jobs: [job(id: "job_new", machineID: newMachine.id)], + nextCursor: nil + ) + ) + let store = RunnerPlatformStore() + + let oldRefresh = Task { + await store.refresh(client: slowClient, machineID: oldMachine.id) + } + await slowClient.waitUntilStarted() + + await store.refresh(client: fastClient, machineID: newMachine.id) + slowClient.resume() + await oldRefresh.value + + XCTAssertEqual(store.workspace, newWorkspace) + XCTAssertEqual(store.machine, newMachine) + XCTAssertEqual(store.jobs.map(\.id), ["job_new"]) + } + + func testSelectionSwitchesBetweenHostAndJob() { + let store = RunnerPlatformStore() + + store.selectHost() + XCTAssertEqual(store.selection, .host) + XCTAssertNil(store.selectedJobID) + + store.selectJob(id: "job_123") + XCTAssertEqual(store.selection, .job("job_123")) + XCTAssertEqual(store.selectedJobID, "job_123") + } + + func testReconcileClearsOnlyMissingJobSelection() { + let store = RunnerPlatformStore() + + store.selectJob(id: "job_123") + store.reconcileSelection(validJobIDs: ["job_123"]) + XCTAssertEqual(store.selection, .job("job_123")) + + store.reconcileSelection(validJobIDs: ["job_other"]) + XCTAssertNil(store.selection) + + store.selectHost() + store.reconcileSelection(validJobIDs: []) + XCTAssertEqual(store.selection, .host) + } + + func testResetClearsSelection() { + let store = RunnerPlatformStore() + store.selectHost() + + store.reset() + + XCTAssertNil(store.selection) + } + + private func workspace(id: String, name: String) -> FleetWorkspace { + FleetWorkspace( + id: id, + name: name, + plan: "free", + createdAt: .distantPast, + updatedAt: .distantPast + ) + } + + private func machine(id: String, name: String) -> FleetMachine { + FleetMachine( + id: id, + name: name, + status: .online, + arch: "arm64", + cpu: 12, + memMib: 24_576, + tags: [], + createdAt: .distantPast, + enrolledAt: .distantPast, + lastSeen: .distantPast, + agentVersion: "0.5.1", + pools: [], + telemetry: nil + ) + } + + private func job(id: String, machineID: String) -> FleetRunnerJob { + FleetRunnerJob( + id: id, + repo: "arcboxlabs/arcbox", + status: .running, + os: .darwin, + arch: .arm64, + githubRunID: 123, + githubJobID: 456, + labels: [], + machineID: machineID, + jitRunnerName: nil, + createdAt: .distantPast, + startedAt: .distantPast, + finishedAt: nil + ) + } +} + +@MainActor +private final class StubRunnerPlatformClient: RunnerPlatformLoading { + let workspaces: [FleetWorkspace] + let machinesByWorkspace: [String: [FleetMachine]] + let jobPage: FleetRunnerJobPage + + private(set) var listWorkspacesCallCount = 0 + private(set) var listedMachineWorkspaces: [String] = [] + private(set) var requestedMachineIDs: [String] = [] + private(set) var jobMachineIDs: [String] = [] + + init( + workspaces: [FleetWorkspace], + machinesByWorkspace: [String: [FleetMachine]], + jobPage: FleetRunnerJobPage + ) { + self.workspaces = workspaces + self.machinesByWorkspace = machinesByWorkspace + self.jobPage = jobPage + } + + func listWorkspaces() async throws -> [FleetWorkspace] { + listWorkspacesCallCount += 1 + return workspaces + } + + func listMachines(workspaceID: String) async throws -> [FleetMachine] { + listedMachineWorkspaces.append(workspaceID) + return machinesByWorkspace[workspaceID] ?? [] + } + + func getMachine(id: String, workspaceID: String) async throws -> FleetMachine { + requestedMachineIDs.append(id) + return try XCTUnwrap(machinesByWorkspace[workspaceID]?.first(where: { $0.id == id })) + } + + func listJobs( + workspaceID: String, + machineID: String?, + status: FleetRunnerJobStatus?, + cursor: String?, + limit: Int? + ) async throws -> FleetRunnerJobPage { + if let machineID { + jobMachineIDs.append(machineID) + } + return jobPage + } +} + +@MainActor +private final class SuspendedRunnerPlatformClient: RunnerPlatformLoading { + let workspace: FleetWorkspace + let machine: FleetMachine + + private var continuation: CheckedContinuation<[FleetWorkspace], Never>? + private var hasStarted = false + + init(workspace: FleetWorkspace, machine: FleetMachine) { + self.workspace = workspace + self.machine = machine + } + + func listWorkspaces() async throws -> [FleetWorkspace] { + hasStarted = true + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func listMachines(workspaceID: String) async throws -> [FleetMachine] { + [machine] + } + + func getMachine(id: String, workspaceID: String) async throws -> FleetMachine { + machine + } + + func listJobs( + workspaceID: String, + machineID: String?, + status: FleetRunnerJobStatus?, + cursor: String?, + limit: Int? + ) async throws -> FleetRunnerJobPage { + FleetRunnerJobPage(jobs: [], nextCursor: nil) + } + + func waitUntilStarted() async { + while !hasStarted { + await Task.yield() + } + } + + func resume() { + continuation?.resume(returning: [workspace]) + continuation = nil + } +} diff --git a/ArcBoxTests/RunnersViewModelTests.swift b/ArcBoxTests/RunnersViewModelTests.swift new file mode 100644 index 00000000..459a50a0 --- /dev/null +++ b/ArcBoxTests/RunnersViewModelTests.swift @@ -0,0 +1,369 @@ +import FleetControlClient +import XCTest + +@testable import ArcBox + +@MainActor +final class RunnersViewModelTests: XCTestCase { + func testInitialStateConnectsWithoutShowingEmptyState() { + let vm = RunnersViewModel() + + XCTAssertEqual(vm.viewState, .connecting) + } + + func testMissingControlClientShowsUnavailableState() { + let vm = RunnersViewModel() + + vm.start(controlClient: nil, platformClient: nil) + + XCTAssertEqual(vm.viewState, .unavailable("Fleet control client is unavailable.")) + } + + func testValidUnenrolledSnapshotReflectsCurrentAuthentication() { + let snapshot = makeSnapshot(enrollment: .unenrolled, machineID: nil) + + XCTAssertEqual( + resolve(snapshot: snapshot, enrollmentState: .idle, isSignedIn: false), + .signedOut + ) + XCTAssertEqual( + resolve(snapshot: snapshot, enrollmentState: .idle, isSignedIn: true), + .unenrolled + ) + } + + func testAttachedSnapshotMapsLiveJobsAndCapabilities() throws { + let fleet = FleetViewModel() + let job = FleetInFlightJob(jobID: "rjob_123", os: "darwin", arch: "arm64") + let capability = FleetCapability(os: "darwin", arch: "arm64", backend: .vm) + let snapshot = makeSnapshot( + enrollment: .attached, + capabilities: [capability], + jobs: [job] + ) + fleet.agentInfo = FleetAgentInfo(agentVersion: "0.5.0", apiVersion: 1, features: []) + fleet.snapshot = snapshot + fleet.loadState = .ready + + let vm = RunnersViewModel(fleet: fleet) + let host = try XCTUnwrap(enrolledHost(from: vm.viewState)) + + XCTAssertEqual(host.machineID, "fltm_test") + XCTAssertEqual(host.status, .online) + XCTAssertEqual(host.capabilities, [capability]) + XCTAssertEqual(host.inFlightJobs, [job]) + XCTAssertEqual(host.agentVersion, "0.5.0") + XCTAssertEqual(vm.activeJobCount, 1) + } + + func testHostNormalizesMacOSPoolNamesAndCountsLiveJobs() throws { + let fleet = FleetViewModel() + let snapshot = makeSnapshot( + enrollment: .attached, + capabilities: [ + FleetCapability(os: "macos", arch: "arm64", backend: .vm), + FleetCapability(os: "darwin", arch: "arm64", backend: .hostRunner), + FleetCapability(os: "linux", arch: "arm64", backend: .docker), + ], + jobs: [ + FleetInFlightJob(jobID: "macos", os: "macos", arch: "arm64"), + FleetInFlightJob(jobID: "darwin", os: "darwin", arch: "arm64"), + FleetInFlightJob(jobID: "linux", os: "linux", arch: "arm64"), + ] + ) + fleet.snapshot = snapshot + fleet.loadState = .ready + + let host = try XCTUnwrap(enrolledHost(from: RunnersViewModel(fleet: fleet).viewState)) + + XCTAssertEqual(host.capabilities(for: .macOS).count, 2) + XCTAssertEqual(host.capabilities(for: .linux).count, 1) + XCTAssertEqual(host.activeJobCount(for: .macOS), 2) + XCTAssertEqual(host.activeJobCount(for: .linux), 1) + } + + func testDrainingSnapshotOverridesAttachedStatus() throws { + let fleet = FleetViewModel() + let snapshot = makeSnapshot(enrollment: .attached, isDraining: true) + fleet.snapshot = snapshot + fleet.loadState = .ready + + let vm = RunnersViewModel(fleet: fleet) + let host = try XCTUnwrap(enrolledHost(from: vm.viewState)) + + XCTAssertEqual(host.status, .draining) + XCTAssertTrue(host.isDraining) + } + + func testUpdatingSnapshotRemainsEnrolledAndDisablesDrainControl() throws { + let state = resolve( + snapshot: makeSnapshot(enrollment: .updating), + enrollmentState: .ready(machineID: "fltm_test"), + isSignedIn: true + ) + let host = try XCTUnwrap(enrolledHost(from: state)) + + XCTAssertEqual(host.status, .updating) + XCTAssertFalse(host.status.canChangeDrainState) + } + + func testEnrollmentCoordinatorPhasesMapToVisibleProgress() { + let snapshot = makeSnapshot(enrollment: .unenrolled, machineID: nil) + let cases: [(FleetEnrollmentCoordinator.State, RunnerEnrollmentProgress)] = [ + (.preparingAgent, .checkingAgent), + (.requestingEnrollmentToken, .requestingToken), + (.enrolling, .enrollingAgent), + (.reconcilingEnrollment, .reconciling), + (.attaching(machineID: "fltm_test"), .attaching), + (.ready(machineID: "fltm_test"), .synchronizing), + ] + + for (coordinatorState, progress) in cases { + XCTAssertEqual( + resolve( + snapshot: snapshot, + enrollmentState: coordinatorState, + isSignedIn: true + ), + .enrolling(progress) + ) + } + } + + func testCoordinatorFailureIsVisibleInsteadOfLeavingDisabledOnboarding() { + let state = resolve( + snapshot: makeSnapshot(enrollment: .unenrolled, machineID: nil), + enrollmentState: .failed(.workspaceRequired), + isSignedIn: true + ) + + XCTAssertEqual( + state, + .enrollmentFailed( + "An ArcBox workspace is required for enrollment.", + recovery: .retry + ) + ) + } + + func testUnknownEnrollmentOutcomeDoesNotOfferUnsafeRetry() { + let state = resolve( + snapshot: makeSnapshot(enrollment: .unenrolled, machineID: nil), + enrollmentState: .failed(.enrollmentOutcomeUnknown), + isSignedIn: true, + canBeginEnrollment: false + ) + + XCTAssertEqual( + state, + .enrollmentFailed( + "The enrollment result is unknown. ArcBox will keep reconciling the Fleet Agent state.", + recovery: .waitForAgent + ) + ) + } + + func testKnownPostHandoffFailureOffersUnenrollRecovery() { + let state = resolve( + snapshot: makeSnapshot(enrollment: .unenrolled, machineID: nil), + enrollmentState: .failed(.attachmentTimedOut(machineID: "fltm_test")), + isSignedIn: true, + canBeginEnrollment: false + ) + + XCTAssertEqual( + state, + .enrollmentFailed( + "This Mac enrolled, but did not attach to the Fleet gateway in time.", + recovery: .unenroll + ) + ) + } + + func testCredentialRejectedSnapshotOverridesIdleCoordinator() { + XCTAssertEqual( + resolve( + snapshot: makeSnapshot(enrollment: .credentialRejected), + enrollmentState: .idle, + isSignedIn: true, + canBeginEnrollment: true + ), + .enrollmentFailed( + "The Fleet gateway rejected this Mac's credential.", + recovery: .unenroll + ) + ) + } + + func testDetachedSnapshotOverridesReadyCoordinator() { + XCTAssertEqual( + resolve( + snapshot: makeSnapshot(enrollment: .detached), + enrollmentState: .ready(machineID: "fltm_test"), + isSignedIn: true, + canBeginEnrollment: false + ), + .enrollmentFailed( + "This Mac is enrolled, but Fleet participation is disabled.", + recovery: .unenroll + ) + ) + } + + func testTerminalSnapshotOverridesInProgressOrMissingCoordinator() { + XCTAssertEqual( + resolve( + snapshot: makeSnapshot(enrollment: .credentialRejected), + enrollmentState: .enrolling, + isSignedIn: true, + canBeginEnrollment: false + ), + .enrollmentFailed( + "The Fleet gateway rejected this Mac's credential.", + recovery: .unenroll + ) + ) + XCTAssertEqual( + resolve( + snapshot: makeSnapshot(enrollment: .detached), + enrollmentState: nil, + isSignedIn: nil, + canBeginEnrollment: nil + ), + .enrollmentFailed( + "This Mac is enrolled, but Fleet participation is disabled.", + recovery: .unenroll + ) + ) + } + + func testInvalidAndUnknownSnapshotsNeverShowOnboarding() { + let invalidSnapshots = [ + makeSnapshot(enrollment: .unenrolled, machineID: "fltm_invalid"), + makeSnapshot(enrollment: .attached, machineID: nil), + makeSnapshot(enrollment: .updating, machineID: nil), + makeSnapshot(enrollment: .detached, machineID: nil), + makeSnapshot(enrollment: .credentialRejected, machineID: nil), + makeSnapshot(enrollment: .unspecified, machineID: nil), + makeSnapshot(enrollment: .unrecognized(42), machineID: nil), + ] + + for snapshot in invalidSnapshots { + let state = resolve( + snapshot: snapshot, + enrollmentState: .idle, + isSignedIn: true + ) + + guard case .failed = state else { + XCTFail("Invalid snapshot must fail closed, got \(state)") + continue + } + } + } + + func testRetainedHostIsMarkedReconnectingWhenWatchFails() { + let state = resolve( + snapshot: makeSnapshot(enrollment: .attached), + loadState: .failed("State stream ended."), + enrollmentState: .ready(machineID: "fltm_test"), + isSignedIn: true + ) + + guard case .enrolled(_, freshness: .reconnecting(let message)) = state else { + XCTFail("Expected a retained host marked as reconnecting, got \(state)") + return + } + XCTAssertEqual(message, "State stream ended.") + } + + func testRetainedUnenrolledSnapshotCannotStartEnrollmentWhileWatchReconnects() { + let state = resolve( + snapshot: makeSnapshot(enrollment: .unenrolled, machineID: nil), + loadState: .failed("State stream ended."), + enrollmentState: .idle, + isSignedIn: true + ) + + XCTAssertEqual(state, .unavailable("State stream ended.")) + } + + func testRetainedCredentialRejectedSnapshotShowsUnavailableWhileWatchReconnects() { + let state = resolve( + snapshot: makeSnapshot(enrollment: .credentialRejected), + loadState: .failed("State stream ended."), + enrollmentState: .idle, + isSignedIn: true + ) + + XCTAssertEqual(state, .unavailable("State stream ended.")) + } + + func testRetainedDetachedSnapshotShowsConnectingWhileWatchReconnects() { + let state = resolve( + snapshot: makeSnapshot(enrollment: .detached), + loadState: .connecting, + enrollmentState: .idle, + isSignedIn: true + ) + + XCTAssertEqual(state, .connecting) + } + + func testCoordinatorReadyWithoutSnapshotDoesNotSynthesizeHost() { + XCTAssertEqual( + resolve( + snapshot: nil, + loadState: .ready, + enrollmentState: .ready(machineID: "fltm_test"), + isSignedIn: true + ), + .connecting + ) + } + + private func makeSnapshot( + enrollment: FleetEnrollmentState, + machineID: String? = "fltm_test", + isDraining: Bool = false, + capabilities: [FleetCapability] = [], + jobs: [FleetInFlightJob] = [] + ) -> FleetAgentSnapshot { + FleetAgentSnapshot( + enrollment: enrollment, + machineID: machineID, + isDraining: isDraining, + capabilities: capabilities, + inFlightJobs: jobs, + recentVerdicts: [], + telemetry: nil, + settings: nil + ) + } + + private func resolve( + snapshot: FleetAgentSnapshot?, + loadState: FleetLoadState = .ready, + enrollmentState: FleetEnrollmentCoordinator.State?, + isSignedIn: Bool?, + canBeginEnrollment: Bool? = true + ) -> RunnersViewState { + RunnersViewModel.resolveViewState( + snapshot: snapshot, + agentInfo: nil, + loadState: loadState, + enrollmentContext: enrollmentState.map { + RunnersViewModel.EnrollmentContext( + state: $0, + isSignedIn: isSignedIn ?? false, + canBeginEnrollment: canBeginEnrollment ?? false + ) + } + ) + } + + private func enrolledHost(from state: RunnersViewState) -> RunnerHostViewModel? { + guard case .enrolled(let host, freshness: _) = state else { return nil } + return host + } +} diff --git a/Local.xcconfig.example b/Local.xcconfig.example index 1208cfa6..f20b99b9 100644 --- a/Local.xcconfig.example +++ b/Local.xcconfig.example @@ -5,20 +5,24 @@ DEVELOPMENT_TEAM = YOUR_TEAM_ID_HERE SENTRY_DSN = YOUR_SENTRY_DSN_HERE POSTHOG_API_KEY = YOUR_POSTHOG_API_KEY_HERE -// OIDC sign-in for the ArcBox platform. Leave the placeholders to run with -// sign-in unconfigured, or point at a local Dex instance for development -// (see OIDCClientConfiguration.swift in Packages/ArcBoxAuth for the exact -// Dex static-client shape). The $() below is required: xcconfig treats "//" -// as a comment even inside values, so an empty expansion splits the slashes; -// the resolved value is a normal http:// URL. -// OIDC_ISSUER_URL = http:/$()/localhost:5556/dex -// OIDC_CLIENT_ID = arcbox-desktop +// Sign-in for the ArcBox platform, via the Better Auth device-authorization +// grant. The issuer is the Better Auth base URL; its device endpoints and +// approval page live under it (see AuthClientConfiguration.swift in +// Packages/ArcBoxAuth). Leave the placeholders to run with sign-in +// unconfigured. The variable names predate the device-grant flow and stay +// stable so CI and release configuration keep working. The $() below is +// required: xcconfig treats "//" as a comment even inside values, so an +// empty expansion splits the slashes; the resolved value is a normal URL. // Production (what release builds ship with, see release.yml): // OIDC_ISSUER_URL = https:/$()/auth.arcbox.dev/api/auth // OIDC_CLIENT_ID = arcbox-desktop OIDC_ISSUER_URL = YOUR_OIDC_ISSUER_URL_HERE OIDC_CLIENT_ID = YOUR_OIDC_CLIENT_ID_HERE +// Fleet Platform REST API used to list workspaces and issue enrollment tokens. +// For a local Platform stack, use http:/$()/localhost:2801. +FLEET_PLATFORM_BASE_URL = https:/$()/api.arcbox.dev + // Optional local-only app identity overrides. Uncomment to use the shared dev preset. // ARCBOX_PRODUCT_BUNDLE_IDENTIFIER = $(ARCBOX_DEV_PRODUCT_BUNDLE_IDENTIFIER) // ARCBOX_PRODUCT_NAME = $(ARCBOX_DEV_PRODUCT_NAME) diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Configuration/OIDCClientConfiguration.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Configuration/AuthClientConfiguration.swift similarity index 53% rename from Packages/ArcBoxAuth/Sources/ArcBoxAuth/Configuration/OIDCClientConfiguration.swift rename to Packages/ArcBoxAuth/Sources/ArcBoxAuth/Configuration/AuthClientConfiguration.swift index 28361988..1e4fd759 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Configuration/OIDCClientConfiguration.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Configuration/AuthClientConfiguration.swift @@ -1,55 +1,40 @@ import Foundation -/// Identifies the OIDC provider the app authenticates against. +/// Identifies the Better Auth identity provider the app authenticates +/// against via the OAuth 2.0 device-authorization grant (RFC 8628). /// -/// Only the issuer and client ID vary per environment; the redirect URI and -/// scopes are facts about this app and are compiled in. All other endpoints -/// are discovered at runtime from `{issuer}/.well-known/openid-configuration`. -/// -/// To develop against a local IdP before the platform provider is registered, -/// run Dex with a static client shaped exactly like this (Dex rejects -/// custom-scheme redirect URIs unless they are listed explicitly): -/// -/// ```yaml -/// staticClients: -/// - id: arcbox-desktop -/// name: ArcBox Desktop -/// public: true -/// redirectURIs: -/// - com.arcboxlabs.desktop:/oauth2redirect -/// ``` -/// -/// then point your `Local.xcconfig` at it: -/// -/// ``` -/// OIDC_ISSUER_URL = http://localhost:5556/dex -/// OIDC_CLIENT_ID = arcbox-desktop -/// ``` -public struct OIDCClientConfiguration: Sendable, Equatable { +/// Only the provider base URL and client ID vary per environment. All +/// endpoints are fixed paths under the base URL — the device endpoints are +/// registered by the provider's `deviceAuthorization()` plugin and are not +/// part of OIDC discovery. +public struct AuthClientConfiguration: Sendable, Equatable { + /// Better Auth base URL, e.g. `https://auth.arcbox.dev/api/auth`. public let issuerURL: URL public let clientID: String - /// Registered with the platform IdP; changing it requires coordinated - /// re-registration server-side, so treat it as stable once shipped. - public static let redirectURI = URL(string: "com.arcboxlabs.desktop:/oauth2redirect")! - public static let scopes = ["openid", "profile", "email", "offline_access"] - public init(issuerURL: URL, clientID: String) { self.issuerURL = issuerURL self.clientID = clientID } - /// Inert default used when no issuer is configured: `.invalid` is an + var deviceCodeEndpoint: URL { issuerURL.appending(path: "device/code") } + var deviceTokenEndpoint: URL { issuerURL.appending(path: "device/token") } + var sessionEndpoint: URL { issuerURL.appending(path: "get-session") } + var signOutEndpoint: URL { issuerURL.appending(path: "sign-out") } + + /// Inert default used when no provider is configured: `.invalid` is an /// RFC 2606 reserved TLD, so sign-in fails fast with a clear error /// instead of reaching a live host. - public static let placeholder = OIDCClientConfiguration( + public static let placeholder = AuthClientConfiguration( issuerURL: URL(string: "https://auth.arcbox.invalid")!, clientID: "arcbox-desktop-placeholder" ) /// Configuration resolved from Info.plist (`OIDCIssuerURL`/`OIDCClientID`, /// injected via the `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID` build settings), - /// falling back to `.placeholder` when unconfigured. + /// falling back to `.placeholder` when unconfigured. The key names + /// predate the device-grant flow and stay stable so existing build + /// configurations and CI keep working. public static let current = resolve( issuer: Bundle.main.object(forInfoDictionaryKey: "OIDCIssuerURL") as? String, @@ -69,12 +54,12 @@ public struct OIDCClientConfiguration: Sendable, Equatable { /// Treats empty strings, unexpanded `$(VAR)` references, and /// `YOUR_..._HERE` sentinels as unconfigured — the same guard the /// Sentry/PostHog Info.plist keys use. - static func resolve(issuer: String?, clientID: String?) -> OIDCClientConfiguration? { + static func resolve(issuer: String?, clientID: String?) -> AuthClientConfiguration? { guard let issuer = configuredValue(issuer), let clientID = configuredValue(clientID), let issuerURL = URL(string: issuer) else { return nil } - return OIDCClientConfiguration(issuerURL: issuerURL, clientID: clientID) + return AuthClientConfiguration(issuerURL: issuerURL, clientID: clientID) } private static func configuredValue(_ raw: String?) -> String? { diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/PKCE/PKCE.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/PKCE/PKCE.swift deleted file mode 100644 index 68a6d4c1..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/PKCE/PKCE.swift +++ /dev/null @@ -1,29 +0,0 @@ -import CryptoKit -import Foundation -import Security - -/// Proof Key for Code Exchange (RFC 7636) values for one authorization attempt. -public struct PKCECodePair: Sendable, Equatable { - public let verifier: String - /// `base64url(SHA256(verifier))` — the S256 challenge method. - public let challenge: String -} - -public enum PKCE { - public static func generateCodePair() -> PKCECodePair { - let verifier = generateRandomToken() - return PKCECodePair(verifier: verifier, challenge: challenge(for: verifier)) - } - - /// CSPRNG token, base64url-encoded; also used for `state` and `nonce`. - public static func generateRandomToken(byteCount: Int = 32) -> String { - var bytes = [UInt8](repeating: 0, count: byteCount) - let status = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) - precondition(status == errSecSuccess, "SecRandomCopyBytes failed with status \(status)") - return Data(bytes).base64URLEncodedString() - } - - static func challenge(for verifier: String) -> String { - Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncodedString() - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/AuthError.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/AuthError.swift new file mode 100644 index 00000000..ab04b975 --- /dev/null +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/AuthError.swift @@ -0,0 +1,37 @@ +import Foundation + +public enum AuthError: Error, Sendable, Equatable { + /// The build carries the placeholder configuration; sign-in cannot work. + case notConfigured + /// The user rejected the device authorization in the browser. + case authorizationDenied + /// The device code expired before the browser approval completed. + case deviceCodeExpired + /// The provider answered with an unexpected status. `body` is truncated + /// so raw provider responses never flood logs or the UI. + case requestFailed(status: Int, body: String) + case malformedResponse(String) + case notSignedIn + /// Transport-level failure, carried as a description to stay Equatable. + case network(String) + + /// Short message suitable for the Account UI. + public var userMessage: String { + switch self { + case .notConfigured: + "No sign-in service is configured for this build. See Local.xcconfig.example." + case .authorizationDenied: + "Sign-in was denied in the browser." + case .deviceCodeExpired: + "The sign-in request expired before it was approved. Please try again." + case .requestFailed: + "Sign-in failed while contacting the ArcBox account service. Please try again." + case .malformedResponse: + "The ArcBox account service sent an unexpected response." + case .notSignedIn: + "Your session has expired. Please sign in again." + case .network(let description): + "Network error: \(description)" + } + } +} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/AuthProviding.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/AuthProviding.swift new file mode 100644 index 00000000..9622e325 --- /dev/null +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/AuthProviding.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Provider-level auth operations, abstracted so `AuthSession` can be tested +/// against a fake provider. +public protocol AuthProviding: Sendable { + /// Starts a device authorization (RFC 8628 §3.1). + func requestDeviceCode( + configuration: AuthClientConfiguration + ) async throws -> DeviceCodeGrant + + /// One poll of the token endpoint. Terminal denial/expiry throw. + func pollDeviceToken( + deviceCode: String, + configuration: AuthClientConfiguration + ) async throws -> DevicePollOutcome + + /// Validates the session token and returns the provider's view of it, + /// or `nil` when the provider authoritatively reports no session. + /// Transport and server failures throw instead, so callers never treat + /// an outage as a sign-out. + func session( + token: String, + configuration: AuthClientConfiguration + ) async throws -> SessionSnapshot? + + /// Revokes the session server-side. + func signOut( + token: String, + configuration: AuthClientConfiguration + ) async throws +} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/BetterAuthClient.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/BetterAuthClient.swift new file mode 100644 index 00000000..c00d150d --- /dev/null +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/BetterAuthClient.swift @@ -0,0 +1,225 @@ +import Foundation + +/// URLSession-backed implementation of the Better Auth calls. +/// +/// All endpoints accept JSON request bodies and answer JSON; errors follow +/// RFC 8628 (`{"error": ..., "error_description": ...}` with HTTP 400). +public final class BetterAuthClient: AuthProviding, Sendable { + private let session: URLSession + + public convenience init() { + self.init(session: URLSession(configuration: Self.makeSessionConfiguration())) + } + + public init(session: URLSession) { + self.session = session + } + + public func requestDeviceCode( + configuration: AuthClientConfiguration + ) async throws -> DeviceCodeGrant { + let (data, status) = try await post( + configuration.deviceCodeEndpoint, + body: ["client_id": configuration.clientID]) + return try Self.decodeDeviceCodeGrant(data: data, status: status) + } + + public func pollDeviceToken( + deviceCode: String, + configuration: AuthClientConfiguration + ) async throws -> DevicePollOutcome { + let (data, status) = try await post( + configuration.deviceTokenEndpoint, + body: [ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": deviceCode, + "client_id": configuration.clientID, + ]) + return try Self.decodePollOutcome(data: data, status: status) + } + + public func session( + token: String, + configuration: AuthClientConfiguration + ) async throws -> SessionSnapshot? { + var request = URLRequest(url: configuration.sessionEndpoint) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, status) = try await perform(request) + return try Self.decodeSessionSnapshot(data: data, status: status) + } + + public func signOut( + token: String, + configuration: AuthClientConfiguration + ) async throws { + var request = URLRequest(url: configuration.signOutEndpoint) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = Data("{}".utf8) + let (data, status) = try await perform(request) + guard (200..<300).contains(status) else { + throw AuthError.requestFailed(status: status, body: Self.truncated(data)) + } + } + + // MARK: - Internal (unit-tested via @testable) + + /// Device grant is bearer-only. Keeping it cookie-free avoids browser CSRF + /// origin checks and prevents auth responses from creating hidden state. + static func makeSessionConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + return configuration + } + + /// Removes cookies persisted by builds that used `URLSession.shared`. + static func clearLegacyCookies(for url: URL) { + let storage = HTTPCookieStorage.shared + for cookie in storage.cookies(for: url) ?? [] where isLegacyAuthCookie(cookie) { + storage.deleteCookie(cookie) + } + } + + static func decodeDeviceCodeGrant(data: Data, status: Int) throws -> DeviceCodeGrant { + guard status == 200 else { + throw AuthError.requestFailed(status: status, body: truncated(data)) + } + do { + return try JSONDecoder().decode(DeviceCodeGrant.self, from: data) + } catch { + throw AuthError.malformedResponse("malformed device authorization response") + } + } + + static func decodePollOutcome(data: Data, status: Int) throws -> DevicePollOutcome { + if status == 200 { + guard let success = try? JSONDecoder().decode(TokenSuccess.self, from: data) else { + throw AuthError.malformedResponse("malformed device token response") + } + return .granted( + DeviceTokenGrant( + sessionToken: success.accessToken, + expiresAt: success.expiresIn.map { Date(timeIntervalSinceNow: $0) } + )) + } + guard let failure = try? JSONDecoder().decode(TokenFailure.self, from: data) else { + throw AuthError.requestFailed(status: status, body: truncated(data)) + } + switch failure.error { + case "authorization_pending": + return .authorizationPending + case "slow_down": + return .slowDown + case "access_denied": + throw AuthError.authorizationDenied + case "expired_token": + throw AuthError.deviceCodeExpired + default: + throw AuthError.requestFailed( + status: status, body: failure.errorDescription ?? failure.error) + } + } + + static func decodeSessionSnapshot(data: Data, status: Int) throws -> SessionSnapshot? { + switch status { + case 200: + let body = String(bytes: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let body, !body.isEmpty, body != "null" else { return nil } + do { + return try decoder.decode(SessionSnapshot.self, from: data) + } catch { + throw AuthError.malformedResponse("malformed session response") + } + case 401, 403: + return nil + default: + throw AuthError.requestFailed(status: status, body: truncated(data)) + } + } + + /// Caps response bodies carried inside errors so provider responses + /// never flood logs or the UI. + static func truncated(_ data: Data, limit: Int = 200) -> String { + let body = String(bytes: data, encoding: .utf8) ?? "" + return body.count <= limit ? body : String(body.prefix(limit)) + "…" + } + + // MARK: - Private + + /// RFC 3339 timestamps, with or without fractional seconds. + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let value = try decoder.singleValueContainer().decode(String.self) + let fractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + if let date = try? fractional.parse(value) { + return date + } + let wholeSeconds = Date.ISO8601FormatStyle() + if let date = try? wholeSeconds.parse(value) { + return date + } + throw DecodingError.dataCorruptedError( + in: try decoder.singleValueContainer(), + debugDescription: "Invalid RFC 3339 date" + ) + } + return decoder + }() + + private func post(_ url: URL, body: [String: String]) async throws -> (Data, Int) { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONEncoder().encode(body) + return try await perform(request) + } + + private func perform(_ request: URLRequest) async throws -> (Data, Int) { + if let url = request.url { + Self.clearLegacyCookies(for: url) + } + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.network(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw AuthError.network("non-HTTP response") + } + return (data, http.statusCode) + } + + private static func isLegacyAuthCookie(_ cookie: HTTPCookie) -> Bool { + cookie.name.hasPrefix("better-auth.") + || cookie.name.hasPrefix("__Secure-better-auth.") + } +} + +private struct TokenSuccess: Decodable { + let accessToken: String + let expiresIn: TimeInterval? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresIn = "expires_in" + } +} + +private struct TokenFailure: Decodable { + let error: String + let errorDescription: String? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + } +} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/DeviceAuthorization.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/DeviceAuthorization.swift new file mode 100644 index 00000000..4257808f --- /dev/null +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/DeviceAuthorization.swift @@ -0,0 +1,77 @@ +import Foundation + +/// A pending device authorization issued by the provider (RFC 8628 §3.2). +public struct DeviceCodeGrant: Sendable, Equatable, Decodable { + /// Opaque code the app polls the token endpoint with. Never shown. + public let deviceCode: String + /// Short code the user confirms in the browser. + public let userCode: String + public let verificationURI: URL + /// Verification URL with the user code pre-filled, when provided. + public let verificationURIComplete: URL? + /// Lifetime of the device code, in seconds. + public let expiresIn: TimeInterval + /// Minimum polling interval, in seconds. + public let interval: TimeInterval? + + public init( + deviceCode: String, + userCode: String, + verificationURI: URL, + verificationURIComplete: URL? = nil, + expiresIn: TimeInterval, + interval: TimeInterval? = nil + ) { + self.deviceCode = deviceCode + self.userCode = userCode + self.verificationURI = verificationURI + self.verificationURIComplete = verificationURIComplete + self.expiresIn = expiresIn + self.interval = interval + } + + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case verificationURI = "verification_uri" + case verificationURIComplete = "verification_uri_complete" + case expiresIn = "expires_in" + case interval + } +} + +/// The approved outcome of the device flow. The provider's `access_token` +/// is an opaque Better Auth session token with a sliding server-side expiry. +public struct DeviceTokenGrant: Sendable, Equatable { + public let sessionToken: String + public let expiresAt: Date? + + public init(sessionToken: String, expiresAt: Date?) { + self.sessionToken = sessionToken + self.expiresAt = expiresAt + } +} + +/// One poll of the device token endpoint (RFC 8628 §3.4/§3.5). Terminal +/// failures (`access_denied`, `expired_token`) are thrown as `AuthError`. +public enum DevicePollOutcome: Sendable, Equatable { + case granted(DeviceTokenGrant) + case authorizationPending + case slowDown +} + +/// What the UI shows while the browser approval is pending. +public struct DeviceAuthorizationPrompt: Sendable, Equatable { + public let userCode: String + public let verificationURI: URL + public let verificationURIComplete: URL? + + public init(userCode: String, verificationURI: URL, verificationURIComplete: URL?) { + self.userCode = userCode + self.verificationURI = verificationURI + self.verificationURIComplete = verificationURIComplete + } + + /// The URL to open: pre-filled variant when available. + public var browserURL: URL { verificationURIComplete ?? verificationURI } +} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/IDTokenClaims.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/IDTokenClaims.swift deleted file mode 100644 index bd647402..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/IDTokenClaims.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -/// Claims read from the ID token for display purposes. -/// -/// The payload is decoded WITHOUT verifying the JWT signature: these values -/// must never feed an authorization decision. The access token — validated by -/// the platform server-side — is the actual authorization artifact. -public struct IDTokenClaims: Sendable, Equatable { - public let subject: String - public let email: String? - public let name: String? - public let nonce: String? - public let expiresAt: Date - - public static func decode(idToken: String) throws -> IDTokenClaims { - let segments = idToken.components(separatedBy: ".") - guard segments.count == 3, let payload = Data(base64URLEncoded: segments[1]) else { - throw OIDCError.invalidIDToken - } - let raw: RawClaims - do { - raw = try JSONDecoder().decode(RawClaims.self, from: payload) - } catch { - throw OIDCError.invalidIDToken - } - return IDTokenClaims( - subject: raw.sub, - email: raw.email, - name: raw.name, - nonce: raw.nonce, - expiresAt: Date(timeIntervalSince1970: raw.exp) - ) - } - - private struct RawClaims: Decodable { - let sub: String - let email: String? - let name: String? - let nonce: String? - let exp: TimeInterval - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCAuthorizationURLBuilder.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCAuthorizationURLBuilder.swift deleted file mode 100644 index 38debb2b..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCAuthorizationURLBuilder.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -public enum OIDCAuthorizationURLBuilder { - public static func makeURL( - endpoints: OIDCEndpoints, - configuration: OIDCClientConfiguration, - pkce: PKCECodePair, - state: String, - nonce: String - ) throws -> URL { - guard - var components = URLComponents( - url: endpoints.authorizationEndpoint, resolvingAgainstBaseURL: false) - else { - throw OIDCError.invalidAuthorizationEndpoint - } - var query = components.queryItems ?? [] - query.append(contentsOf: [ - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "client_id", value: configuration.clientID), - URLQueryItem(name: "redirect_uri", value: OIDCClientConfiguration.redirectURI.absoluteString), - URLQueryItem(name: "scope", value: OIDCClientConfiguration.scopes.joined(separator: " ")), - URLQueryItem(name: "state", value: state), - URLQueryItem(name: "nonce", value: nonce), - URLQueryItem(name: "code_challenge", value: pkce.challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - ]) - components.queryItems = query - guard let url = components.url else { - throw OIDCError.invalidAuthorizationEndpoint - } - return url - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCClient.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCClient.swift deleted file mode 100644 index 81254fdc..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCClient.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation - -/// URLSession-backed implementation of the OIDC protocol calls. -public final class OIDCClient: OIDCProviding, Sendable { - private let session: URLSession - - public init(session: URLSession = .shared) { - self.session = session - } - - public func discover(issuer: URL) async throws -> OIDCEndpoints { - let url = issuer.appending(path: ".well-known/openid-configuration") - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, status) = try await perform(request) - guard status == 200 else { - throw OIDCError.discoveryFailed("HTTP \(status) from \(url.absoluteString)") - } - do { - return try JSONDecoder().decode(OIDCEndpoints.self, from: data) - } catch { - throw OIDCError.discoveryFailed("malformed discovery document") - } - } - - public func exchangeCode( - _ code: String, - verifier: String, - configuration: OIDCClientConfiguration, - endpoints: OIDCEndpoints - ) async throws -> TokenResponse { - try await tokenRequest( - endpoints.tokenEndpoint, - fields: [ - "grant_type": "authorization_code", - "code": code, - "code_verifier": verifier, - "client_id": configuration.clientID, - "redirect_uri": OIDCClientConfiguration.redirectURI.absoluteString, - ]) - } - - public func refresh( - refreshToken: String, - configuration: OIDCClientConfiguration, - endpoints: OIDCEndpoints - ) async throws -> TokenResponse { - try await tokenRequest( - endpoints.tokenEndpoint, - fields: [ - "grant_type": "refresh_token", - "refresh_token": refreshToken, - "client_id": configuration.clientID, - ]) - } - - public func revoke( - token: String, - tokenTypeHint: String, - configuration: OIDCClientConfiguration, - endpoint: URL - ) async throws { - let (data, status) = try await post( - endpoint, - fields: [ - "token": token, - "token_type_hint": tokenTypeHint, - "client_id": configuration.clientID, - ]) - guard (200..<300).contains(status) else { - throw OIDCError.tokenRequestFailed( - status: status, body: String(bytes: data, encoding: .utf8) ?? "") - } - } - - public func userInfo(accessToken: String, endpoint: URL) async throws -> OIDCUserInfo { - var request = URLRequest(url: endpoint) - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, status) = try await perform(request) - return try Self.decodeUserInfo(data: data, status: status) - } - - // MARK: - Internal (unit-tested via @testable) - - static func decodeUserInfo(data: Data, status: Int) throws -> OIDCUserInfo { - guard status == 200 else { - throw OIDCError.userInfoFailed( - status: status, body: String(bytes: data, encoding: .utf8) ?? "") - } - do { - return try JSONDecoder().decode(OIDCUserInfo.self, from: data) - } catch { - throw OIDCError.userInfoFailed(status: status, body: "malformed userinfo response") - } - } - - static func decodeTokenResponse(data: Data, status: Int) throws -> TokenResponse { - guard (200..<300).contains(status) else { - throw OIDCError.tokenRequestFailed( - status: status, body: String(bytes: data, encoding: .utf8) ?? "") - } - do { - return try JSONDecoder().decode(TokenResponse.self, from: data) - } catch { - throw OIDCError.tokenRequestFailed(status: status, body: "malformed token response") - } - } - - /// `application/x-www-form-urlencoded` body; keys sorted for determinism. - static func formBody(_ fields: [String: String]) -> Data { - let pairs = fields.sorted { $0.key < $1.key } - .map { "\(formEncode($0.key))=\(formEncode($0.value))" } - return Data(pairs.joined(separator: "&").utf8) - } - - private static let unreserved = CharacterSet( - charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") - - private static func formEncode(_ value: String) -> String { - value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value - } - - // MARK: - Private - - private func tokenRequest(_ url: URL, fields: [String: String]) async throws -> TokenResponse { - let (data, status) = try await post(url, fields: fields) - return try Self.decodeTokenResponse(data: data, status: status) - } - - private func post(_ url: URL, fields: [String: String]) async throws -> (Data, Int) { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = Self.formBody(fields) - return try await perform(request) - } - - private func perform(_ request: URLRequest) async throws -> (Data, Int) { - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - throw OIDCError.network(error.localizedDescription) - } - guard let http = response as? HTTPURLResponse else { - throw OIDCError.network("non-HTTP response") - } - return (data, http.statusCode) - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCEndpoints.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCEndpoints.swift deleted file mode 100644 index bd2a4d8f..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCEndpoints.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -/// Endpoints advertised by the provider's discovery document -/// (`{issuer}/.well-known/openid-configuration`). -public struct OIDCEndpoints: Sendable, Equatable, Decodable { - public let authorizationEndpoint: URL - public let tokenEndpoint: URL - /// RFC 7009 token revocation; not all providers implement it. - public let revocationEndpoint: URL? - public let endSessionEndpoint: URL? - public let userinfoEndpoint: URL? - - public init( - authorizationEndpoint: URL, - tokenEndpoint: URL, - revocationEndpoint: URL? = nil, - endSessionEndpoint: URL? = nil, - userinfoEndpoint: URL? = nil - ) { - self.authorizationEndpoint = authorizationEndpoint - self.tokenEndpoint = tokenEndpoint - self.revocationEndpoint = revocationEndpoint - self.endSessionEndpoint = endSessionEndpoint - self.userinfoEndpoint = userinfoEndpoint - } - - enum CodingKeys: String, CodingKey { - case authorizationEndpoint = "authorization_endpoint" - case tokenEndpoint = "token_endpoint" - case revocationEndpoint = "revocation_endpoint" - case endSessionEndpoint = "end_session_endpoint" - case userinfoEndpoint = "userinfo_endpoint" - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCError.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCError.swift deleted file mode 100644 index 002d88c2..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCError.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -public enum OIDCError: Error, Sendable, Equatable { - case discoveryFailed(String) - case invalidAuthorizationEndpoint - case invalidCallbackURL - /// The provider redirected back with an `error` parameter. - case authorizationDenied(String) - /// The `state` echoed by the provider does not match what we sent (CSRF guard). - case stateMismatch - case missingAuthorizationCode - case tokenRequestFailed(status: Int, body: String) - case userInfoFailed(status: Int, body: String) - case missingRefreshToken - case invalidIDToken - case notSignedIn - /// Transport-level failure, carried as a description to stay Equatable. - case network(String) - - /// Short message suitable for the Account UI. - public var userMessage: String { - switch self { - case .discoveryFailed: - "Could not reach the sign-in service. Check the OIDC issuer configuration." - case .invalidAuthorizationEndpoint, .invalidCallbackURL, .missingAuthorizationCode: - "Sign-in failed: the provider sent an unexpected response." - case .authorizationDenied(let reason): - "Sign-in was denied: \(reason)" - case .stateMismatch, .invalidIDToken: - "Sign-in failed a security check. Please try again." - case .tokenRequestFailed: - "Sign-in failed while exchanging credentials. Please try again." - case .userInfoFailed: - "Could not load your profile. Please try again." - case .missingRefreshToken, .notSignedIn: - "Your session has expired. Please sign in again." - case .network(let description): - "Network error: \(description)" - } - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCProviding.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCProviding.swift deleted file mode 100644 index 15fd8f57..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCProviding.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -/// Protocol-level OIDC operations, abstracted so `AuthSession` can be tested -/// against a fake provider. -public protocol OIDCProviding: Sendable { - func discover(issuer: URL) async throws -> OIDCEndpoints - - func exchangeCode( - _ code: String, - verifier: String, - configuration: OIDCClientConfiguration, - endpoints: OIDCEndpoints - ) async throws -> TokenResponse - - func refresh( - refreshToken: String, - configuration: OIDCClientConfiguration, - endpoints: OIDCEndpoints - ) async throws -> TokenResponse - - /// RFC 7009 revocation. Best-effort: callers decide whether to surface failures. - func revoke( - token: String, - tokenTypeHint: String, - configuration: OIDCClientConfiguration, - endpoint: URL - ) async throws - - /// OIDC Core §5.3 userinfo request with a Bearer access token. - func userInfo(accessToken: String, endpoint: URL) async throws -> OIDCUserInfo -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCUserInfo.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCUserInfo.swift deleted file mode 100644 index 05911080..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/OIDCUserInfo.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -/// Profile claims from the userinfo endpoint (OIDC Core §5.3). -/// -/// The platform IdP never embeds profile claims in ID tokens, so this is the -/// sole source of name/email/avatar. Every field except `subject` is -/// scope-gated and optional: `name`/`picture` require `profile`, -/// `email`/`emailVerified` require `email`, and the provider omits claims -/// whose backing user fields are unset (e.g. no `picture` until the account -/// has an avatar). -public struct OIDCUserInfo: Sendable, Equatable, Decodable { - public let subject: String - public let name: String? - public let email: String? - public let emailVerified: Bool? - public let picture: URL? - - public init( - subject: String, - name: String? = nil, - email: String? = nil, - emailVerified: Bool? = nil, - picture: URL? = nil - ) { - self.subject = subject - self.name = name - self.email = email - self.emailVerified = emailVerified - self.picture = picture - } - - enum CodingKeys: String, CodingKey { - case subject = "sub" - case name - case email - case emailVerified = "email_verified" - case picture - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/SessionSnapshot.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/SessionSnapshot.swift new file mode 100644 index 00000000..5febd7ef --- /dev/null +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/SessionSnapshot.swift @@ -0,0 +1,47 @@ +import Foundation + +/// The provider's view of a session, from `GET {issuer}/get-session`. +/// +/// A syntactically valid but revoked or expired token yields `null` (mapped +/// to `nil` by the client), which is the authoritative "signed out" signal. +public struct SessionSnapshot: Sendable, Equatable, Decodable { + public let session: SessionDetails + public let user: SessionUser + + public init(session: SessionDetails, user: SessionUser) { + self.session = session + self.user = user + } +} + +public struct SessionDetails: Sendable, Equatable, Decodable { + /// Sliding expiry; the provider extends it as the session is used. + public let expiresAt: Date? + + public init(expiresAt: Date?) { + self.expiresAt = expiresAt + } +} + +public struct SessionUser: Sendable, Equatable, Decodable { + public let id: String + public let name: String? + public let email: String? + public let emailVerified: Bool? + /// Avatar URL as sent by the provider; may be absent or empty. + public let image: String? + + public init( + id: String, + name: String? = nil, + email: String? = nil, + emailVerified: Bool? = nil, + image: String? = nil + ) { + self.id = id + self.name = name + self.email = email + self.emailVerified = emailVerified + self.image = image + } +} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/TokenResponse.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/TokenResponse.swift deleted file mode 100644 index 5a6bb62d..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Protocol/TokenResponse.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -/// Token-endpoint response (RFC 6749 §5.1). -public struct TokenResponse: Decodable, Sendable { - public let accessToken: String - public let tokenType: String - /// Seconds until the access token expires; RECOMMENDED in the spec, so optional. - public let expiresIn: TimeInterval? - /// Only present when the provider grants offline access. - public let refreshToken: String? - public let idToken: String? - public let scope: String? - - public init( - accessToken: String, - tokenType: String = "bearer", - expiresIn: TimeInterval? = nil, - refreshToken: String? = nil, - idToken: String? = nil, - scope: String? = nil - ) { - self.accessToken = accessToken - self.tokenType = tokenType - self.expiresIn = expiresIn - self.refreshToken = refreshToken - self.idToken = idToken - self.scope = scope - } - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case tokenType = "token_type" - case expiresIn = "expires_in" - case refreshToken = "refresh_token" - case idToken = "id_token" - case scope - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AccessTokenProviding.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AccessTokenProviding.swift index 31ee8b90..e0b30cc1 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AccessTokenProviding.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AccessTokenProviding.swift @@ -2,6 +2,6 @@ /// token — refreshed under the hood when needed — without depending on /// SwiftUI or Observation. public protocol AccessTokenProviding: Sendable { - /// Throws `OIDCError.notSignedIn` when there is no session to refresh. + /// Throws `AuthError.notSignedIn` when there is no session. func accessToken() async throws -> String } diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession+SignIn.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession+SignIn.swift index de30a12b..4afc416b 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession+SignIn.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession+SignIn.swift @@ -1,110 +1,57 @@ -import AuthenticationServices import Foundation extension AuthSession { - /// Everything needed to finish the code exchange once the browser - /// redirects back, held while the browser leg is in flight. - struct PendingAuthorization { - let state: String - let verifier: String - let nonce: String - let endpoints: OIDCEndpoints - } - - /// Runs the full browser-based Authorization Code + PKCE flow. - /// - /// The caller supplies the platform-specific browser presentation. Failures - /// land in `status` rather than being thrown; a user-cancelled browser sheet - /// quietly returns to `.signedOut`. - /// - /// The redirect can come back two ways: the web session returns it - /// directly, or — when sign-in finishes in an external browser — Launch - /// Services delivers it as a deep link (`handleAuthorizationCallback`). - /// Both funnel into `finishAuthorization`; whichever arrives first wins. - public func signIn( - using authenticate: @MainActor (URL, String) async throws -> URL - ) async { - guard status != .signingIn else { return } - status = .signingIn - do { - let authorizationURL = try await beginAuthorization() - guard let scheme = OIDCClientConfiguration.redirectURI.scheme else { - throw OIDCError.invalidCallbackURL - } - let callbackURL = try await authenticate(authorizationURL, scheme) - await finishAuthorization(callbackURL: callbackURL) - } catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin { - // The user may have dismissed the sheet to finish in an external - // browser: keep `pendingAuthorization` so a deep-link callback - // can still complete, but stop showing progress. Don't clobber a - // session a deep link already established. - if status == .signingIn { status = .signedOut } - } catch let error as OIDCError { - ClientLog.auth.error("Sign-in failed: \(String(describing: error))") - status = .error(error.userMessage) - } catch { - ClientLog.auth.error("Sign-in failed: \(String(describing: error))") - status = .error(error.localizedDescription) - } - } - - /// Completes sign-in from an OAuth redirect delivered as a deep link - /// (`onOpenURL`) rather than through the web session — the path taken - /// when the authorization leg ends in an external browser. + /// Runs the device-authorization sign-in (RFC 8628): requests a device + /// code, opens the verification page in the default browser, and polls + /// the token endpoint until the user approves there. Failures land in + /// `status` rather than being thrown; cancellation quietly returns to + /// `.signedOut`. /// - /// Returns `false` when the URL is not this app's OAuth redirect, so the - /// caller can route it as an ordinary deep link. A redirect with no - /// sign-in in flight (stale or replayed callback) is consumed and - /// ignored. - @discardableResult - public func handleAuthorizationCallback(_ url: URL) async -> Bool { - let redirect = OIDCClientConfiguration.redirectURI - guard let scheme = url.scheme, let expectedScheme = redirect.scheme, - scheme.caseInsensitiveCompare(expectedScheme) == .orderedSame, - url.path(percentEncoded: false) == redirect.path(percentEncoded: false) - else { return false } - guard pendingAuthorization != nil else { - ClientLog.auth.warning("Ignoring OAuth callback: no sign-in in progress") - return true + /// While the browser approval is pending, `deviceAuthorization` carries + /// the user code and verification URL for display. + public func signIn() async { + guard status != .restoring, status != .signingIn, signInTask == nil else { return } + guard !configuration.isPlaceholder else { + status = .error(AuthError.notConfigured.userMessage) + return } - status = .signingIn - await finishAuthorization(callbackURL: url) - return true + // App-scoped task so `cancelSignIn()` can abandon the polling loop + // from anywhere (Cancel button, sign-out, termination). + let task = Task { await performDeviceSignIn() } + signInTask = task + await task.value + if signInTask == task { signInTask = nil } } - /// Builds the authorization request and records the context needed to - /// finish it. Split from `signIn(using:)` so tests can drive each leg - /// independently. - func beginAuthorization() async throws -> URL { - let endpoints = try await resolvedEndpoints() - let pkce = PKCE.generateCodePair() - let state = PKCE.generateRandomToken() - let nonce = PKCE.generateRandomToken() - let authorizationURL = try OIDCAuthorizationURLBuilder.makeURL( - endpoints: endpoints, - configuration: configuration, - pkce: pkce, - state: state, - nonce: nonce) - pendingAuthorization = PendingAuthorization( - state: state, verifier: pkce.verifier, nonce: nonce, endpoints: endpoints) - return authorizationURL + /// Abandons an in-flight sign-in. The device code simply expires + /// server-side; nothing needs revoking. + public func cancelSignIn() { + signInTask?.cancel() } - /// Single completion funnel: consumes `pendingAuthorization` exactly once - /// (MainActor serialization makes take-then-clear race-free) and maps - /// failures into `status`. A second arrival is a no-op. - private func finishAuthorization(callbackURL: URL) async { - guard let pending = pendingAuthorization else { return } - pendingAuthorization = nil + private func performDeviceSignIn() async { + status = .signingIn + defer { deviceAuthorization = nil } do { - try await completeSignIn( - callbackURL: callbackURL, - expectedState: pending.state, - verifier: pending.verifier, - nonce: pending.nonce, - endpoints: pending.endpoints) - } catch let error as OIDCError { + let grant = try await provider.requestDeviceCode(configuration: configuration) + try Task.checkCancellation() + let prompt = DeviceAuthorizationPrompt( + userCode: grant.userCode, + verificationURI: grant.verificationURI, + verificationURIComplete: grant.verificationURIComplete) + deviceAuthorization = prompt + openURL(prompt.browserURL) + + let granted = try await pollUntilGranted(grant: grant) + let stored = StoredSession( + sessionToken: granted.sessionToken, expiresAt: granted.expiresAt) + await persist(stored) + try Task.checkCancellation() + adopt(stored) + await refreshSession() + } catch is CancellationError { + if status == .signingIn { status = .signedOut } + } catch let error as AuthError { ClientLog.auth.error("Sign-in failed: \(String(describing: error))") status = .error(error.userMessage) } catch { @@ -113,44 +60,29 @@ extension AuthSession { } } - /// Security-critical completion: validates `state` (CSRF) and `nonce`, - /// exchanges the code, persists, and publishes the session. Split from - /// `signIn(using:)` so it is unit-testable. - func completeSignIn( - callbackURL: URL, - expectedState: String, - verifier: String, - nonce: String, - endpoints: OIDCEndpoints - ) async throws { - let query = Self.queryParameters(of: callbackURL) - if let errorCode = query["error"] { - throw OIDCError.authorizationDenied(query["error_description"] ?? errorCode) - } - guard let state = query["state"], state == expectedState else { - throw OIDCError.stateMismatch - } - guard let code = query["code"], !code.isEmpty else { - throw OIDCError.missingAuthorizationCode - } - let response = try await provider.exchangeCode( - code, verifier: verifier, configuration: configuration, endpoints: endpoints) - if let idToken = response.idToken { - let claims = try IDTokenClaims.decode(idToken: idToken) - if let tokenNonce = claims.nonce, tokenNonce != nonce { - throw OIDCError.invalidIDToken + /// RFC 8628 §3.5: waits the server-given interval between polls, + /// stretching by five seconds on `slow_down`, until approval, denial, + /// or device-code expiry. Elapsed time is accounted from the intervals + /// actually slept, so the injected sleeper fully drives the loop in tests. + private func pollUntilGranted(grant: DeviceCodeGrant) async throws -> DeviceTokenGrant { + var interval = max(grant.interval ?? 5, 1) + var elapsed: TimeInterval = 0 + while true { + try await sleeper(.seconds(interval)) + try Task.checkCancellation() + elapsed += interval + guard elapsed < grant.expiresIn else { throw AuthError.deviceCodeExpired } + + switch try await provider.pollDeviceToken( + deviceCode: grant.deviceCode, configuration: configuration) + { + case .granted(let token): + return token + case .authorizationPending: + continue + case .slowDown: + interval += 5 } } - adopt(Self.merge(response: response, into: nil), persist: true) - await loadUserInfo() - } - - private static func queryParameters(of url: URL) -> [String: String] { - guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - else { return [:] } - return Dictionary( - items.compactMap { item in item.value.map { (item.name, $0) } }, - uniquingKeysWith: { first, _ in first } - ) } } diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession.swift index 46bc5580..62b819e3 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthSession.swift @@ -1,53 +1,87 @@ +import AppKit import Foundation import Observation /// Observable session state for platform sign-in: restores from the Keychain -/// on launch, hands out valid access tokens (refreshing lazily), and signs out. +/// when started, hands out the session bearer token, and signs out. /// -/// The interactive browser leg lives in `AuthSession+SignIn.swift`. +/// The session token has a sliding server-side expiry that the provider +/// extends on use, so there is no client-side refresh: a token is valid +/// until the provider says otherwise (`refreshSession()`), and a platform +/// 401 means the user must sign in again. +/// +/// The interactive device-authorization leg lives in `AuthSession+SignIn.swift`. @Observable @MainActor public final class AuthSession: AccessTokenProviding { public internal(set) var status: AuthStatus = .signedOut - public private(set) var identity: AuthIdentity? - /// When the current access token expires, for display in the Account UI. - public private(set) var accessTokenExpiresAt: Date? + public internal(set) var identity: AuthIdentity? + /// The browser-approval prompt to display while sign-in is in flight. + public internal(set) var deviceAuthorization: DeviceAuthorizationPrompt? - public let configuration: OIDCClientConfiguration + public let configuration: AuthClientConfiguration - let provider: any OIDCProviding + let provider: any AuthProviding private let tokenStore: any TokenStoring - @ObservationIgnored private var tokens: StoredTokens? - @ObservationIgnored private var endpoints: OIDCEndpoints? - @ObservationIgnored private var refreshTask: Task? - /// Context for the in-flight browser leg, consumed exactly once by - /// whichever completion path returns first: the web-session result or a - /// deep-link callback (see `AuthSession+SignIn.swift`). - @ObservationIgnored var pendingAuthorization: PendingAuthorization? - - /// Refresh this long before nominal expiry to absorb clock skew. - private static let expiryLeeway: TimeInterval = 60 - /// Used when the token response omits the RECOMMENDED `expires_in`. - private static let defaultTokenLifetime: TimeInterval = 3600 + @ObservationIgnored var session: StoredSession? + @ObservationIgnored private var restorationTask: Task? + @ObservationIgnored private var didAttemptRestore = false + @ObservationIgnored var signInTask: Task? + /// Sleep seam so tests drive the polling loop without real delays. + @ObservationIgnored let sleeper: @Sendable (Duration) async throws -> Void + /// Browser seam so tests observe the verification URL being opened. + @ObservationIgnored let openURL: @MainActor (URL) -> Void public init( - configuration: OIDCClientConfiguration = .current, - provider: any OIDCProviding = OIDCClient(), - tokenStore: any TokenStoring = KeychainTokenStore() + configuration: AuthClientConfiguration = .current, + provider: any AuthProviding = BetterAuthClient(), + tokenStore: any TokenStoring = KeychainTokenStore(), + sleeper: @escaping @Sendable (Duration) async throws -> Void = { + try await Task.sleep(for: $0) + }, + openURL: (@MainActor (URL) -> Void)? = nil ) { self.configuration = configuration self.provider = provider self.tokenStore = tokenStore - restoreSession() + self.sleeper = sleeper + self.openURL = openURL ?? { NSWorkspace.shared.open($0) } } - /// Rehydrates state from the Keychain; no network. An expired access - /// token still restores the session — it refreshes on first use. - public func restoreSession() { + /// Rehydrates state from the Keychain; no network. The provider owns + /// expiry, so any stored token restores the session — `refreshSession()` + /// signs out if the provider no longer honors it. + public func restoreSession() async { + if let restorationTask { + await restorationTask.value + return + } + guard !didAttemptRestore else { return } + didAttemptRestore = true + guard status == .signedOut else { return } + status = .restoring + + let task = Task { [weak self] in + guard let self else { return } + await performSessionRestore() + } + restorationTask = task + await task.value + restorationTask = nil + } + + private func performSessionRestore() async { do { - guard let stored = try tokenStore.load() else { return } + let stored = try await tokenStore.load() + guard status == .restoring else { return } + guard let stored else { + status = .signedOut + return + } adopt(stored) } catch { + guard status == .restoring else { return } + status = .signedOut ClientLog.auth.error( "Failed to restore session from Keychain: \(String(describing: error))") } @@ -56,148 +90,99 @@ public final class AuthSession: AccessTokenProviding { // MARK: - AccessTokenProviding public func accessToken() async throws -> String { - guard let tokens else { throw OIDCError.notSignedIn } - if tokens.expiresAt > Date().addingTimeInterval(Self.expiryLeeway) { - return tokens.accessToken - } - return try await refreshedTokens().accessToken + guard let session else { throw AuthError.notSignedIn } + return session.sessionToken } - // MARK: - UserInfo + // MARK: - Session refresh - /// Fetches profile claims from the userinfo endpoint and publishes them - /// as `identity`. The platform IdP never embeds profile claims in ID - /// tokens, so this is the only source of name/email/avatar. Best-effort: - /// failures log and keep the existing identity. Runs automatically after - /// sign-in; the app also calls it after a restored launch. - public func loadUserInfo() async { - guard status == .signedIn else { return } + /// Verifies the session with the provider and publishes the account + /// identity (this is the sole source of name/email/avatar). A provider + /// that authoritatively reports no session signs the user out; transport + /// failures keep the local session. Runs automatically after sign-in; + /// the app also calls it after a restored launch. + public func refreshSession() async { + guard status == .signedIn, let token = session?.sessionToken else { return } do { - let endpoints = try await resolvedEndpoints() - guard let endpoint = endpoints.userinfoEndpoint else { - ClientLog.auth.warning("Provider advertises no userinfo endpoint") + guard + let snapshot = try await provider.session( + token: token, configuration: configuration) + else { + ClientLog.auth.info("Provider no longer honors the stored session; signing out") + await forgetSession() return } - let info = try await provider.userInfo( - accessToken: try await accessToken(), endpoint: endpoint) + // The session may have been signed out or replaced while the + // request was in flight. + guard session?.sessionToken == token else { return } identity = AuthIdentity( - subject: info.subject, - email: info.email ?? identity?.email, - name: info.name ?? identity?.name, - avatarURL: info.picture, - emailVerified: info.emailVerified) + subject: snapshot.user.id, + email: normalized(snapshot.user.email), + name: normalized(snapshot.user.name), + avatarURL: normalized(snapshot.user.image).flatMap(URL.init(string:)), + emailVerified: snapshot.user.emailVerified) + if let expiresAt = snapshot.session.expiresAt { + let updated = StoredSession(sessionToken: token, expiresAt: expiresAt) + session = updated + await persist(updated) + } ClientLog.auth.info( - "UserInfo loaded for \(info.subject, privacy: .private(mask: .hash))") + "Session verified for \(snapshot.user.id, privacy: .private(mask: .hash))") } catch { - ClientLog.auth.warning("UserInfo fetch failed: \(String(describing: error))") + ClientLog.auth.warning("Session refresh failed: \(String(describing: error))") } } // MARK: - Sign-out - /// Clears the session locally and best-effort revokes the refresh token. - /// Revocation is only attempted when discovery already ran this launch — - /// sign-out must never block on an unreachable issuer. + /// Revokes the session server-side (best-effort) and clears local state. + /// Sign-out must never block on an unreachable provider. public func signOut() async { - refreshTask?.cancel() - refreshTask = nil - if let refreshToken = tokens?.refreshToken, - let endpoint = endpoints?.revocationEndpoint - { + cancelSignIn() + if let token = session?.sessionToken { do { - try await provider.revoke( - token: refreshToken, - tokenTypeHint: "refresh_token", - configuration: configuration, - endpoint: endpoint) + try await provider.signOut(token: token, configuration: configuration) } catch { - ClientLog.auth.warning("Token revocation failed: \(String(describing: error))") + ClientLog.auth.warning( + "Server-side sign-out failed: \(String(describing: error))") } } - forgetSession() + await forgetSession() } // MARK: - Internal (shared with AuthSession+SignIn, tested via @testable) - func resolvedEndpoints() async throws -> OIDCEndpoints { - if let endpoints { return endpoints } - let discovered = try await provider.discover(issuer: configuration.issuerURL) - endpoints = discovered - return discovered - } - - /// Publishes a token set as the current session, optionally persisting it. - func adopt(_ stored: StoredTokens, persist: Bool = false) { - tokens = stored - accessTokenExpiresAt = stored.expiresAt - if let idToken = stored.idToken, - let claims = try? IDTokenClaims.decode(idToken: idToken) - { - identity = AuthIdentity(subject: claims.subject, email: claims.email, name: claims.name) - } + /// Publishes a stored session as the current one. Identity arrives + /// separately via `refreshSession()`. + func adopt(_ stored: StoredSession) { + session = stored status = .signedIn - guard persist else { return } - do { - try tokenStore.save(stored) - } catch { - // Keep the in-memory session; it just won't survive a relaunch. - ClientLog.auth.error("Failed to persist tokens: \(String(describing: error))") - } - } - - static func merge(response: TokenResponse, into current: StoredTokens?) -> StoredTokens { - StoredTokens( - accessToken: response.accessToken, - // Providers may rotate the refresh token; keep the old one when absent. - refreshToken: response.refreshToken ?? current?.refreshToken, - idToken: response.idToken ?? current?.idToken, - expiresAt: Date().addingTimeInterval(response.expiresIn ?? Self.defaultTokenLifetime) - ) - } - - // MARK: - Private - - /// De-duplicates concurrent refreshes: the MainActor serial executor plus - /// no `await` between the check and the store makes this race-free. - private func refreshedTokens() async throws -> StoredTokens { - if let refreshTask { return try await refreshTask.value } - let task = Task { try await performRefresh() } - refreshTask = task - defer { refreshTask = nil } - return try await task.value } - private func performRefresh() async throws -> StoredTokens { - guard let current = tokens else { throw OIDCError.notSignedIn } - guard let refreshToken = current.refreshToken else { - throw OIDCError.missingRefreshToken - } - let endpoints = try await resolvedEndpoints() + /// Persists the session without blocking the main actor. Persistence is + /// best-effort so a Keychain failure does not discard a valid live session. + func persist(_ stored: StoredSession) async { do { - let response = try await provider.refresh( - refreshToken: refreshToken, configuration: configuration, endpoints: endpoints) - let updated = Self.merge(response: response, into: current) - adopt(updated, persist: true) - return updated - } catch let error as OIDCError { - if case .tokenRequestFailed(let status, _) = error, status == 400 || status == 401 { - // invalid_grant: the refresh token is dead — the session is over. - forgetSession() - throw OIDCError.notSignedIn - } - throw error + try await tokenStore.save(stored) + } catch { + ClientLog.auth.error("Failed to persist session: \(String(describing: error))") } } - private func forgetSession() { - tokens = nil + func forgetSession() async { + session = nil identity = nil - accessTokenExpiresAt = nil + deviceAuthorization = nil status = .signedOut do { - try tokenStore.clear() + try await tokenStore.clear() } catch { ClientLog.auth.error("Failed to clear Keychain: \(String(describing: error))") } } + + private func normalized(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } } diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthStatus.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthStatus.swift index 68b4a75d..9aca361b 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthStatus.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Session/AuthStatus.swift @@ -2,15 +2,15 @@ import Foundation public enum AuthStatus: Sendable, Equatable { case signedOut + case restoring case signingIn case signedIn /// Sign-in failed; carries a user-presentable message. case error(String) } -/// Who is signed in, for display purposes only. Seeded from the ID token -/// (which this provider limits to `sub`) and enriched from the userinfo -/// endpoint via `AuthSession.loadUserInfo()`. +/// Who is signed in, for display purposes only. Sourced from the provider's +/// session endpoint via `AuthSession.refreshSession()`. public struct AuthIdentity: Sendable, Equatable { public let subject: String public let email: String? diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainError.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainError.swift index 3ba0927c..0b9edbb4 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainError.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainError.swift @@ -2,5 +2,4 @@ import Security public enum KeychainError: Error, Sendable, Equatable { case unhandledStatus(OSStatus) - case corruptedItem } diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainTokenStore.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainTokenStore.swift index 8f17dc64..1595085a 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainTokenStore.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/KeychainTokenStore.swift @@ -1,22 +1,24 @@ import Foundation import Security -/// Persists the token set as one generic-password item holding a JSON blob, +/// Persists the session as one generic-password item holding a JSON blob, /// so every save is atomic. The app is unsandboxed; the default per-app /// keychain access applies without any entitlement. -public final class KeychainTokenStore: TokenStoring, Sendable { +public actor KeychainTokenStore: TokenStoring { private let service: String private let account: String /// `service` identifies the persisted item — treat the default as a - /// stable format once shipped. + /// stable format once shipped. It predates the device-grant flow, so + /// items written by earlier builds share it; `load()` clears any blob + /// it cannot decode (the old OIDC token-set shape) instead of failing. public init(service: String = "com.arcboxlabs.desktop.oidc", account: String = "default") { self.service = service self.account = account } - public func save(_ tokens: StoredTokens) throws { - let data = try JSONEncoder().encode(tokens) + public func save(_ session: StoredSession) throws { + let data = try JSONEncoder().encode(session) let update = [kSecValueData as String: data] let status = SecItemUpdate(baseQuery as CFDictionary, update as CFDictionary) switch status { @@ -34,7 +36,7 @@ public final class KeychainTokenStore: TokenStoring, Sendable { } } - public func load() throws -> StoredTokens? { + public func load() throws -> StoredSession? { var query = baseQuery query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne @@ -43,9 +45,15 @@ public final class KeychainTokenStore: TokenStoring, Sendable { switch status { case errSecSuccess: guard let data = result as? Data, - let tokens = try? JSONDecoder().decode(StoredTokens.self, from: data) - else { throw KeychainError.corruptedItem } - return tokens + let session = try? JSONDecoder().decode(StoredSession.self, from: data) + else { + // Undecodable blob — a pre-device-flow token set or + // corruption. Either way it cannot authenticate anything; + // clear it so the user starts cleanly signed out. + try clear() + return nil + } + return session case errSecItemNotFound: return nil default: diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/StoredSession.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/StoredSession.swift new file mode 100644 index 00000000..9e23570e --- /dev/null +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/StoredSession.swift @@ -0,0 +1,16 @@ +import Foundation + +/// The session credential persisted across launches. +public struct StoredSession: Codable, Sendable, Equatable { + /// Opaque Better Auth session token, sent as `Authorization: Bearer`. + public var sessionToken: String + /// Sliding server-side expiry as of the last time the session was + /// verified. Display-only: the provider is the authority, so an expired + /// date never blocks a restore. + public var expiresAt: Date? + + public init(sessionToken: String, expiresAt: Date?) { + self.sessionToken = sessionToken + self.expiresAt = expiresAt + } +} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/StoredTokens.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/StoredTokens.swift deleted file mode 100644 index 0b840903..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/StoredTokens.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// The token set persisted across launches. -public struct StoredTokens: Codable, Sendable, Equatable { - public var accessToken: String - public var refreshToken: String? - public var idToken: String? - public var expiresAt: Date - - public init(accessToken: String, refreshToken: String?, idToken: String?, expiresAt: Date) { - self.accessToken = accessToken - self.refreshToken = refreshToken - self.idToken = idToken - self.expiresAt = expiresAt - } -} diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/TokenStoring.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/TokenStoring.swift index 96394f8d..02bb3fca 100644 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/TokenStoring.swift +++ b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Storage/TokenStoring.swift @@ -1,7 +1,7 @@ -/// Persistence seam for the token set, so `AuthSession` can be tested with an -/// in-memory fake instead of the real Keychain. +/// Persistence seam for the session credential, so `AuthSession` can be +/// tested with an in-memory fake instead of the real Keychain. public protocol TokenStoring: Sendable { - func save(_ tokens: StoredTokens) throws - func load() throws -> StoredTokens? - func clear() throws + func save(_ session: StoredSession) async throws + func load() async throws -> StoredSession? + func clear() async throws } diff --git a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Support/Base64URL.swift b/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Support/Base64URL.swift deleted file mode 100644 index 94f67398..00000000 --- a/Packages/ArcBoxAuth/Sources/ArcBoxAuth/Support/Base64URL.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -extension Data { - /// Base64url without padding (RFC 4648 §5), as used throughout OAuth/OIDC. - func base64URLEncodedString() -> String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - init?(base64URLEncoded string: String) { - var base64 = - string - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) - self.init(base64Encoded: base64) - } -} diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthClientConfigurationTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthClientConfigurationTests.swift new file mode 100644 index 00000000..3b43a069 --- /dev/null +++ b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthClientConfigurationTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing + +@testable import ArcBoxAuth + +struct AuthClientConfigurationTests { + @Test func resolvesAConfiguredIssuerAndClient() throws { + let configuration = try #require( + AuthClientConfiguration.resolve( + issuer: "https://auth.example.com/api/auth", clientID: "desktop")) + + #expect(configuration.issuerURL.absoluteString == "https://auth.example.com/api/auth") + #expect(configuration.clientID == "desktop") + #expect(!configuration.isPlaceholder) + } + + @Test func derivesEndpointsFromTheIssuer() throws { + let configuration = try #require( + AuthClientConfiguration.resolve( + issuer: "https://auth.example.com/api/auth", clientID: "desktop")) + + #expect( + configuration.deviceCodeEndpoint.absoluteString + == "https://auth.example.com/api/auth/device/code") + #expect( + configuration.deviceTokenEndpoint.absoluteString + == "https://auth.example.com/api/auth/device/token") + #expect( + configuration.sessionEndpoint.absoluteString + == "https://auth.example.com/api/auth/get-session") + #expect( + configuration.signOutEndpoint.absoluteString + == "https://auth.example.com/api/auth/sign-out") + } + + @Test(arguments: [nil, "", "$(OIDC_ISSUER_URL)", "YOUR_OIDC_ISSUER_URL_HERE"]) + func treatsUnexpandedOrPlaceholderIssuersAsUnconfigured(issuer: String?) { + #expect(AuthClientConfiguration.resolve(issuer: issuer, clientID: "desktop") == nil) + } + + @Test(arguments: [nil, "", "$(OIDC_CLIENT_ID)", "YOUR_OIDC_CLIENT_ID_HERE"]) + func treatsUnexpandedOrPlaceholderClientIDsAsUnconfigured(clientID: String?) { + #expect( + AuthClientConfiguration.resolve(issuer: "https://a.example.com", clientID: clientID) + == nil) + } + + @Test func labelsEnvironments() { + #expect(AuthClientConfiguration.placeholder.environmentLabel == "Not Configured") + #expect( + AuthClientConfiguration.resolve( + issuer: "http://localhost:2801/api/auth", clientID: "desktop")? + .environmentLabel == "Local") + #expect( + AuthClientConfiguration.resolve( + issuer: "https://auth.arcbox.dev/api/auth", clientID: "desktop")? + .environmentLabel == "auth.arcbox.dev") + } +} diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthSessionTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthSessionTests.swift index ce2c340f..1580e404 100644 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthSessionTests.swift +++ b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthSessionTests.swift @@ -1,4 +1,3 @@ -import AuthenticationServices import Foundation import Testing @@ -6,401 +5,248 @@ import Testing @MainActor struct AuthSessionTests { - private let provider = FakeOIDCProvider() + private let provider = FakeAuthProvider() private let store = InMemoryTokenStore() + private let sleeper = RecordingSleeper() + private let browser = BrowserSpy() - private func makeSession() -> AuthSession { + private func makeSession( + configuration: AuthClientConfiguration = AuthTestSupport.configuration + ) -> AuthSession { AuthSession( - configuration: AuthTestSupport.configuration, + configuration: configuration, provider: provider, - tokenStore: store) + tokenStore: store, + sleeper: sleeper.sleep, + openURL: browser.open + ) } - private func freshTokens(refreshToken: String? = "refresh-1") -> StoredTokens { - StoredTokens( - accessToken: "access-1", - refreshToken: refreshToken, - idToken: AuthTestSupport.idToken(subject: "user-1", email: "april@arcbox.dev"), - expiresAt: Date().addingTimeInterval(3600)) - } - - private func expiredTokens(refreshToken: String? = "refresh-1") -> StoredTokens { - var tokens = freshTokens(refreshToken: refreshToken) - tokens.expiresAt = Date().addingTimeInterval(-10) - return tokens - } + private static let storedSession = StoredSession( + sessionToken: "stored-token", + expiresAt: Date(timeIntervalSince1970: 4_102_444_800) + ) - // MARK: - Restore + // MARK: - Sign-in - @Test func initRestoresSessionFromStore() throws { - try store.save(freshTokens()) + @Test func signInStoresSessionAndLoadsIdentity() async throws { let session = makeSession() + await session.signIn() + #expect(session.status == .signedIn) + #expect(try await session.accessToken() == "session-1") + #expect(store.stored?.sessionToken == "session-1") #expect(session.identity?.subject == "user-1") - #expect(session.identity?.email == "april@arcbox.dev") + #expect(session.identity?.name == "Ada") + #expect(session.deviceAuthorization == nil) + #expect(browser.opened == [AuthTestSupport.grant().verificationURIComplete!]) } - @Test func initStaysSignedOutWhenStoreIsEmpty() { + @Test func signInPollsUntilApproved() async { + provider.configure { state in + state.pollScript = [ + .success(.authorizationPending), + .success(.authorizationPending), + .success(.granted(DeviceTokenGrant(sessionToken: "session-1", expiresAt: nil))), + ] + } let session = makeSession() - #expect(session.status == .signedOut) - #expect(session.identity == nil) - } - - // MARK: - completeSignIn + await session.signIn() - private func callback(code: String = "code-1", state: String = "state-1") -> URL { - URL(string: "com.arcboxlabs.desktop:/oauth2redirect?code=\(code)&state=\(state)")! + #expect(session.status == .signedIn) + #expect(provider.pollCalls == 3) + #expect(sleeper.slept == [.seconds(5.0), .seconds(5.0), .seconds(5.0)]) } - @Test func completeSignInHappyPath() async throws { - provider.configure { - $0.exchangeResult = .success( - TokenResponse( - accessToken: "access-1", - expiresIn: 3600, - refreshToken: "refresh-1", - idToken: AuthTestSupport.idToken( - subject: "user-1", email: "april@arcbox.dev", nonce: "nonce-1"))) + @Test func slowDownStretchesThePollingInterval() async { + provider.configure { state in + state.pollScript = [ + .success(.slowDown), + .success(.authorizationPending), + .success(.granted(DeviceTokenGrant(sessionToken: "session-1", expiresAt: nil))), + ] } let session = makeSession() - try await session.completeSignIn( - callbackURL: callback(), - expectedState: "state-1", - verifier: "verifier", - nonce: "nonce-1", - endpoints: AuthTestSupport.endpoints) + await session.signIn() + #expect(session.status == .signedIn) - #expect(session.identity?.email == "april@arcbox.dev") - #expect(store.stored?.accessToken == "access-1") - #expect(provider.exchangeCalls == 1) + #expect(sleeper.slept == [.seconds(5.0), .seconds(10.0), .seconds(10.0)]) } - @Test func completeSignInRejectsStateMismatch() async { - let session = makeSession() - await #expect(throws: OIDCError.stateMismatch) { - try await session.completeSignIn( - callbackURL: callback(state: "attacker-state"), - expectedState: "state-1", - verifier: "verifier", - nonce: "nonce-1", - endpoints: AuthTestSupport.endpoints) + @Test func denialInTheBrowserFailsSignIn() async { + provider.configure { state in + state.pollScript = [.failure(.authorizationDenied)] } - #expect(session.status != .signedIn) - #expect(store.stored == nil) - #expect(provider.exchangeCalls == 0) - } - - @Test func completeSignInSurfacesProviderError() async { let session = makeSession() - let url = URL( - string: "com.arcboxlabs.desktop:/oauth2redirect?error=access_denied&error_description=Denied&state=state-1" - )! - await #expect(throws: OIDCError.authorizationDenied("Denied")) { - try await session.completeSignIn( - callbackURL: url, - expectedState: "state-1", - verifier: "verifier", - nonce: "nonce-1", - endpoints: AuthTestSupport.endpoints) - } - } + await session.signIn() - @Test func completeSignInRejectsNonceMismatch() async { - provider.configure { - $0.exchangeResult = .success( - TokenResponse( - accessToken: "access-1", - idToken: AuthTestSupport.idToken(subject: "user-1", nonce: "other-nonce"))) - } - let session = makeSession() - await #expect(throws: OIDCError.invalidIDToken) { - try await session.completeSignIn( - callbackURL: callback(), - expectedState: "state-1", - verifier: "verifier", - nonce: "nonce-1", - endpoints: AuthTestSupport.endpoints) - } + #expect(session.status == .error(AuthError.authorizationDenied.userMessage)) #expect(store.stored == nil) } - // MARK: - UserInfo - - @Test func loadUserInfoPopulatesIdentity() async throws { - try store.save(freshTokens()) - provider.configure { - $0.userInfoResult = .success( - OIDCUserInfo( - subject: "user-1", - name: "April", - email: "april@arcbox.dev", - emailVerified: true, - picture: URL(string: "https://avatars.example.com/user-1.png"))) + @Test func deviceCodeExpiryFailsSignInLocally() async { + provider.configure { state in + state.deviceCodeResult = .success(AuthTestSupport.grant(expiresIn: 12)) + state.pollScript = [.success(.authorizationPending)] } let session = makeSession() - await session.loadUserInfo() - #expect(provider.userInfoCalls == 1) - #expect(session.identity?.name == "April") - #expect(session.identity?.email == "april@arcbox.dev") - #expect(session.identity?.emailVerified == true) - #expect(session.identity?.avatarURL?.absoluteString == "https://avatars.example.com/user-1.png") - } + await session.signIn() - @Test func loadUserInfoKeepsIdentityOnFailure() async throws { - try store.save(freshTokens()) - let session = makeSession() - let before = session.identity - await session.loadUserInfo() - #expect(provider.userInfoCalls == 1) - #expect(session.identity == before) - #expect(session.status == .signedIn) - } - - @Test func loadUserInfoIsNoOpWhenSignedOut() async { - let session = makeSession() - await session.loadUserInfo() - #expect(provider.userInfoCalls == 0) + #expect(session.status == .error(AuthError.deviceCodeExpired.userMessage)) + // Two polls fit inside the 12s budget with a 5s interval. + #expect(provider.pollCalls == 2) } - @Test func signInFetchesUserInfo() async throws { - let session = makeSession() - _ = try await session.beginAuthorization() - let pending = try #require(session.pendingAuthorization) - provider.configure { - $0.exchangeResult = .success( - TokenResponse( - accessToken: "access-1", - expiresIn: 3600, - idToken: AuthTestSupport.idToken(subject: "user-1", nonce: pending.nonce))) - $0.userInfoResult = .success( - OIDCUserInfo( - subject: "user-1", - name: "April", - picture: URL(string: "https://avatars.example.com/user-1.png"))) + @Test func cancelDuringPollingReturnsToSignedOut() async { + provider.configure { state in + state.pollScript = [.success(.authorizationPending)] } - await session.handleAuthorizationCallback(callback(state: pending.state)) - #expect(session.status == .signedIn) - #expect(provider.userInfoCalls == 1) - #expect(session.identity?.name == "April") - #expect(session.identity?.avatarURL != nil) - } - - @Test func cancelledSignInKeepsPendingAuthorizationForDeepLink() async { let session = makeSession() - await session.signIn { _, _ in - throw ASWebAuthenticationSessionError(.canceledLogin) + let signIn = Task { await session.signIn() } + while provider.pollCalls == 0 { + await Task.yield() } + session.cancelSignIn() + await signIn.value + #expect(session.status == .signedOut) - #expect(session.pendingAuthorization != nil) + #expect(session.deviceAuthorization == nil) + #expect(store.stored == nil) } - // MARK: - Deep-link callback + @Test func placeholderConfigurationCannotSignIn() async { + let session = makeSession(configuration: .placeholder) + await session.signIn() - @Test func deepLinkCallbackCompletesPendingSignIn() async throws { - let session = makeSession() - _ = try await session.beginAuthorization() - let pending = try #require(session.pendingAuthorization) - provider.configure { - $0.exchangeResult = .success( - TokenResponse( - accessToken: "access-1", - expiresIn: 3600, - refreshToken: "refresh-1", - idToken: AuthTestSupport.idToken( - subject: "user-1", email: "april@arcbox.dev", nonce: pending.nonce))) - } - let handled = await session.handleAuthorizationCallback(callback(state: pending.state)) - #expect(handled) - #expect(session.status == .signedIn) - #expect(session.pendingAuthorization == nil) - #expect(store.stored?.accessToken == "access-1") + #expect(session.status == .error(AuthError.notConfigured.userMessage)) + #expect(provider.deviceCodeCalls == 0) } - @Test func deepLinkCallbackIgnoresForeignURLs() async { + @Test func signInWhileSigningInIsANoOp() async { + provider.configure { state in + state.pollScript = [.success(.authorizationPending)] + } let session = makeSession() - _ = try? await session.beginAuthorization() - let handled = await session.handleAuthorizationCallback( - URL(string: "arcbox://containers/abc")!) - #expect(!handled) - #expect(session.pendingAuthorization != nil) - #expect(provider.exchangeCalls == 0) - } + let first = Task { await session.signIn() } + while provider.deviceCodeCalls == 0 { + await Task.yield() + } + await session.signIn() + #expect(provider.deviceCodeCalls == 1) - @Test func deepLinkCallbackWithoutPendingSignInIsDropped() async { - let session = makeSession() - let handled = await session.handleAuthorizationCallback(callback()) - #expect(handled) - #expect(session.status == .signedOut) - #expect(provider.exchangeCalls == 0) + session.cancelSignIn() + await first.value } - @Test func deepLinkCallbackRejectsStateMismatch() async throws { + @Test func concurrentSignInStartsOnlyOneDeviceFlow() async { let session = makeSession() - _ = try await session.beginAuthorization() - let handled = await session.handleAuthorizationCallback( - callback(state: "attacker-state")) - #expect(handled) - #expect(session.status == .error(OIDCError.stateMismatch.userMessage)) - #expect(session.pendingAuthorization == nil) - #expect(store.stored == nil) - #expect(provider.exchangeCalls == 0) - } + let first = Task { await session.signIn() } + let second = Task { await session.signIn() } - @Test func deepLinkCallbackConsumesPendingExactlyOnce() async throws { - let session = makeSession() - _ = try await session.beginAuthorization() - let pending = try #require(session.pendingAuthorization) - provider.configure { - $0.exchangeResult = .success( - TokenResponse( - accessToken: "access-1", - expiresIn: 3600, - idToken: AuthTestSupport.idToken(subject: "user-1", nonce: pending.nonce))) - } - let url = callback(state: pending.state) - let first = await session.handleAuthorizationCallback(url) - let second = await session.handleAuthorizationCallback(url) - #expect(first) - #expect(second) - #expect(session.status == .signedIn) - #expect(provider.exchangeCalls == 1) + await first.value + await second.value + + #expect(provider.deviceCodeCalls == 1) + #expect(browser.opened.count == 1) } - // MARK: - accessToken + // MARK: - Restore - @Test func accessTokenThrowsWhenSignedOut() async { + @Test func restoreAdoptsAStoredSessionWithoutNetwork() async throws { + try store.save(Self.storedSession) let session = makeSession() - await #expect(throws: OIDCError.notSignedIn) { - try await session.accessToken() - } - } + await session.restoreSession() - @Test func accessTokenReturnsCachedTokenWhileFresh() async throws { - try store.save(freshTokens()) - let session = makeSession() - #expect(try await session.accessToken() == "access-1") - #expect(provider.refreshCalls == 0) + #expect(session.status == .signedIn) + #expect(try await session.accessToken() == "stored-token") + #expect(provider.sessionCalls == 0) } - @Test func accessTokenRefreshesWhenExpired() async throws { - try store.save(expiredTokens()) - provider.configure { - $0.refreshResult = .success( - TokenResponse(accessToken: "access-2", expiresIn: 3600, refreshToken: "refresh-2")) - } + @Test func restoreWithAnEmptyKeychainSignsOut() async { let session = makeSession() - #expect(try await session.accessToken() == "access-2") - #expect(provider.refreshCalls == 1) - #expect(store.stored?.accessToken == "access-2") - #expect(store.stored?.refreshToken == "refresh-2") + await session.restoreSession() + #expect(session.status == .signedOut) } - @Test func refreshKeepsOldRefreshTokenWhenNotRotated() async throws { - try store.save(expiredTokens()) - provider.configure { - $0.refreshResult = .success(TokenResponse(accessToken: "access-2", expiresIn: 3600)) - } + @Test func restoreFailureSignsOut() async { + store.failLoading() let session = makeSession() - _ = try await session.accessToken() - #expect(store.stored?.refreshToken == "refresh-1") + await session.restoreSession() + #expect(session.status == .signedOut) } - @Test func concurrentCallersTriggerExactlyOneRefresh() async throws { - try store.save(expiredTokens()) - provider.configure { - $0.refreshResult = .success(TokenResponse(accessToken: "access-2", expiresIn: 3600)) - $0.refreshDelay = .milliseconds(50) - } - let session = makeSession() - async let first = session.accessToken() - async let second = session.accessToken() - let tokens = try await (first, second) - #expect(tokens == ("access-2", "access-2")) - #expect(provider.refreshCalls == 1) - } + // MARK: - Session refresh - @Test func accessTokenThrowsWithoutRefreshToken() async throws { - try store.save(expiredTokens(refreshToken: nil)) + @Test func refreshPublishesIdentityAndSlidExpiry() async throws { + try store.save(Self.storedSession) let session = makeSession() - await #expect(throws: OIDCError.missingRefreshToken) { - try await session.accessToken() - } + await session.restoreSession() + await session.refreshSession() + + #expect(session.identity?.subject == "user-1") + #expect(session.identity?.email == "ada@example.com") + #expect(store.stored?.expiresAt == AuthTestSupport.snapshot().session.expiresAt) } - @Test func invalidGrantEndsTheSession() async throws { - try store.save(expiredTokens()) - provider.configure { - $0.refreshResult = .failure( - .tokenRequestFailed(status: 400, body: #"{"error":"invalid_grant"}"#)) + @Test func refreshSignsOutWhenTheProviderDropsTheSession() async throws { + try store.save(Self.storedSession) + provider.configure { state in + state.sessionResult = .success(nil) } let session = makeSession() - await #expect(throws: OIDCError.notSignedIn) { - try await session.accessToken() - } + await session.restoreSession() + await session.refreshSession() + #expect(session.status == .signedOut) #expect(store.stored == nil) } - @Test func transientRefreshFailureKeepsTheSession() async throws { - try store.save(expiredTokens()) - provider.configure { - $0.refreshResult = .failure(.network("timeout")) + @Test func refreshKeepsTheSessionOnTransportFailure() async throws { + try store.save(Self.storedSession) + provider.configure { state in + state.sessionResult = .failure(.network("offline")) } let session = makeSession() - await #expect(throws: OIDCError.network("timeout")) { - try await session.accessToken() - } + await session.restoreSession() + await session.refreshSession() + #expect(session.status == .signedIn) - #expect(store.stored != nil) + #expect(try await session.accessToken() == "stored-token") } - // MARK: - signOut + // MARK: - Sign-out - @Test func signOutRevokesAndClears() async throws { - provider.configure { - $0.exchangeResult = .success( - TokenResponse(accessToken: "access-1", expiresIn: 3600, refreshToken: "refresh-1")) - } + @Test func signOutRevokesServerSideAndClearsLocally() async throws { + try store.save(Self.storedSession) let session = makeSession() - // Complete a sign-in so discovery has run and revocation is attempted. - try await session.completeSignIn( - callbackURL: callback(), - expectedState: "state-1", - verifier: "verifier", - nonce: "nonce-1", - endpoints: session.resolvedEndpoints()) + await session.restoreSession() await session.signOut() + #expect(session.status == .signedOut) - #expect(session.identity == nil) + #expect(provider.signOutTokens == ["stored-token"]) #expect(store.stored == nil) - #expect(provider.revokeCalls == 1) + await #expect(throws: AuthError.notSignedIn) { + try await session.accessToken() + } } - @Test func signOutClearsEvenWhenRevocationFails() async throws { - provider.configure { - $0.exchangeResult = .success( - TokenResponse(accessToken: "access-1", expiresIn: 3600, refreshToken: "refresh-1")) - $0.revokeError = .network("unreachable") + @Test func signOutClearsLocallyEvenWhenRevocationFails() async throws { + try store.save(Self.storedSession) + provider.configure { state in + state.signOutError = .network("offline") } let session = makeSession() - try await session.completeSignIn( - callbackURL: callback(), - expectedState: "state-1", - verifier: "verifier", - nonce: "nonce-1", - endpoints: session.resolvedEndpoints()) + await session.restoreSession() await session.signOut() + #expect(session.status == .signedOut) #expect(store.stored == nil) - #expect(provider.revokeCalls == 1) } - @Test func signOutWithoutDiscoverySkipsRevocation() async throws { - try store.save(freshTokens()) + @Test func accessTokenThrowsWhenSignedOut() async { let session = makeSession() - await session.signOut() - #expect(session.status == .signedOut) - #expect(store.stored == nil) - #expect(provider.revokeCalls == 0) + await #expect(throws: AuthError.notSignedIn) { + try await session.accessToken() + } } } diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthTestSupport.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthTestSupport.swift index 4d46993c..7e8feeb7 100644 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthTestSupport.swift +++ b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/AuthTestSupport.swift @@ -4,114 +4,183 @@ import os @testable import ArcBoxAuth enum AuthTestSupport { - static let configuration = OIDCClientConfiguration( - issuerURL: URL(string: "https://idp.example.com")!, + static let configuration = AuthClientConfiguration( + issuerURL: URL(string: "https://idp.example.com/api/auth")!, clientID: "test-client" ) - static let endpoints = OIDCEndpoints( - authorizationEndpoint: URL(string: "https://idp.example.com/auth")!, - tokenEndpoint: URL(string: "https://idp.example.com/token")!, - revocationEndpoint: URL(string: "https://idp.example.com/revoke")!, - userinfoEndpoint: URL(string: "https://idp.example.com/userinfo")! - ) + static func grant( + expiresIn: TimeInterval = 1800, + interval: TimeInterval? = 5 + ) -> DeviceCodeGrant { + DeviceCodeGrant( + deviceCode: "device-1", + userCode: "ABCD1234", + verificationURI: URL(string: "https://idp.example.com/device")!, + verificationURIComplete: URL( + string: "https://idp.example.com/device?user_code=ABCD1234"), + expiresIn: expiresIn, + interval: interval + ) + } - /// Unsigned JWT with the given payload, shaped like a real ID token. - static func idToken(subject: String, email: String? = nil, nonce: String? = nil) -> String { - var claims = ["\"sub\":\"\(subject)\"", "\"exp\":4102444800"] - if let email { claims.append("\"email\":\"\(email)\"") } - if let nonce { claims.append("\"nonce\":\"\(nonce)\"") } - let header = Data("{\"alg\":\"RS256\"}".utf8).base64URLEncodedString() - let payload = Data("{\(claims.joined(separator: ","))}".utf8).base64URLEncodedString() - return "\(header).\(payload).signature" + static func snapshot( + subject: String = "user-1", + name: String? = "Ada", + email: String? = "ada@example.com", + expiresAt: Date? = Date(timeIntervalSince1970: 4_102_444_800) + ) -> SessionSnapshot { + SessionSnapshot( + session: SessionDetails(expiresAt: expiresAt), + user: SessionUser( + id: subject, name: name, email: email, emailVerified: true, image: nil) + ) } } final class InMemoryTokenStore: TokenStoring { - private let storage = OSAllocatedUnfairLock(initialState: nil) + private struct State { + var stored: StoredSession? + var saveCalls = 0 + var loadCalls = 0 + var clearCalls = 0 + var shouldFailLoading = false + } + + enum Failure: Error { + case loadFailed + } + + private let storage: OSAllocatedUnfairLock + + var stored: StoredSession? { storage.withLock { $0.stored } } + var saveCalls: Int { storage.withLock { $0.saveCalls } } + var loadCalls: Int { storage.withLock { $0.loadCalls } } + var clearCalls: Int { storage.withLock { $0.clearCalls } } + + init(initial: StoredSession? = nil) { + storage = OSAllocatedUnfairLock(initialState: State(stored: initial)) + } + + func failLoading() { + storage.withLock { $0.shouldFailLoading = true } + } - var stored: StoredTokens? { storage.withLock { $0 } } + func save(_ session: StoredSession) throws { + storage.withLock { + $0.saveCalls += 1 + $0.stored = session + } + } - init(initial: StoredTokens? = nil) { - storage.withLock { $0 = initial } + func load() throws -> StoredSession? { + let result = storage.withLock { + $0.loadCalls += 1 + return ($0.shouldFailLoading, $0.stored) + } + if result.0 { throw Failure.loadFailed } + return result.1 } - func save(_ tokens: StoredTokens) throws { storage.withLock { $0 = tokens } } - func load() throws -> StoredTokens? { storage.withLock { $0 } } - func clear() throws { storage.withLock { $0 = nil } } + func clear() throws { + storage.withLock { + $0.clearCalls += 1 + $0.stored = nil + } + } } -final class FakeOIDCProvider: OIDCProviding { +/// Scripted provider: poll outcomes are consumed in order; the last entry +/// repeats for any further polls. +final class FakeAuthProvider: AuthProviding { struct State { - var exchangeResult: Result = .failure(.notSignedIn) - var refreshResult: Result = .failure(.notSignedIn) - var userInfoResult: Result = .failure( - .userInfoFailed(status: 401, body: "unconfigured")) - var refreshDelay: Duration? - var revokeError: OIDCError? - var exchangeCalls = 0 - var refreshCalls = 0 - var revokeCalls = 0 - var userInfoCalls = 0 + var deviceCodeResult: Result = .success( + AuthTestSupport.grant()) + var pollScript: [Result] = [ + .success(.granted(DeviceTokenGrant(sessionToken: "session-1", expiresAt: nil))) + ] + var sessionResult: Result = .success( + AuthTestSupport.snapshot()) + var signOutError: AuthError? + var deviceCodeCalls = 0 + var pollCalls = 0 + var sessionCalls = 0 + var signOutCalls = 0 + var signOutTokens: [String] = [] } private let state = OSAllocatedUnfairLock(initialState: State()) - var exchangeCalls: Int { state.withLock { $0.exchangeCalls } } - var refreshCalls: Int { state.withLock { $0.refreshCalls } } - var revokeCalls: Int { state.withLock { $0.revokeCalls } } - var userInfoCalls: Int { state.withLock { $0.userInfoCalls } } + var deviceCodeCalls: Int { state.withLock { $0.deviceCodeCalls } } + var pollCalls: Int { state.withLock { $0.pollCalls } } + var sessionCalls: Int { state.withLock { $0.sessionCalls } } + var signOutCalls: Int { state.withLock { $0.signOutCalls } } + var signOutTokens: [String] { state.withLock { $0.signOutTokens } } func configure(_ change: @Sendable (inout State) -> Void) { state.withLock { change(&$0) } } - func discover(issuer: URL) async throws -> OIDCEndpoints { - AuthTestSupport.endpoints + func requestDeviceCode( + configuration: AuthClientConfiguration + ) async throws -> DeviceCodeGrant { + try state.withLock { s in + s.deviceCodeCalls += 1 + return s.deviceCodeResult + }.get() } - func exchangeCode( - _ code: String, - verifier: String, - configuration: OIDCClientConfiguration, - endpoints: OIDCEndpoints - ) async throws -> TokenResponse { + func pollDeviceToken( + deviceCode: String, + configuration: AuthClientConfiguration + ) async throws -> DevicePollOutcome { try state.withLock { s in - s.exchangeCalls += 1 - return s.exchangeResult + s.pollCalls += 1 + let result = s.pollScript.first ?? .failure(.notSignedIn) + if s.pollScript.count > 1 { s.pollScript.removeFirst() } + return result }.get() } - func refresh( - refreshToken: String, - configuration: OIDCClientConfiguration, - endpoints: OIDCEndpoints - ) async throws -> TokenResponse { - let (delay, result) = state.withLock { s in - s.refreshCalls += 1 - return (s.refreshDelay, s.refreshResult) - } - if let delay { try await Task.sleep(for: delay) } - return try result.get() + func session( + token: String, + configuration: AuthClientConfiguration + ) async throws -> SessionSnapshot? { + try state.withLock { s in + s.sessionCalls += 1 + return s.sessionResult + }.get() } - func revoke( - token: String, - tokenTypeHint: String, - configuration: OIDCClientConfiguration, - endpoint: URL - ) async throws { + func signOut(token: String, configuration: AuthClientConfiguration) async throws { let error = state.withLock { s in - s.revokeCalls += 1 - return s.revokeError + s.signOutCalls += 1 + s.signOutTokens.append(token) + return s.signOutError } if let error { throw error } } +} + +/// Records slept intervals; can gate the loop so tests observe mid-poll state. +final class RecordingSleeper: Sendable { + private let state = OSAllocatedUnfairLock(initialState: [Duration]()) + + var slept: [Duration] { state.withLock { $0 } } + + @Sendable func sleep(_ duration: Duration) async throws { + state.withLock { $0.append(duration) } + await Task.yield() + try Task.checkCancellation() + } +} - func userInfo(accessToken: String, endpoint: URL) async throws -> OIDCUserInfo { - try state.withLock { s in - s.userInfoCalls += 1 - return s.userInfoResult - }.get() +/// Captures URLs the session asked the browser to open. +@MainActor +final class BrowserSpy { + private(set) var opened: [URL] = [] + + func open(_ url: URL) { + opened.append(url) } } diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/BetterAuthClientTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/BetterAuthClientTests.swift new file mode 100644 index 00000000..b850b6d2 --- /dev/null +++ b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/BetterAuthClientTests.swift @@ -0,0 +1,237 @@ +import Foundation +import Testing +import os + +@testable import ArcBoxAuth + +struct BetterAuthClientTests { + // MARK: - Session isolation + + @Test func defaultSessionConfigurationDisablesCookies() { + let configuration = BetterAuthClient.makeSessionConfiguration() + + #expect(configuration.httpCookieStorage == nil) + #expect(configuration.httpShouldSetCookies == false) + } + + @Test func responsesCannotSetCookiesForLaterRequests() async throws { + CookieRecordingURLProtocol.reset() + let configuration = BetterAuthClient.makeSessionConfiguration() + configuration.protocolClasses = [CookieRecordingURLProtocol.self] + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + let client = BetterAuthClient(session: session) + + _ = try await client.requestDeviceCode(configuration: AuthTestSupport.configuration) + _ = try await client.requestDeviceCode(configuration: AuthTestSupport.configuration) + + let cookieHeaders = CookieRecordingURLProtocol.cookieHeaders + #expect(cookieHeaders.count == 2) + #expect(cookieHeaders.allSatisfy { $0 == nil }) + } + + @Test func legacyCleanupIsLimitedToBetterAuthCookies() throws { + let secureToken = try cookie(named: "__Secure-better-auth.session_token") + let secureData = try cookie(named: "__Secure-better-auth.session_data") + let localToken = try cookie(named: "better-auth.session_token") + let unrelated = try cookie(named: "arcbox-preference") + let cookies = [secureToken, secureData, localToken, unrelated] + let storage = HTTPCookieStorage.shared + cookies.forEach(storage.setCookie) + defer { cookies.forEach(storage.deleteCookie) } + + BetterAuthClient.clearLegacyCookies(for: AuthTestSupport.configuration.issuerURL) + + let remainingNames = + storage.cookies(for: AuthTestSupport.configuration.issuerURL)? + .map(\.name) ?? [] + #expect(!remainingNames.contains(secureToken.name)) + #expect(!remainingNames.contains(secureData.name)) + #expect(!remainingNames.contains(localToken.name)) + #expect(remainingNames.contains(unrelated.name)) + } + + // MARK: - Device code + + @Test func decodesADeviceCodeGrant() throws { + let json = """ + {"device_code":"dev-1","user_code":"ABCD1234",\ + "verification_uri":"https://idp.example.com/device",\ + "verification_uri_complete":"https://idp.example.com/device?user_code=ABCD1234",\ + "expires_in":1800,"interval":5} + """ + let grant = try BetterAuthClient.decodeDeviceCodeGrant( + data: Data(json.utf8), status: 200) + + #expect(grant.deviceCode == "dev-1") + #expect(grant.userCode == "ABCD1234") + #expect(grant.verificationURIComplete?.query() == "user_code=ABCD1234") + #expect(grant.expiresIn == 1800) + #expect(grant.interval == 5) + } + + @Test func deviceCodeGrantToleratesMissingOptionalFields() throws { + let json = """ + {"device_code":"dev-1","user_code":"ABCD1234",\ + "verification_uri":"https://idp.example.com/device","expires_in":600} + """ + let grant = try BetterAuthClient.decodeDeviceCodeGrant( + data: Data(json.utf8), status: 200) + + #expect(grant.verificationURIComplete == nil) + #expect(grant.interval == nil) + } + + @Test func deviceCodeRequestFailureCarriesTruncatedBody() { + let body = String(repeating: "x", count: 500) + #expect( + throws: AuthError.requestFailed( + status: 500, body: String(repeating: "x", count: 200) + "…") + ) { + try BetterAuthClient.decodeDeviceCodeGrant(data: Data(body.utf8), status: 500) + } + } + + // MARK: - Token polling + + @Test func decodesAGrantedToken() throws { + let json = #"{"access_token":"session-1","token_type":"Bearer","expires_in":2592000}"# + let outcome = try BetterAuthClient.decodePollOutcome(data: Data(json.utf8), status: 200) + + guard case .granted(let token) = outcome else { + Issue.record("Expected .granted, got \(outcome)") + return + } + #expect(token.sessionToken == "session-1") + let expiresAt = try #require(token.expiresAt) + #expect(abs(expiresAt.timeIntervalSinceNow - 2_592_000) < 60) + } + + @Test(arguments: [ + ("authorization_pending", DevicePollOutcome.authorizationPending), + ("slow_down", DevicePollOutcome.slowDown), + ]) + func mapsRetryableTokenErrors(code: String, expected: DevicePollOutcome) throws { + let json = #"{"error":"\#(code)","error_description":"…"}"# + let outcome = try BetterAuthClient.decodePollOutcome(data: Data(json.utf8), status: 400) + #expect(outcome == expected) + } + + @Test func mapsDenialToATerminalError() { + let json = #"{"error":"access_denied","error_description":"denied"}"# + #expect(throws: AuthError.authorizationDenied) { + try BetterAuthClient.decodePollOutcome(data: Data(json.utf8), status: 400) + } + } + + @Test func mapsExpiryToATerminalError() { + let json = #"{"error":"expired_token","error_description":"expired"}"# + #expect(throws: AuthError.deviceCodeExpired) { + try BetterAuthClient.decodePollOutcome(data: Data(json.utf8), status: 400) + } + } + + @Test func mapsUnknownTokenErrorsToRequestFailed() { + let json = #"{"error":"invalid_grant","error_description":"Invalid device code"}"# + #expect(throws: AuthError.requestFailed(status: 400, body: "Invalid device code")) { + try BetterAuthClient.decodePollOutcome(data: Data(json.utf8), status: 400) + } + } + + // MARK: - Session + + @Test func decodesASessionSnapshot() throws { + let json = """ + {"session":{"id":"s1","token":"t1","userId":"user-1",\ + "expiresAt":"2026-08-14T12:00:00.000Z"},\ + "user":{"id":"user-1","name":"Ada","email":"ada@example.com",\ + "emailVerified":true,"image":null,"createdAt":"2026-01-01T00:00:00.000Z"}} + """ + let snapshot = try #require( + try BetterAuthClient.decodeSessionSnapshot(data: Data(json.utf8), status: 200)) + + #expect(snapshot.user.id == "user-1") + #expect(snapshot.user.name == "Ada") + #expect(snapshot.user.emailVerified == true) + #expect(snapshot.user.image == nil) + let expiresAt = try #require(snapshot.session.expiresAt) + #expect( + expiresAt + == (try Date.ISO8601FormatStyle(includingFractionalSeconds: true) + .parse("2026-08-14T12:00:00.000Z"))) + } + + @Test func nullSessionBodyMeansNoSession() throws { + #expect( + try BetterAuthClient.decodeSessionSnapshot( + data: Data("null".utf8), status: 200) == nil) + #expect( + try BetterAuthClient.decodeSessionSnapshot( + data: Data(), status: 200) == nil) + #expect( + try BetterAuthClient.decodeSessionSnapshot( + data: Data("{}".utf8), status: 401) == nil) + } + + @Test func serverFailuresThrowInsteadOfSigningOut() { + #expect(throws: AuthError.requestFailed(status: 503, body: "unavailable")) { + try BetterAuthClient.decodeSessionSnapshot( + data: Data("unavailable".utf8), status: 503) + } + } + + private func cookie(named name: String) throws -> HTTPCookie { + try #require( + HTTPCookie(properties: [ + .name: name, + .value: "opaque", + .domain: "idp.example.com", + .path: "/", + .secure: "TRUE", + ])) + } +} + +private class CookieRecordingURLProtocol: URLProtocol { + private static let storage = OSAllocatedUnfairLock(initialState: [String?]()) + + static var cookieHeaders: [String?] { storage.withLock { $0 } } + + static func reset() { + storage.withLock { $0.removeAll() } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let url = request.url else { fatalError("Stub request is missing its URL") } + let cookieHeader = request.value(forHTTPHeaderField: "Cookie") + Self.storage.withLock { + $0.append(cookieHeader) + } + guard + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Set-Cookie": + "__Secure-better-auth.session_data=opaque; Path=/; Secure; HttpOnly" + ]) + else { + fatalError("Failed to create stub response") + } + let body = Data( + """ + {"device_code":"dev-1","user_code":"ABCD1234",\ + "verification_uri":"https://idp.example.com/device","expires_in":1800} + """.utf8) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/IDTokenClaimsTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/IDTokenClaimsTests.swift deleted file mode 100644 index a6297bfd..00000000 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/IDTokenClaimsTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Testing - -@testable import ArcBoxAuth - -struct IDTokenClaimsTests { - private func token(payloadJSON: String) -> String { - let header = Data("{\"alg\":\"RS256\"}".utf8).base64URLEncodedString() - let payload = Data(payloadJSON.utf8).base64URLEncodedString() - return "\(header).\(payload).signature" - } - - @Test func decodesValidToken() throws { - let jwt = token( - payloadJSON: """ - {"sub":"user-1","email":"april@arcbox.dev","name":"April","nonce":"n-1","exp":1751900000} - """) - let claims = try IDTokenClaims.decode(idToken: jwt) - #expect(claims.subject == "user-1") - #expect(claims.email == "april@arcbox.dev") - #expect(claims.name == "April") - #expect(claims.nonce == "n-1") - #expect(claims.expiresAt == Date(timeIntervalSince1970: 1_751_900_000)) - } - - @Test func toleratesAbsentOptionalClaims() throws { - let jwt = token(payloadJSON: #"{"sub":"user-1","exp":1751900000}"#) - let claims = try IDTokenClaims.decode(idToken: jwt) - #expect(claims.email == nil) - #expect(claims.name == nil) - #expect(claims.nonce == nil) - } - - @Test func rejectsMissingSubject() { - let jwt = token(payloadJSON: #"{"exp":1751900000}"#) - #expect(throws: OIDCError.invalidIDToken) { - try IDTokenClaims.decode(idToken: jwt) - } - } - - @Test func rejectsMalformedPayload() { - #expect(throws: OIDCError.invalidIDToken) { - try IDTokenClaims.decode(idToken: "abc.!!!not-base64!!!.def") - } - } - - @Test func rejectsWrongSegmentCount() { - #expect(throws: OIDCError.invalidIDToken) { - try IDTokenClaims.decode(idToken: "only-one-segment") - } - } -} diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/KeychainTokenStoreTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/KeychainTokenStoreTests.swift index 5956ae80..1efe4cbe 100644 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/KeychainTokenStoreTests.swift +++ b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/KeychainTokenStoreTests.swift @@ -6,40 +6,95 @@ import Testing /// Exercises the real Keychain with a unique per-test service name; every /// test cleans up its item so repeated runs never leak into the login keychain. struct KeychainTokenStoreTests { - private let store = KeychainTokenStore( - service: "com.arcboxlabs.desktop.oidc.tests.\(UUID().uuidString)") + private let service = "com.arcboxlabs.desktop.oidc.tests.\(UUID().uuidString)" + private var store: KeychainTokenStore { KeychainTokenStore(service: service) } - private let tokens = StoredTokens( - accessToken: "access-1", - refreshToken: "refresh-1", - idToken: "id-1", + private let session = StoredSession( + sessionToken: "session-1", expiresAt: Date(timeIntervalSince1970: 1_751_900_000) ) - @Test func roundTripsATokenSet() throws { - defer { try? store.clear() } - try store.save(tokens) - #expect(try store.load() == tokens) + @Test func roundTripsASession() async throws { + try await withCleanup { + try await store.save(session) + #expect(try await store.load() == session) + } } - @Test func loadReturnsNilWhenEmpty() throws { - #expect(try store.load() == nil) + @Test func loadReturnsNilWhenEmpty() async throws { + #expect(try await store.load() == nil) } - @Test func saveOverwritesTheExistingItem() throws { - defer { try? store.clear() } - try store.save(tokens) - var updated = tokens - updated.accessToken = "access-2" - updated.refreshToken = nil - try store.save(updated) - #expect(try store.load() == updated) + @Test func saveOverwritesTheExistingItem() async throws { + try await withCleanup { + try await store.save(session) + var updated = session + updated.sessionToken = "session-2" + updated.expiresAt = nil + try await store.save(updated) + #expect(try await store.load() == updated) + } } - @Test func clearRemovesTheItemAndIsIdempotent() throws { - try store.save(tokens) - try store.clear() - #expect(try store.load() == nil) - try store.clear() + @Test func clearRemovesTheItem() async throws { + try await store.save(session) + try await store.clear() + #expect(try await store.load() == nil) + } + + @Test func clearingAnEmptyStoreIsFine() async throws { + try await store.clear() + } + + /// Blobs written by the pre-device-flow OIDC builds cannot authenticate + /// anything; loading one must self-heal to a clean signed-out state. + @Test func undecodableLegacyBlobIsClearedOnLoad() async throws { + try await withCleanup { + let legacy = Data( + #"{"accessToken":"jwt","refreshToken":"r","expiresAt":775875577}"#.utf8) + try saveRaw(legacy) + #expect(try await store.load() == nil) + #expect(try loadRaw() == nil) + } + } + + // MARK: - Support + + private func withCleanup(_ body: () async throws -> Void) async throws { + do { + try await body() + try await store.clear() + } catch { + try? await store.clear() + throw error + } + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "default", + ] + } + + private func saveRaw(_ data: Data) throws { + var query = baseQuery + query[kSecValueData as String] = data + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.unhandledStatus(status) } + } + + private func loadRaw() throws -> Data? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: return result as? Data + case errSecItemNotFound: return nil + default: throw KeychainError.unhandledStatus(status) + } } } diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCAuthorizationURLBuilderTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCAuthorizationURLBuilderTests.swift deleted file mode 100644 index a96072c9..00000000 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCAuthorizationURLBuilderTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import Testing - -@testable import ArcBoxAuth - -struct OIDCAuthorizationURLBuilderTests { - private let endpoints = OIDCEndpoints( - authorizationEndpoint: URL(string: "https://idp.example.com/auth?audience=api")!, - tokenEndpoint: URL(string: "https://idp.example.com/token")! - ) - private let configuration = OIDCClientConfiguration( - issuerURL: URL(string: "https://idp.example.com")!, - clientID: "test-client" - ) - - @Test func includesAllRequiredParameters() throws { - let pkce = PKCE.generateCodePair() - let url = try OIDCAuthorizationURLBuilder.makeURL( - endpoints: endpoints, - configuration: configuration, - pkce: pkce, - state: "the-state", - nonce: "the-nonce" - ) - let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - let byName = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value) }) - #expect(byName["response_type"] == "code") - #expect(byName["client_id"] == "test-client") - #expect(byName["redirect_uri"] == "com.arcboxlabs.desktop:/oauth2redirect") - #expect(byName["scope"] == "openid profile email offline_access") - #expect(byName["state"] == "the-state") - #expect(byName["nonce"] == "the-nonce") - #expect(byName["code_challenge"] == pkce.challenge) - #expect(byName["code_challenge_method"] == "S256") - // Query parameters already on the discovered endpoint survive. - #expect(byName["audience"] == "api") - } - - @Test func neverLeaksTheVerifierIntoTheURL() throws { - let pkce = PKCE.generateCodePair() - let url = try OIDCAuthorizationURLBuilder.makeURL( - endpoints: endpoints, - configuration: configuration, - pkce: pkce, - state: "s", - nonce: "n" - ) - #expect(!url.absoluteString.contains(pkce.verifier)) - } -} diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCClientConfigurationTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCClientConfigurationTests.swift deleted file mode 100644 index 96b0206d..00000000 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCClientConfigurationTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -import Testing - -@testable import ArcBoxAuth - -struct OIDCClientConfigurationTests { - @Test func resolvesConfiguredValues() throws { - let config = try #require( - OIDCClientConfiguration.resolve( - issuer: "https://auth.arcbox.dev", clientID: "arcbox-desktop")) - #expect(config.issuerURL == URL(string: "https://auth.arcbox.dev")) - #expect(config.clientID == "arcbox-desktop") - #expect(!config.isPlaceholder) - } - - @Test( - arguments: [ - nil, "", "$(OIDC_ISSUER_URL)", "YOUR_OIDC_ISSUER_URL_HERE", - ] as [String?]) - func rejectsUnconfiguredIssuer(_ issuer: String?) { - #expect(OIDCClientConfiguration.resolve(issuer: issuer, clientID: "arcbox-desktop") == nil) - } - - @Test( - arguments: [ - nil, "", "$(OIDC_CLIENT_ID)", "YOUR_OIDC_CLIENT_ID_HERE", - ] as [String?]) - func rejectsUnconfiguredClientID(_ clientID: String?) { - #expect( - OIDCClientConfiguration.resolve(issuer: "https://auth.arcbox.dev", clientID: clientID) - == nil) - } - - @Test func placeholderIsInert() { - let placeholder = OIDCClientConfiguration.placeholder - #expect(placeholder.isPlaceholder) - #expect(placeholder.environmentLabel == "Not Configured") - // RFC 2606 reserved TLD — guaranteed to never resolve. - #expect(placeholder.issuerURL.host()?.hasSuffix(".invalid") == true) - } - - @Test func environmentLabelDescribesTheHost() { - let local = OIDCClientConfiguration( - issuerURL: URL(string: "http://localhost:5556/dex")!, clientID: "arcbox-desktop") - #expect(local.environmentLabel == "Local") - let staging = OIDCClientConfiguration( - issuerURL: URL(string: "https://auth.staging.arcbox.dev")!, clientID: "arcbox-desktop") - #expect(staging.environmentLabel == "auth.staging.arcbox.dev") - } -} diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCClientTests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCClientTests.swift deleted file mode 100644 index 8ac66749..00000000 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/OIDCClientTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation -import Testing - -@testable import ArcBoxAuth - -struct OIDCClientTests { - @Test func formBodyPercentEncodesAndSortsFields() { - let body = OIDCClient.formBody(["b key": "v&1", "a": "x y"]) - #expect(String(bytes: body, encoding: .utf8) == "a=x%20y&b%20key=v%261") - } - - @Test func formBodyPreservesUnreservedCharacters() { - let body = OIDCClient.formBody(["code_verifier": "abc-DEF_123.~"]) - #expect(String(bytes: body, encoding: .utf8) == "code_verifier=abc-DEF_123.~") - } - - @Test func decodeTokenResponseParsesSuccess() throws { - let json = """ - {"access_token":"at","token_type":"bearer","expires_in":3600,\ - "refresh_token":"rt","id_token":"idt","scope":"openid email"} - """ - let response = try OIDCClient.decodeTokenResponse(data: Data(json.utf8), status: 200) - #expect(response.accessToken == "at") - #expect(response.tokenType == "bearer") - #expect(response.expiresIn == 3600) - #expect(response.refreshToken == "rt") - #expect(response.idToken == "idt") - #expect(response.scope == "openid email") - } - - @Test func decodeTokenResponseToleratesMinimalPayload() throws { - let json = #"{"access_token":"at","token_type":"bearer"}"# - let response = try OIDCClient.decodeTokenResponse(data: Data(json.utf8), status: 200) - #expect(response.expiresIn == nil) - #expect(response.refreshToken == nil) - } - - @Test func decodeTokenResponseThrowsOnHTTPError() { - let body = #"{"error":"invalid_grant"}"# - #expect(throws: OIDCError.tokenRequestFailed(status: 400, body: body)) { - try OIDCClient.decodeTokenResponse(data: Data(body.utf8), status: 400) - } - } - - @Test func decodeTokenResponseThrowsOnMalformedJSON() { - #expect(throws: OIDCError.tokenRequestFailed(status: 200, body: "malformed token response")) { - try OIDCClient.decodeTokenResponse(data: Data("not json".utf8), status: 200) - } - } - - @Test func decodeUserInfoParsesFullPayload() throws { - let json = """ - {"sub":"user-1","name":"April","email":"april@arcbox.dev",\ - "email_verified":true,"picture":"https://avatars.example.com/u.png"} - """ - let info = try OIDCClient.decodeUserInfo(data: Data(json.utf8), status: 200) - #expect(info.subject == "user-1") - #expect(info.name == "April") - #expect(info.email == "april@arcbox.dev") - #expect(info.emailVerified == true) - #expect(info.picture?.absoluteString == "https://avatars.example.com/u.png") - } - - @Test func decodeUserInfoToleratesSubjectOnlyPayload() throws { - let info = try OIDCClient.decodeUserInfo(data: Data(#"{"sub":"user-1"}"#.utf8), status: 200) - #expect(info == OIDCUserInfo(subject: "user-1")) - } - - @Test func decodeUserInfoThrowsOnHTTPError() { - let body = #"{"error":"invalid_token"}"# - #expect(throws: OIDCError.userInfoFailed(status: 401, body: body)) { - try OIDCClient.decodeUserInfo(data: Data(body.utf8), status: 401) - } - } -} diff --git a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/PKCETests.swift b/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/PKCETests.swift deleted file mode 100644 index 3b5389d4..00000000 --- a/Packages/ArcBoxAuth/Tests/ArcBoxAuthTests/PKCETests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import Testing - -@testable import ArcBoxAuth - -struct PKCETests { - @Test func verifierHasRFC7636LengthAndCharset() { - let verifier = PKCE.generateCodePair().verifier - // 32 random bytes -> 43 base64url chars without padding. - #expect(verifier.count == 43) - let unreserved = CharacterSet( - charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") - #expect(verifier.unicodeScalars.allSatisfy { unreserved.contains($0) }) - } - - @Test func challengeMatchesRFC7636TestVector() { - // RFC 7636 Appendix B. - let challenge = PKCE.challenge(for: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") - #expect(challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") - } - - @Test func challengeDiffersFromVerifier() { - let pair = PKCE.generateCodePair() - #expect(pair.challenge != pair.verifier) - } - - @Test func tokensAreUnique() { - #expect(PKCE.generateRandomToken() != PKCE.generateRandomToken()) - } - - @Test func tokenRespectsByteCount() { - // 16 bytes -> 22 base64url chars without padding. - #expect(PKCE.generateRandomToken(byteCount: 16).count == 22) - } -} diff --git a/Packages/FleetControlClient/PROTO_SOURCE b/Packages/FleetControlClient/PROTO_SOURCE new file mode 100644 index 00000000..dfc3a9e9 --- /dev/null +++ b/Packages/FleetControlClient/PROTO_SOURCE @@ -0,0 +1,4 @@ +repository=https://github.com/ArcBoxLabs/arcbox +commit=224eedbd7a11b41d68a1592593bb505123ea0d8b +path=fleet/arcbox-fleet-control-proto/proto/arcbox/fleet/control/v1/control.proto +sha256=d9ff764f929db2de49083637a6df11ab41bb97b568ed3d200e2085e4c8a4168e diff --git a/Packages/FleetControlClient/Package.resolved b/Packages/FleetControlClient/Package.resolved new file mode 100644 index 00000000..4d6ccb0d --- /dev/null +++ b/Packages/FleetControlClient/Package.resolved @@ -0,0 +1,204 @@ +{ + "originHash" : "0b038f465107c88189c2887e0a26e0687d6a46cf0c92e5937d90817e5496f7cf", + "pins" : [ + { + "identity" : "grpc-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift.git", + "state" : { + "revision" : "adc18c3e1c55027d0ce43893897ac448e3f27ebe", + "version" : "2.2.3" + } + }, + { + "identity" : "grpc-swift-nio-transport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", + "state" : { + "revision" : "ca2303eb7f3df556beafbba33a143ffa30d5b786", + "version" : "1.2.3" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "53e89e3a5d417307f70a721c7b83e564fefb1e1c", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "eaa10d47d19919979dd8df22ff4fd6349b1108d4", + "version" : "1.19.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd3e1152083706d77b223fb29110e590efcc70c0", + "version" : "2.101.2" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", + "version" : "1.34.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", + "version" : "2.37.1" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" + } + } + ], + "version" : 3 +} diff --git a/Packages/FleetControlClient/Package.swift b/Packages/FleetControlClient/Package.swift new file mode 100644 index 00000000..40b682d9 --- /dev/null +++ b/Packages/FleetControlClient/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "FleetControlClient", + platforms: [ + .macOS(.v15) + ], + products: [ + .library(name: "FleetControlClient", targets: ["FleetControlClient"]) + ], + dependencies: [ + .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "1.2.0"), + .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.35.0"), + ], + targets: [ + .target( + name: "FleetControlClient", + dependencies: [ + .product(name: "GRPCNIOTransportHTTP2TransportServices", package: "grpc-swift-nio-transport"), + .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + ] + ), + .testTarget( + name: "FleetControlClientTests", + dependencies: ["FleetControlClient"] + ), + ] +) diff --git a/Packages/FleetControlClient/Sources/FleetControlClient/FleetControlClient.swift b/Packages/FleetControlClient/Sources/FleetControlClient/FleetControlClient.swift new file mode 100644 index 00000000..9da00488 --- /dev/null +++ b/Packages/FleetControlClient/Sources/FleetControlClient/FleetControlClient.swift @@ -0,0 +1,389 @@ +import Foundation +import GRPCCore +import GRPCNIOTransportHTTP2TransportServices +import os + +/// High-level client for the local fleet-agent control API. +/// +/// This client talks to the standalone fleet agent over its owner-only Unix +/// domain socket. It intentionally exposes desktop-friendly models instead of +/// generated protobuf stubs. +@available(macOS 15.0, *) +public final class FleetControlClient: Sendable { + /// Default Unix socket path for the local fleet agent. + public static let defaultSocketPath: String = { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.arcbox/fleet/agent.sock" + }() + + /// Default timeout for unary RPCs. + public static let defaultRPCTimeout: Duration = .seconds(15) + + private let socketPath: String + private let _grpcClient: OSAllocatedUnfairLock> + private let _closed: OSAllocatedUnfairLock + + /// Creates a client targeting the given fleet-agent Unix socket. + /// + /// The transport is not started until ``runConnections()`` is called. + public init(socketPath: String = FleetControlClient.defaultSocketPath) throws { + self.socketPath = socketPath + self._closed = OSAllocatedUnfairLock(initialState: false) + + let transport = try Self.makeTransport(socketPath: socketPath) + self._grpcClient = OSAllocatedUnfairLock( + initialState: GRPCClient(transport: transport) + ) + } + + /// Run the gRPC transport with automatic recovery. + /// + /// Blocks until cancelled or ``close()`` is called. Service clients are + /// recreated after transport termination, so callers should not cache + /// generated service clients. + public func runConnections() async throws { + while !Task.isCancelled && !_closed.withLock({ $0 }) { + let client = _grpcClient.withLock { $0 } + + do { + try await client.runConnections() + } catch is CancellationError { + return + } catch { + FleetControlLog.grpc.warning("Fleet control transport failed, will recreate: \(error)") + } + + guard !Task.isCancelled, !_closed.withLock({ $0 }) else { return } + + do { + let transport = try Self.makeTransport(socketPath: socketPath) + _grpcClient.withLock { $0 = GRPCClient(transport: transport) } + } catch { + FleetControlLog.grpc.warning("Failed to recreate fleet control transport: \(error)") + try? await Task.sleep(for: .seconds(5)) + continue + } + + try? await Task.sleep(for: .seconds(1)) + } + } + + /// Whether the client has been closed. + public var isClosed: Bool { + _closed.withLock { $0 } + } + + /// Initiates graceful shutdown of the transport. + public func close() { + let wasClosed = _closed.withLock { value -> Bool in + let previous = value + value = true + return previous + } + + guard !wasClosed else { return } + _grpcClient.withLock { $0 }.beginGracefulShutdown() + FleetControlLog.grpc.info("FleetControlClient closed") + } + + /// Reads agent version and capability flags. + /// + /// `waitForReady` is useful for bounded startup probes while the agent is + /// creating its local socket. Normal UI calls should keep the fail-fast + /// default. + public func getAgentInfo( + timeout: Duration = FleetControlClient.defaultRPCTimeout, + waitForReady: Bool = false + ) async throws -> FleetAgentInfo { + let response = try await lifecycle.getAgentInfo( + .init(), + options: Self.callOptions(timeout: timeout, waitForReady: waitForReady) + ) + return FleetAgentInfo(proto: response) + } + + /// Enrolls this host with an enrollment token. + /// + /// The machine credential is exchanged and persisted by the fleet agent, + /// not by the desktop app. The deadline bounds a blackholed gateway; after + /// handoff, any failure is an unknown outcome that callers must reconcile + /// via Watch rather than retry with a fresh token. + public func enroll(token: String, controlPlane: String? = nil) async throws -> String { + var request = Arcbox_Fleet_Control_V1_EnrollRequest() + request.enrollmentToken = token + if let controlPlane { + request.controlPlane = controlPlane + } + + let response = try await lifecycle.enroll( + request, + options: Self.enrollmentCallOptions + ) + return response.machineID + } + + /// Stops accepting new offers while allowing in-flight jobs to finish. + public func drain() async throws { + _ = try await lifecycle.drain( + .init(), + options: Self.defaultCallOptions + ) + } + + /// Resumes accepting offers after draining. + public func resume() async throws { + _ = try await lifecycle.resume( + .init(), + options: Self.defaultCallOptions + ) + } + + /// Removes the persisted machine credential and returns to unenrolled. + public func unenroll() async throws { + _ = try await lifecycle.unenroll( + .init(), + options: Self.defaultCallOptions + ) + } + + /// Reads coarse lifecycle status. + public func getStatus() async throws -> FleetAgentStatus { + let response = try await lifecycle.getStatus( + .init(), + options: Self.defaultCallOptions + ) + return FleetAgentStatus(proto: response) + } + + /// Streams live agent snapshots. + /// + /// The first yielded value is the current snapshot, followed by updates as + /// the agent state changes. The stream finishes with an error if the RPC + /// fails; ViewModels should reconnect with backoff. + public func watchSnapshots() -> AsyncThrowingStream { + AsyncThrowingStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let task = Task { + do { + let service = state + try await service.watch( + .init(), + options: Self.streamingCallOptions + ) { response in + for try await message in response.messages { + guard let snapshot = FleetAgentSnapshot(proto: message) else { + continue + } + if case .terminated = continuation.yield(snapshot) { + return + } + } + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Prepares the requested image settings and streams their progress. + /// + /// An empty kinds array asks the agent to prepare every supported image. + public func prepareImages( + _ kinds: [FleetImageKind] = [] + ) -> AsyncThrowingStream { + let request: Arcbox_Fleet_Control_V1_PrepareRequest = { + var request = Arcbox_Fleet_Control_V1_PrepareRequest() + request.kinds = kinds.map(\.protoValue) + return request + }() + + return AsyncThrowingStream { continuation in + let task = Task { + do { + let service = image + try await service.prepare( + request, + options: Self.streamingCallOptions + ) { response in + for try await message in response.messages { + continuation.yield(FleetImagePreparationEvent(proto: message)) + } + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Reads the current persisted agent settings. + public func getSettings() async throws -> FleetAgentSettings { + let response = try await settings.getSettings( + .init(), + options: Self.defaultCallOptions + ) + guard response.hasSettings else { + return FleetAgentSettings() + } + return FleetAgentSettings(proto: response.settings) + } + + /// Applies a partial settings update. + /// + /// Nil update fields are omitted from the request, preserving proto + /// presence semantics. + public func updateSettings(_ update: FleetSettingsUpdate) async throws -> FleetAgentSettings { + let response = try await settings.updateSettings( + update.protoValue, + options: Self.defaultCallOptions + ) + guard response.hasSettings else { + return FleetAgentSettings() + } + return FleetAgentSettings(proto: response.settings) + } + + /// Applies a partial settings update. + public func updateSettings( + loadCeiling: Double? = nil, + memFloorMib: UInt64? = nil, + linuxRunnerImage: String? = nil, + gateway: String? = nil, + dockerMode: FleetDockerMode? = nil, + runnerScript: String? = nil, + participate: Bool? = nil, + macosRunnerImage: String? = nil, + vmMode: FleetVmMode? = nil + ) async throws -> FleetAgentSettings { + try await updateSettings( + FleetSettingsUpdate( + loadCeiling: loadCeiling, + memFloorMib: memFloorMib, + linuxRunnerImage: linuxRunnerImage, + gateway: gateway, + dockerMode: dockerMode, + runnerScript: runnerScript, + participate: participate, + macosRunnerImage: macosRunnerImage, + vmMode: vmMode + ) + ) + } + + /// Maps transport and gRPC failures to UI-friendly text. + public static func userMessage(for error: Error) -> String { + let description = String(describing: error) + + if description.contains("unavailable") || description.contains("UNAVAILABLE") { + return "Cannot reach the fleet agent. Is it running?" + } + if description.contains("No such file") + || description.contains("ENOENT") + || description.contains("agent.sock") + { + return "Fleet agent is not running." + } + if description.contains("deadline") || description.contains("DEADLINE_EXCEEDED") { + return "Operation timed out. The fleet agent may be busy." + } + if description.contains("permission") || description.contains("PERMISSION_DENIED") { + return "Permission denied. Check fleet agent socket permissions." + } + if description.contains("already exists") || description.contains("ALREADY_EXISTS") { + return "This Mac is already enrolled in the fleet." + } + if description.contains("not found") || description.contains("NOT_FOUND") { + return "Fleet resource not found." + } + if description.contains("invalid argument") || description.contains("INVALID_ARGUMENT") { + return "Fleet settings were rejected by the agent." + } + if description.contains("ECONNREFUSED") || description.contains("Connection refused") { + return "Connection refused. Is the fleet agent running?" + } + + return error.localizedDescription + } + + private static var defaultCallOptions: GRPCCore.CallOptions { + callOptions(timeout: defaultRPCTimeout) + } + + private static var streamingCallOptions: GRPCCore.CallOptions { + .defaults + } + + private static var enrollmentCallOptions: GRPCCore.CallOptions { + callOptions(timeout: .seconds(30)) + } + + private static func callOptions( + timeout: Duration, + waitForReady: Bool = false + ) -> GRPCCore.CallOptions { + var options = CallOptions.defaults + options.timeout = timeout + options.waitForReady = waitForReady + return options + } + + private static func makeTransport( + socketPath: String + ) throws -> HTTP2ClientTransport.TransportServices { + try HTTP2ClientTransport.TransportServices( + target: .unixDomainSocket(path: socketPath), + transportSecurity: .plaintext, + config: .defaults { $0.http2.authority = "arcbox-fleet.local" } + ) + } + + private var grpcClient: GRPCClient { + _grpcClient.withLock { $0 } + } + + private var lifecycle: + Arcbox_Fleet_Control_V1_FleetLifecycleService.Client< + HTTP2ClientTransport.TransportServices + > + { + .init(wrapping: grpcClient) + } + + private var state: + Arcbox_Fleet_Control_V1_FleetStateService.Client< + HTTP2ClientTransport.TransportServices + > + { + .init(wrapping: grpcClient) + } + + private var settings: + Arcbox_Fleet_Control_V1_FleetSettingsService.Client< + HTTP2ClientTransport.TransportServices + > + { + .init(wrapping: grpcClient) + } + + private var image: + Arcbox_Fleet_Control_V1_FleetImageService.Client< + HTTP2ClientTransport.TransportServices + > + { + .init(wrapping: grpcClient) + } +} diff --git a/Packages/FleetControlClient/Sources/FleetControlClient/FleetControlModels.swift b/Packages/FleetControlClient/Sources/FleetControlClient/FleetControlModels.swift new file mode 100644 index 00000000..14b9c435 --- /dev/null +++ b/Packages/FleetControlClient/Sources/FleetControlClient/FleetControlModels.swift @@ -0,0 +1,580 @@ +/// Version and capability handshake returned by the local fleet agent. +public struct FleetAgentInfo: Equatable, Sendable { + public var agentVersion: String + public var apiVersion: UInt32 + public var features: [String] + + public init(agentVersion: String, apiVersion: UInt32, features: [String]) { + self.agentVersion = agentVersion + self.apiVersion = apiVersion + self.features = features + } + + public func supportsFeature(_ feature: String) -> Bool { + features.contains(feature) + } +} + +/// Coarse lifecycle status from FleetLifecycleService.GetStatus. +public struct FleetAgentStatus: Equatable, Sendable { + public var state: FleetConnectionState + public var machineID: String? + + public init(state: FleetConnectionState, machineID: String?) { + self.state = state + self.machineID = machineID + } +} + +/// Coarse enrollment state exposed by GetStatus. +public enum FleetConnectionState: Equatable, Sendable { + case unspecified + case unenrolled + case enrolled + case draining + case detached + case credentialRejected + case unrecognized(Int) +} + +/// Live enrollment/connectivity state exposed by Watch snapshots. +public enum FleetEnrollmentState: Equatable, Sendable { + case unspecified + case unenrolled + case attaching + case attached + case updating + case detached + case credentialRejected + case unrecognized(Int) +} + +/// Execution backend advertised for a fleet capability. +public enum FleetBackend: Equatable, Sendable { + case unspecified + case hostRunner + case docker + case vm + case unrecognized(Int) +} + +/// Docker runner policy mode. +public enum FleetDockerMode: Equatable, Sendable { + case unspecified + case auto + case enabled + case disabled + case unrecognized(Int) +} + +/// macOS runner VM policy mode. +public enum FleetVmMode: Equatable, Sendable { + case unspecified + case auto + case enabled + case disabled + case unrecognized(Int) +} + +/// A setting with an observed current value and a desired target value. +public struct FleetSetting: Equatable, Sendable { + public var current: Value + public var target: Value + + public init(current: Value, target: Value) { + self.current = current + self.target = target + } + + public var isPending: Bool { + current != target + } +} + +/// Persisted fleet-agent settings, preserving field presence from the proto. +public struct FleetAgentSettings: Equatable, Sendable { + public var loadCeiling: FleetSetting? + public var memFloorMib: FleetSetting? + public var linuxRunnerImage: FleetSetting? + public var gateway: FleetSetting? + public var dockerMode: FleetSetting? + public var runnerScript: FleetSetting? + public var participate: FleetSetting? + public var macosRunnerImage: FleetSetting? + public var vmMode: FleetSetting? + + public init( + loadCeiling: FleetSetting? = nil, + memFloorMib: FleetSetting? = nil, + linuxRunnerImage: FleetSetting? = nil, + gateway: FleetSetting? = nil, + dockerMode: FleetSetting? = nil, + runnerScript: FleetSetting? = nil, + participate: FleetSetting? = nil, + macosRunnerImage: FleetSetting? = nil, + vmMode: FleetSetting? = nil + ) { + self.loadCeiling = loadCeiling + self.memFloorMib = memFloorMib + self.linuxRunnerImage = linuxRunnerImage + self.gateway = gateway + self.dockerMode = dockerMode + self.runnerScript = runnerScript + self.participate = participate + self.macosRunnerImage = macosRunnerImage + self.vmMode = vmMode + } + + public var hasPendingChanges: Bool { + loadCeiling?.isPending == true + || memFloorMib?.isPending == true + || linuxRunnerImage?.isPending == true + || gateway?.isPending == true + || dockerMode?.isPending == true + || runnerScript?.isPending == true + || participate?.isPending == true + || macosRunnerImage?.isPending == true + || vmMode?.isPending == true + } +} + +/// Partial settings update. Nil means "leave this field unchanged". +public struct FleetSettingsUpdate: Equatable, Sendable { + public var loadCeiling: Double? + public var memFloorMib: UInt64? + public var linuxRunnerImage: String? + public var gateway: String? + public var dockerMode: FleetDockerMode? + public var runnerScript: String? + public var participate: Bool? + public var macosRunnerImage: String? + public var vmMode: FleetVmMode? + + public init( + loadCeiling: Double? = nil, + memFloorMib: UInt64? = nil, + linuxRunnerImage: String? = nil, + gateway: String? = nil, + dockerMode: FleetDockerMode? = nil, + runnerScript: String? = nil, + participate: Bool? = nil, + macosRunnerImage: String? = nil, + vmMode: FleetVmMode? = nil + ) { + self.loadCeiling = loadCeiling + self.memFloorMib = memFloorMib + self.linuxRunnerImage = linuxRunnerImage + self.gateway = gateway + self.dockerMode = dockerMode + self.runnerScript = runnerScript + self.participate = participate + self.macosRunnerImage = macosRunnerImage + self.vmMode = vmMode + } + + public var isEmpty: Bool { + loadCeiling == nil + && memFloorMib == nil + && linuxRunnerImage == nil + && gateway == nil + && dockerMode == nil + && runnerScript == nil + && participate == nil + && macosRunnerImage == nil + && vmMode == nil + } +} + +/// Host telemetry reported by the fleet agent. +public struct FleetHostTelemetry: Equatable, Sendable { + public var loadAverage1Minute: Double + public var cpuCount: UInt32 + public var memoryTotalMib: UInt64 + public var memoryAvailableMib: UInt64 + + public init( + loadAverage1Minute: Double, + cpuCount: UInt32, + memoryTotalMib: UInt64, + memoryAvailableMib: UInt64 + ) { + self.loadAverage1Minute = loadAverage1Minute + self.cpuCount = cpuCount + self.memoryTotalMib = memoryTotalMib + self.memoryAvailableMib = memoryAvailableMib + } +} + +/// Capability advertised by this host. +public struct FleetCapability: Equatable, Sendable, Identifiable { + public var os: String + public var arch: String + public var backend: FleetBackend + + public init(os: String, arch: String, backend: FleetBackend) { + self.os = os + self.arch = arch + self.backend = backend + } + + public var id: String { + "\(os):\(arch):\(backend)" + } +} + +/// Job currently running on this host. +public struct FleetInFlightJob: Equatable, Sendable, Identifiable { + public var jobID: String + public var os: String + public var arch: String + + public init(jobID: String, os: String, arch: String) { + self.jobID = jobID + self.os = os + self.arch = arch + } + + public var id: String { + jobID + } +} + +/// Recent offer admission decision reported by the agent. +public struct FleetOfferVerdict: Equatable, Sendable, Identifiable { + public var jobID: String + public var accepted: Bool + public var reason: String? + + public init(jobID: String, accepted: Bool, reason: String?) { + self.jobID = jobID + self.accepted = accepted + self.reason = reason + } + + public var id: String { + "\(jobID):\(accepted):\(reason ?? "")" + } +} + +/// Full live state snapshot streamed by FleetStateService.Watch. +public struct FleetAgentSnapshot: Equatable, Sendable { + public var enrollment: FleetEnrollmentState + public var machineID: String? + public var isDraining: Bool + public var capabilities: [FleetCapability] + public var inFlightJobs: [FleetInFlightJob] + public var recentVerdicts: [FleetOfferVerdict] + public var telemetry: FleetHostTelemetry? + public var settings: FleetAgentSettings? + + public init( + enrollment: FleetEnrollmentState, + machineID: String?, + isDraining: Bool, + capabilities: [FleetCapability], + inFlightJobs: [FleetInFlightJob], + recentVerdicts: [FleetOfferVerdict], + telemetry: FleetHostTelemetry?, + settings: FleetAgentSettings? + ) { + self.enrollment = enrollment + self.machineID = machineID + self.isDraining = isDraining + self.capabilities = capabilities + self.inFlightJobs = inFlightJobs + self.recentVerdicts = recentVerdicts + self.telemetry = telemetry + self.settings = settings + } +} + +extension FleetAgentInfo { + init(proto: Arcbox_Fleet_Control_V1_GetAgentInfoResponse) { + self.init( + agentVersion: proto.agentVersion, + apiVersion: proto.apiVersion, + features: proto.features + ) + } +} + +extension FleetAgentStatus { + init(proto: Arcbox_Fleet_Control_V1_GetStatusResponse) { + self.init( + state: FleetConnectionState(proto: proto.state), + machineID: proto.machineID.nonEmpty + ) + } +} + +extension FleetConnectionState { + init(proto: Arcbox_Fleet_Control_V1_ConnectionState) { + switch proto { + case .unspecified: + self = .unspecified + case .unenrolled: + self = .unenrolled + case .enrolled: + self = .enrolled + case .draining: + self = .draining + case .detached: + self = .detached + case .credentialRejected: + self = .credentialRejected + case .UNRECOGNIZED(let value): + self = .unrecognized(value) + } + } +} + +extension FleetEnrollmentState { + init(proto: Arcbox_Fleet_Control_V1_Enrollment) { + switch proto { + case .unspecified: + self = .unspecified + case .unenrolled: + self = .unenrolled + case .attaching: + self = .attaching + case .attached: + self = .attached + case .updating: + self = .updating + case .detached: + self = .detached + case .credentialRejected: + self = .credentialRejected + case .UNRECOGNIZED(let value): + self = .unrecognized(value) + } + } +} + +extension FleetBackend { + init(proto: Arcbox_Fleet_Control_V1_Backend) { + switch proto { + case .unspecified: + self = .unspecified + case .hostRunner: + self = .hostRunner + case .docker: + self = .docker + case .vm: + self = .vm + case .UNRECOGNIZED(let value): + self = .unrecognized(value) + } + } +} + +extension FleetDockerMode { + init(proto: Arcbox_Fleet_Control_V1_DockerMode) { + switch proto { + case .unspecified: + self = .unspecified + case .auto: + self = .auto + case .enabled: + self = .enabled + case .disabled: + self = .disabled + case .UNRECOGNIZED(let value): + self = .unrecognized(value) + } + } + + var protoValue: Arcbox_Fleet_Control_V1_DockerMode { + switch self { + case .unspecified: + return .unspecified + case .auto: + return .auto + case .enabled: + return .enabled + case .disabled: + return .disabled + case .unrecognized(let value): + return .UNRECOGNIZED(value) + } + } +} + +extension FleetVmMode { + init(proto: Arcbox_Fleet_Control_V1_VmMode) { + switch proto { + case .unspecified: + self = .unspecified + case .auto: + self = .auto + case .enabled: + self = .enabled + case .disabled: + self = .disabled + case .UNRECOGNIZED(let value): + self = .unrecognized(value) + } + } + + var protoValue: Arcbox_Fleet_Control_V1_VmMode { + switch self { + case .unspecified: + return .unspecified + case .auto: + return .auto + case .enabled: + return .enabled + case .disabled: + return .disabled + case .unrecognized(let value): + return .UNRECOGNIZED(value) + } + } +} + +extension FleetAgentSettings { + init(proto: Arcbox_Fleet_Control_V1_AgentSettings) { + self.init( + loadCeiling: proto.hasLoadCeiling + ? FleetSetting(current: proto.loadCeiling.current, target: proto.loadCeiling.target) + : nil, + memFloorMib: proto.hasMemFloorMib + ? FleetSetting(current: proto.memFloorMib.current, target: proto.memFloorMib.target) + : nil, + linuxRunnerImage: proto.hasLinuxRunnerImage + ? FleetSetting( + current: proto.linuxRunnerImage.current, + target: proto.linuxRunnerImage.target + ) + : nil, + gateway: proto.hasGateway + ? FleetSetting(current: proto.gateway.current, target: proto.gateway.target) + : nil, + dockerMode: proto.hasDockerMode + ? FleetSetting( + current: FleetDockerMode(proto: proto.dockerMode.current), + target: FleetDockerMode(proto: proto.dockerMode.target) + ) + : nil, + runnerScript: proto.hasRunnerScript + ? FleetSetting(current: proto.runnerScript.current, target: proto.runnerScript.target) + : nil, + participate: proto.hasParticipate + ? FleetSetting(current: proto.participate.current, target: proto.participate.target) + : nil, + macosRunnerImage: proto.hasMacosRunnerImage + ? FleetSetting( + current: proto.macosRunnerImage.current, + target: proto.macosRunnerImage.target + ) + : nil, + vmMode: proto.hasVmMode + ? FleetSetting( + current: FleetVmMode(proto: proto.vmMode.current), + target: FleetVmMode(proto: proto.vmMode.target) + ) + : nil + ) + } +} + +extension FleetSettingsUpdate { + var protoValue: Arcbox_Fleet_Control_V1_UpdateSettingsRequest { + var request = Arcbox_Fleet_Control_V1_UpdateSettingsRequest() + if let loadCeiling { + request.loadCeiling = loadCeiling + } + if let memFloorMib { + request.memFloorMib = memFloorMib + } + if let linuxRunnerImage { + request.linuxRunnerImage = linuxRunnerImage + } + if let gateway { + request.gateway = gateway + } + if let dockerMode { + request.dockerMode = dockerMode.protoValue + } + if let runnerScript { + request.runnerScript = runnerScript + } + if let participate { + request.participate = participate + } + if let macosRunnerImage { + request.macosRunnerImage = macosRunnerImage + } + if let vmMode { + request.vmMode = vmMode.protoValue + } + return request + } +} + +extension FleetHostTelemetry { + init(proto: Arcbox_Fleet_Control_V1_HostTelemetry) { + self.init( + loadAverage1Minute: proto.loadAvg1M, + cpuCount: proto.cpuCount, + memoryTotalMib: proto.memTotalMib, + memoryAvailableMib: proto.memAvailableMib + ) + } +} + +extension FleetCapability { + init(proto: Arcbox_Fleet_Control_V1_Capability) { + self.init( + os: proto.os, + arch: proto.arch, + backend: FleetBackend(proto: proto.backedBy) + ) + } +} + +extension FleetInFlightJob { + init(proto: Arcbox_Fleet_Control_V1_InFlightJob) { + self.init( + jobID: proto.jobID, + os: proto.os, + arch: proto.arch + ) + } +} + +extension FleetOfferVerdict { + init(proto: Arcbox_Fleet_Control_V1_OfferVerdict) { + self.init( + jobID: proto.jobID, + accepted: proto.accepted, + reason: proto.reason.nonEmpty + ) + } +} + +extension FleetAgentSnapshot { + init(proto: Arcbox_Fleet_Control_V1_AgentStateSnapshot) { + self.init( + enrollment: FleetEnrollmentState(proto: proto.enrollment), + machineID: proto.machineID.nonEmpty, + isDraining: proto.draining, + capabilities: proto.capabilities.map(FleetCapability.init(proto:)), + inFlightJobs: proto.inFlight.map(FleetInFlightJob.init(proto:)), + recentVerdicts: proto.recentVerdicts.map(FleetOfferVerdict.init(proto:)), + telemetry: proto.hasTelemetry ? FleetHostTelemetry(proto: proto.telemetry) : nil, + settings: proto.hasSettings ? FleetAgentSettings(proto: proto.settings) : nil + ) + } + + init?(proto: Arcbox_Fleet_Control_V1_WatchResponse) { + guard proto.hasSnapshot else { return nil } + self.init(proto: proto.snapshot) + } +} + +extension String { + fileprivate var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Packages/FleetControlClient/Sources/FleetControlClient/FleetImageModels.swift b/Packages/FleetControlClient/Sources/FleetControlClient/FleetImageModels.swift new file mode 100644 index 00000000..84ae4a20 --- /dev/null +++ b/Packages/FleetControlClient/Sources/FleetControlClient/FleetImageModels.swift @@ -0,0 +1,66 @@ +/// A fleet image setting that the local agent can prepare. +public enum FleetImageKind: Equatable, Sendable { + case unspecified + case linuxRunnerImage + case macosRunnerImage + case unrecognized(Int) +} + +/// Progress from FleetImageService.Prepare for one image preparation step. +public struct FleetImagePreparationEvent: Equatable, Sendable { + public var kind: FleetImageKind + public var detail: String + public var stage: String + public var fraction: Double + + public init( + kind: FleetImageKind, + detail: String, + stage: String, + fraction: Double + ) { + self.kind = kind + self.detail = detail + self.stage = stage + self.fraction = fraction + } +} + +extension FleetImageKind { + init(proto: Arcbox_Fleet_Control_V1_ImageKind) { + switch proto { + case .unspecified: + self = .unspecified + case .linuxRunnerImage: + self = .linuxRunnerImage + case .macosRunnerImage: + self = .macosRunnerImage + case .UNRECOGNIZED(let value): + self = .unrecognized(value) + } + } + + var protoValue: Arcbox_Fleet_Control_V1_ImageKind { + switch self { + case .unspecified: + return .unspecified + case .linuxRunnerImage: + return .linuxRunnerImage + case .macosRunnerImage: + return .macosRunnerImage + case .unrecognized(let value): + return .UNRECOGNIZED(value) + } + } +} + +extension FleetImagePreparationEvent { + init(proto: Arcbox_Fleet_Control_V1_PrepareResponse) { + self.init( + kind: FleetImageKind(proto: proto.kind), + detail: proto.detail, + stage: proto.stage, + fraction: proto.fraction + ) + } +} diff --git a/Packages/FleetControlClient/Sources/FleetControlClient/Generated/arcbox/fleet/control/v1/control.grpc.swift b/Packages/FleetControlClient/Sources/FleetControlClient/Generated/arcbox/fleet/control/v1/control.grpc.swift new file mode 100644 index 00000000..5bab59a5 --- /dev/null +++ b/Packages/FleetControlClient/Sources/FleetControlClient/Generated/arcbox/fleet/control/v1/control.grpc.swift @@ -0,0 +1,2810 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the gRPC Swift generator plugin for the protocol buffer compiler. +// Source: arcbox/fleet/control/v1/control.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/grpc/grpc-swift + +import GRPCCore +import GRPCProtobuf + +// MARK: - arcbox.fleet.control.v1.FleetLifecycleService + +/// Namespace containing generated types for the "arcbox.fleet.control.v1.FleetLifecycleService" service. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +public enum Arcbox_Fleet_Control_V1_FleetLifecycleService { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetLifecycleService" service. + public static let descriptor = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService") + /// Namespace for method metadata. + public enum Method { + /// Namespace for "GetAgentInfo" metadata. + public enum GetAgentInfo { + /// Request type for "GetAgentInfo". + public typealias Input = Arcbox_Fleet_Control_V1_GetAgentInfoRequest + /// Response type for "GetAgentInfo". + public typealias Output = Arcbox_Fleet_Control_V1_GetAgentInfoResponse + /// Descriptor for "GetAgentInfo". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService"), + method: "GetAgentInfo" + ) + } + /// Namespace for "Enroll" metadata. + public enum Enroll { + /// Request type for "Enroll". + public typealias Input = Arcbox_Fleet_Control_V1_EnrollRequest + /// Response type for "Enroll". + public typealias Output = Arcbox_Fleet_Control_V1_EnrollResponse + /// Descriptor for "Enroll". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService"), + method: "Enroll" + ) + } + /// Namespace for "Drain" metadata. + public enum Drain { + /// Request type for "Drain". + public typealias Input = Arcbox_Fleet_Control_V1_DrainRequest + /// Response type for "Drain". + public typealias Output = Arcbox_Fleet_Control_V1_DrainResponse + /// Descriptor for "Drain". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService"), + method: "Drain" + ) + } + /// Namespace for "Resume" metadata. + public enum Resume { + /// Request type for "Resume". + public typealias Input = Arcbox_Fleet_Control_V1_ResumeRequest + /// Response type for "Resume". + public typealias Output = Arcbox_Fleet_Control_V1_ResumeResponse + /// Descriptor for "Resume". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService"), + method: "Resume" + ) + } + /// Namespace for "Unenroll" metadata. + public enum Unenroll { + /// Request type for "Unenroll". + public typealias Input = Arcbox_Fleet_Control_V1_UnenrollRequest + /// Response type for "Unenroll". + public typealias Output = Arcbox_Fleet_Control_V1_UnenrollResponse + /// Descriptor for "Unenroll". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService"), + method: "Unenroll" + ) + } + /// Namespace for "GetStatus" metadata. + public enum GetStatus { + /// Request type for "GetStatus". + public typealias Input = Arcbox_Fleet_Control_V1_GetStatusRequest + /// Response type for "GetStatus". + public typealias Output = Arcbox_Fleet_Control_V1_GetStatusResponse + /// Descriptor for "GetStatus". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService"), + method: "GetStatus" + ) + } + /// Descriptors for all methods in the "arcbox.fleet.control.v1.FleetLifecycleService" service. + public static let descriptors: [GRPCCore.MethodDescriptor] = [ + GetAgentInfo.descriptor, + Enroll.descriptor, + Drain.descriptor, + Resume.descriptor, + Unenroll.descriptor, + GetStatus.descriptor + ] + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension GRPCCore.ServiceDescriptor { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetLifecycleService" service. + public static let arcbox_fleet_control_v1_FleetLifecycleService = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetLifecycleService") +} + +// MARK: arcbox.fleet.control.v1.FleetLifecycleService (server) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetLifecycleService { + /// Streaming variant of the service protocol for the "arcbox.fleet.control.v1.FleetLifecycleService" service. + /// + /// This protocol is the lowest-level of the service protocols generated for this service + /// giving you the most flexibility over the implementation of your service. This comes at + /// the cost of more verbose and less strict APIs. Each RPC requires you to implement it in + /// terms of a request stream and response stream. Where only a single request or response + /// message is expected, you are responsible for enforcing this invariant is maintained. + /// + /// Where possible, prefer using the stricter, less-verbose ``ServiceProtocol`` + /// or ``SimpleServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Local control-plane API served by the fleet agent on a Unix socket + /// > (~/.arcbox/fleet/agent.sock, owner-only). Clients: the arcbox-fleet-agent + /// > CLI and the desktop app. The agent and its clients ship and self-update + /// > independently, so this API evolves additively — call GetAgentInfo and + /// > adapt to what it reports, rather than gating other RPCs on an exact + /// > version match. + public protocol StreamingServiceProtocol: GRPCCore.RegistrableRPCService { + /// Handle the "GetAgentInfo" method. + /// + /// > Source IDL Documentation: + /// > + /// > Version/capability handshake. Call first; do not hard-reject on skew. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_GetAgentInfoResponse` messages. + func getAgentInfo( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "Enroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Exchanges an enrollment token for the machine credential via the + /// > gateway, persists it, and starts attaching. This is the desktop-managed + /// > handoff: the machine credential is created and persisted here, in the + /// > agent — never in the desktop. Fails if already enrolled; Unenroll + /// > first. The headless/farm path instead uses the `arcbox-fleet-agent + /// > quick enroll` CLI subcommand before this process is even running. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_EnrollRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_EnrollResponse` messages. + func enroll( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "Drain" method. + /// + /// > Source IDL Documentation: + /// > + /// > Stops accepting new offers; in-flight jobs finish normally. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_DrainRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_DrainResponse` messages. + func drain( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "Resume" method. + /// + /// > Source IDL Documentation: + /// > + /// > Resumes accepting new offers after a Drain. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_ResumeRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_ResumeResponse` messages. + func resume( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "Unenroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Leaves the fleet — terminal. Stops attaching and removes the machine + /// > credential; the server keeps the machine record (decommissioned), so a + /// > later Enroll joins as a new machine. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_UnenrollRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_UnenrollResponse` messages. + func unenroll( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "GetStatus" method. + /// + /// > Source IDL Documentation: + /// > + /// > Current lifecycle/attachment status. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_GetStatusRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_GetStatusResponse` messages. + func getStatus( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + } + + /// Service protocol for the "arcbox.fleet.control.v1.FleetLifecycleService" service. + /// + /// This protocol is higher level than ``StreamingServiceProtocol`` but lower level than + /// the ``SimpleServiceProtocol``, it provides access to request and response metadata and + /// trailing response metadata. If you don't need these then consider using + /// the ``SimpleServiceProtocol``. If you need fine grained control over your RPCs then + /// use ``StreamingServiceProtocol``. + /// + /// > Source IDL Documentation: + /// > + /// > Local control-plane API served by the fleet agent on a Unix socket + /// > (~/.arcbox/fleet/agent.sock, owner-only). Clients: the arcbox-fleet-agent + /// > CLI and the desktop app. The agent and its clients ship and self-update + /// > independently, so this API evolves additively — call GetAgentInfo and + /// > adapt to what it reports, rather than gating other RPCs on an exact + /// > version match. + public protocol ServiceProtocol: Arcbox_Fleet_Control_V1_FleetLifecycleService.StreamingServiceProtocol { + /// Handle the "GetAgentInfo" method. + /// + /// > Source IDL Documentation: + /// > + /// > Version/capability handshake. Call first; do not hard-reject on skew. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_GetAgentInfoResponse` message. + func getAgentInfo( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + + /// Handle the "Enroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Exchanges an enrollment token for the machine credential via the + /// > gateway, persists it, and starts attaching. This is the desktop-managed + /// > handoff: the machine credential is created and persisted here, in the + /// > agent — never in the desktop. Fails if already enrolled; Unenroll + /// > first. The headless/farm path instead uses the `arcbox-fleet-agent + /// > quick enroll` CLI subcommand before this process is even running. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_EnrollRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_EnrollResponse` message. + func enroll( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + + /// Handle the "Drain" method. + /// + /// > Source IDL Documentation: + /// > + /// > Stops accepting new offers; in-flight jobs finish normally. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_DrainRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_DrainResponse` message. + func drain( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + + /// Handle the "Resume" method. + /// + /// > Source IDL Documentation: + /// > + /// > Resumes accepting new offers after a Drain. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_ResumeRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_ResumeResponse` message. + func resume( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + + /// Handle the "Unenroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Leaves the fleet — terminal. Stops attaching and removes the machine + /// > credential; the server keeps the machine record (decommissioned), so a + /// > later Enroll joins as a new machine. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UnenrollRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_UnenrollResponse` message. + func unenroll( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + + /// Handle the "GetStatus" method. + /// + /// > Source IDL Documentation: + /// > + /// > Current lifecycle/attachment status. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetStatusRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_GetStatusResponse` message. + func getStatus( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + } + + /// Simple service protocol for the "arcbox.fleet.control.v1.FleetLifecycleService" service. + /// + /// This is the highest level protocol for the service. The API is the easiest to use but + /// doesn't provide access to request or response metadata. If you need access to these + /// then use ``ServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Local control-plane API served by the fleet agent on a Unix socket + /// > (~/.arcbox/fleet/agent.sock, owner-only). Clients: the arcbox-fleet-agent + /// > CLI and the desktop app. The agent and its clients ship and self-update + /// > independently, so this API evolves additively — call GetAgentInfo and + /// > adapt to what it reports, rather than gating other RPCs on an exact + /// > version match. + public protocol SimpleServiceProtocol: Arcbox_Fleet_Control_V1_FleetLifecycleService.ServiceProtocol { + /// Handle the "GetAgentInfo" method. + /// + /// > Source IDL Documentation: + /// > + /// > Version/capability handshake. Call first; do not hard-reject on skew. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_GetAgentInfoResponse` to respond with. + func getAgentInfo( + request: Arcbox_Fleet_Control_V1_GetAgentInfoRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_GetAgentInfoResponse + + /// Handle the "Enroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Exchanges an enrollment token for the machine credential via the + /// > gateway, persists it, and starts attaching. This is the desktop-managed + /// > handoff: the machine credential is created and persisted here, in the + /// > agent — never in the desktop. Fails if already enrolled; Unenroll + /// > first. The headless/farm path instead uses the `arcbox-fleet-agent + /// > quick enroll` CLI subcommand before this process is even running. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_EnrollRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_EnrollResponse` to respond with. + func enroll( + request: Arcbox_Fleet_Control_V1_EnrollRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_EnrollResponse + + /// Handle the "Drain" method. + /// + /// > Source IDL Documentation: + /// > + /// > Stops accepting new offers; in-flight jobs finish normally. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_DrainRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_DrainResponse` to respond with. + func drain( + request: Arcbox_Fleet_Control_V1_DrainRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_DrainResponse + + /// Handle the "Resume" method. + /// + /// > Source IDL Documentation: + /// > + /// > Resumes accepting new offers after a Drain. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_ResumeRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_ResumeResponse` to respond with. + func resume( + request: Arcbox_Fleet_Control_V1_ResumeRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_ResumeResponse + + /// Handle the "Unenroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Leaves the fleet — terminal. Stops attaching and removes the machine + /// > credential; the server keeps the machine record (decommissioned), so a + /// > later Enroll joins as a new machine. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_UnenrollRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_UnenrollResponse` to respond with. + func unenroll( + request: Arcbox_Fleet_Control_V1_UnenrollRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_UnenrollResponse + + /// Handle the "GetStatus" method. + /// + /// > Source IDL Documentation: + /// > + /// > Current lifecycle/attachment status. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_GetStatusRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_GetStatusResponse` to respond with. + func getStatus( + request: Arcbox_Fleet_Control_V1_GetStatusRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_GetStatusResponse + } +} + +// Default implementation of 'registerMethods(with:)'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetLifecycleService.StreamingServiceProtocol { + public func registerMethods(with router: inout GRPCCore.RPCRouter) where Transport: GRPCCore.ServerTransport { + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.GetAgentInfo.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.getAgentInfo( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Enroll.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.enroll( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Drain.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.drain( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Resume.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.resume( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Unenroll.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.unenroll( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.GetStatus.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.getStatus( + request: request, + context: context + ) + } + ) + } +} + +// Default implementation of streaming methods from 'StreamingServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetLifecycleService.ServiceProtocol { + public func getAgentInfo( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.getAgentInfo( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + + public func enroll( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.enroll( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + + public func drain( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.drain( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + + public func resume( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.resume( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + + public func unenroll( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.unenroll( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + + public func getStatus( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.getStatus( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } +} + +// Default implementation of methods from 'ServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetLifecycleService.SimpleServiceProtocol { + public func getAgentInfo( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.getAgentInfo( + request: request.message, + context: context + ), + metadata: [:] + ) + } + + public func enroll( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.enroll( + request: request.message, + context: context + ), + metadata: [:] + ) + } + + public func drain( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.drain( + request: request.message, + context: context + ), + metadata: [:] + ) + } + + public func resume( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.resume( + request: request.message, + context: context + ), + metadata: [:] + ) + } + + public func unenroll( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.unenroll( + request: request.message, + context: context + ), + metadata: [:] + ) + } + + public func getStatus( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.getStatus( + request: request.message, + context: context + ), + metadata: [:] + ) + } +} + +// MARK: arcbox.fleet.control.v1.FleetLifecycleService (client) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetLifecycleService { + /// Generated client protocol for the "arcbox.fleet.control.v1.FleetLifecycleService" service. + /// + /// You don't need to implement this protocol directly, use the generated + /// implementation, ``Client``. + /// + /// > Source IDL Documentation: + /// > + /// > Local control-plane API served by the fleet agent on a Unix socket + /// > (~/.arcbox/fleet/agent.sock, owner-only). Clients: the arcbox-fleet-agent + /// > CLI and the desktop app. The agent and its clients ship and self-update + /// > independently, so this API evolves additively — call GetAgentInfo and + /// > adapt to what it reports, rather than gating other RPCs on an exact + /// > version match. + public protocol ClientProtocol: Sendable { + /// Call the "GetAgentInfo" method. + /// + /// > Source IDL Documentation: + /// > + /// > Version/capability handshake. Call first; do not hard-reject on skew. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_GetAgentInfoResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func getAgentInfo( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "Enroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Exchanges an enrollment token for the machine credential via the + /// > gateway, persists it, and starts attaching. This is the desktop-managed + /// > handoff: the machine credential is created and persisted here, in the + /// > agent — never in the desktop. Fails if already enrolled; Unenroll + /// > first. The headless/farm path instead uses the `arcbox-fleet-agent + /// > quick enroll` CLI subcommand before this process is even running. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_EnrollRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_EnrollRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_EnrollResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func enroll( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "Drain" method. + /// + /// > Source IDL Documentation: + /// > + /// > Stops accepting new offers; in-flight jobs finish normally. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_DrainRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_DrainRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_DrainResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func drain( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "Resume" method. + /// + /// > Source IDL Documentation: + /// > + /// > Resumes accepting new offers after a Drain. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_ResumeRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_ResumeRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_ResumeResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func resume( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "Unenroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Leaves the fleet — terminal. Stops attaching and removes the machine + /// > credential; the server keeps the machine record (decommissioned), so a + /// > later Enroll joins as a new machine. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UnenrollRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_UnenrollRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_UnenrollResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func unenroll( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "GetStatus" method. + /// + /// > Source IDL Documentation: + /// > + /// > Current lifecycle/attachment status. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetStatusRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_GetStatusRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_GetStatusResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func getStatus( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + } + + /// Generated client for the "arcbox.fleet.control.v1.FleetLifecycleService" service. + /// + /// The ``Client`` provides an implementation of ``ClientProtocol`` which wraps + /// a `GRPCCore.GRPCCClient`. The underlying `GRPCClient` provides the long-lived + /// means of communication with the remote peer. + /// + /// > Source IDL Documentation: + /// > + /// > Local control-plane API served by the fleet agent on a Unix socket + /// > (~/.arcbox/fleet/agent.sock, owner-only). Clients: the arcbox-fleet-agent + /// > CLI and the desktop app. The agent and its clients ship and self-update + /// > independently, so this API evolves additively — call GetAgentInfo and + /// > adapt to what it reports, rather than gating other RPCs on an exact + /// > version match. + public struct Client: ClientProtocol where Transport: GRPCCore.ClientTransport { + private let client: GRPCCore.GRPCClient + + /// Creates a new client wrapping the provided `GRPCCore.GRPCClient`. + /// + /// - Parameters: + /// - client: A `GRPCCore.GRPCClient` providing a communication channel to the service. + public init(wrapping client: GRPCCore.GRPCClient) { + self.client = client + } + + /// Call the "GetAgentInfo" method. + /// + /// > Source IDL Documentation: + /// > + /// > Version/capability handshake. Call first; do not hard-reject on skew. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_GetAgentInfoResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getAgentInfo( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.GetAgentInfo.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Enroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Exchanges an enrollment token for the machine credential via the + /// > gateway, persists it, and starts attaching. This is the desktop-managed + /// > handoff: the machine credential is created and persisted here, in the + /// > agent — never in the desktop. Fails if already enrolled; Unenroll + /// > first. The headless/farm path instead uses the `arcbox-fleet-agent + /// > quick enroll` CLI subcommand before this process is even running. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_EnrollRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_EnrollRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_EnrollResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func enroll( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Enroll.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Drain" method. + /// + /// > Source IDL Documentation: + /// > + /// > Stops accepting new offers; in-flight jobs finish normally. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_DrainRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_DrainRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_DrainResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func drain( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Drain.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Resume" method. + /// + /// > Source IDL Documentation: + /// > + /// > Resumes accepting new offers after a Drain. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_ResumeRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_ResumeRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_ResumeResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func resume( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Resume.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Unenroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Leaves the fleet — terminal. Stops attaching and removes the machine + /// > credential; the server keeps the machine record (decommissioned), so a + /// > later Enroll joins as a new machine. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UnenrollRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_UnenrollRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_UnenrollResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func unenroll( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.Unenroll.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "GetStatus" method. + /// + /// > Source IDL Documentation: + /// > + /// > Current lifecycle/attachment status. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetStatusRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_GetStatusRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_GetStatusResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getStatus( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetLifecycleService.Method.GetStatus.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + } +} + +// Helpers providing default arguments to 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetLifecycleService.ClientProtocol { + /// Call the "GetAgentInfo" method. + /// + /// > Source IDL Documentation: + /// > + /// > Version/capability handshake. Call first; do not hard-reject on skew. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetAgentInfoRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getAgentInfo( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.getAgentInfo( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Enroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Exchanges an enrollment token for the machine credential via the + /// > gateway, persists it, and starts attaching. This is the desktop-managed + /// > handoff: the machine credential is created and persisted here, in the + /// > agent — never in the desktop. Fails if already enrolled; Unenroll + /// > first. The headless/farm path instead uses the `arcbox-fleet-agent + /// > quick enroll` CLI subcommand before this process is even running. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_EnrollRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func enroll( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.enroll( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Drain" method. + /// + /// > Source IDL Documentation: + /// > + /// > Stops accepting new offers; in-flight jobs finish normally. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_DrainRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func drain( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.drain( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Resume" method. + /// + /// > Source IDL Documentation: + /// > + /// > Resumes accepting new offers after a Drain. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_ResumeRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func resume( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.resume( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Unenroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Leaves the fleet — terminal. Stops attaching and removes the machine + /// > credential; the server keeps the machine record (decommissioned), so a + /// > later Enroll joins as a new machine. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UnenrollRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func unenroll( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.unenroll( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "GetStatus" method. + /// + /// > Source IDL Documentation: + /// > + /// > Current lifecycle/attachment status. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetStatusRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getStatus( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.getStatus( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } +} + +// Helpers providing sugared APIs for 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetLifecycleService.ClientProtocol { + /// Call the "GetAgentInfo" method. + /// + /// > Source IDL Documentation: + /// > + /// > Version/capability handshake. Call first; do not hard-reject on skew. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getAgentInfo( + _ message: Arcbox_Fleet_Control_V1_GetAgentInfoRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.getAgentInfo( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Enroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Exchanges an enrollment token for the machine credential via the + /// > gateway, persists it, and starts attaching. This is the desktop-managed + /// > handoff: the machine credential is created and persisted here, in the + /// > agent — never in the desktop. Fails if already enrolled; Unenroll + /// > first. The headless/farm path instead uses the `arcbox-fleet-agent + /// > quick enroll` CLI subcommand before this process is even running. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func enroll( + _ message: Arcbox_Fleet_Control_V1_EnrollRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.enroll( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Drain" method. + /// + /// > Source IDL Documentation: + /// > + /// > Stops accepting new offers; in-flight jobs finish normally. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func drain( + _ message: Arcbox_Fleet_Control_V1_DrainRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.drain( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Resume" method. + /// + /// > Source IDL Documentation: + /// > + /// > Resumes accepting new offers after a Drain. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func resume( + _ message: Arcbox_Fleet_Control_V1_ResumeRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.resume( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Unenroll" method. + /// + /// > Source IDL Documentation: + /// > + /// > Leaves the fleet — terminal. Stops attaching and removes the machine + /// > credential; the server keeps the machine record (decommissioned), so a + /// > later Enroll joins as a new machine. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func unenroll( + _ message: Arcbox_Fleet_Control_V1_UnenrollRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.unenroll( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "GetStatus" method. + /// + /// > Source IDL Documentation: + /// > + /// > Current lifecycle/attachment status. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getStatus( + _ message: Arcbox_Fleet_Control_V1_GetStatusRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.getStatus( + request: request, + options: options, + onResponse: handleResponse + ) + } +} + +// MARK: - arcbox.fleet.control.v1.FleetStateService + +/// Namespace containing generated types for the "arcbox.fleet.control.v1.FleetStateService" service. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +public enum Arcbox_Fleet_Control_V1_FleetStateService { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetStateService" service. + public static let descriptor = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetStateService") + /// Namespace for method metadata. + public enum Method { + /// Namespace for "Watch" metadata. + public enum Watch { + /// Request type for "Watch". + public typealias Input = Arcbox_Fleet_Control_V1_WatchRequest + /// Response type for "Watch". + public typealias Output = Arcbox_Fleet_Control_V1_WatchResponse + /// Descriptor for "Watch". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetStateService"), + method: "Watch" + ) + } + /// Descriptors for all methods in the "arcbox.fleet.control.v1.FleetStateService" service. + public static let descriptors: [GRPCCore.MethodDescriptor] = [ + Watch.descriptor + ] + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension GRPCCore.ServiceDescriptor { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetStateService" service. + public static let arcbox_fleet_control_v1_FleetStateService = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetStateService") +} + +// MARK: arcbox.fleet.control.v1.FleetStateService (server) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetStateService { + /// Streaming variant of the service protocol for the "arcbox.fleet.control.v1.FleetStateService" service. + /// + /// This protocol is the lowest-level of the service protocols generated for this service + /// giving you the most flexibility over the implementation of your service. This comes at + /// the cost of more verbose and less strict APIs. Each RPC requires you to implement it in + /// terms of a request stream and response stream. Where only a single request or response + /// message is expected, you are responsible for enforcing this invariant is maintained. + /// + /// Where possible, prefer using the stricter, less-verbose ``ServiceProtocol`` + /// or ``SimpleServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Live state-watch: the desktop holds no state of its own and renders only + /// > what this streams — settings (a later addition) will push through the + /// > same snapshot instead of needing their own notification path. + public protocol StreamingServiceProtocol: GRPCCore.RegistrableRPCService { + /// Handle the "Watch" method. + /// + /// > Source IDL Documentation: + /// > + /// > Server-streaming: the first message is the current snapshot, then one + /// > per change for as long as the client stays subscribed. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_WatchRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_WatchResponse` messages. + func watch( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + } + + /// Service protocol for the "arcbox.fleet.control.v1.FleetStateService" service. + /// + /// This protocol is higher level than ``StreamingServiceProtocol`` but lower level than + /// the ``SimpleServiceProtocol``, it provides access to request and response metadata and + /// trailing response metadata. If you don't need these then consider using + /// the ``SimpleServiceProtocol``. If you need fine grained control over your RPCs then + /// use ``StreamingServiceProtocol``. + /// + /// > Source IDL Documentation: + /// > + /// > Live state-watch: the desktop holds no state of its own and renders only + /// > what this streams — settings (a later addition) will push through the + /// > same snapshot instead of needing their own notification path. + public protocol ServiceProtocol: Arcbox_Fleet_Control_V1_FleetStateService.StreamingServiceProtocol { + /// Handle the "Watch" method. + /// + /// > Source IDL Documentation: + /// > + /// > Server-streaming: the first message is the current snapshot, then one + /// > per change for as long as the client stays subscribed. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_WatchRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_WatchResponse` messages. + func watch( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + } + + /// Simple service protocol for the "arcbox.fleet.control.v1.FleetStateService" service. + /// + /// This is the highest level protocol for the service. The API is the easiest to use but + /// doesn't provide access to request or response metadata. If you need access to these + /// then use ``ServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Live state-watch: the desktop holds no state of its own and renders only + /// > what this streams — settings (a later addition) will push through the + /// > same snapshot instead of needing their own notification path. + public protocol SimpleServiceProtocol: Arcbox_Fleet_Control_V1_FleetStateService.ServiceProtocol { + /// Handle the "Watch" method. + /// + /// > Source IDL Documentation: + /// > + /// > Server-streaming: the first message is the current snapshot, then one + /// > per change for as long as the client stays subscribed. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_WatchRequest` message. + /// - response: A response stream of `Arcbox_Fleet_Control_V1_WatchResponse` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + func watch( + request: Arcbox_Fleet_Control_V1_WatchRequest, + response: GRPCCore.RPCWriter, + context: GRPCCore.ServerContext + ) async throws + } +} + +// Default implementation of 'registerMethods(with:)'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetStateService.StreamingServiceProtocol { + public func registerMethods(with router: inout GRPCCore.RPCRouter) where Transport: GRPCCore.ServerTransport { + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetStateService.Method.Watch.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.watch( + request: request, + context: context + ) + } + ) + } +} + +// Default implementation of streaming methods from 'StreamingServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetStateService.ServiceProtocol { + public func watch( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.watch( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return response + } +} + +// Default implementation of methods from 'ServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetStateService.SimpleServiceProtocol { + public func watch( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + return GRPCCore.StreamingServerResponse( + metadata: [:], + producer: { writer in + try await self.watch( + request: request.message, + response: writer, + context: context + ) + return [:] + } + ) + } +} + +// MARK: arcbox.fleet.control.v1.FleetStateService (client) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetStateService { + /// Generated client protocol for the "arcbox.fleet.control.v1.FleetStateService" service. + /// + /// You don't need to implement this protocol directly, use the generated + /// implementation, ``Client``. + /// + /// > Source IDL Documentation: + /// > + /// > Live state-watch: the desktop holds no state of its own and renders only + /// > what this streams — settings (a later addition) will push through the + /// > same snapshot instead of needing their own notification path. + public protocol ClientProtocol: Sendable { + /// Call the "Watch" method. + /// + /// > Source IDL Documentation: + /// > + /// > Server-streaming: the first message is the current snapshot, then one + /// > per change for as long as the client stays subscribed. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_WatchRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_WatchRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_WatchResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func watch( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + } + + /// Generated client for the "arcbox.fleet.control.v1.FleetStateService" service. + /// + /// The ``Client`` provides an implementation of ``ClientProtocol`` which wraps + /// a `GRPCCore.GRPCCClient`. The underlying `GRPCClient` provides the long-lived + /// means of communication with the remote peer. + /// + /// > Source IDL Documentation: + /// > + /// > Live state-watch: the desktop holds no state of its own and renders only + /// > what this streams — settings (a later addition) will push through the + /// > same snapshot instead of needing their own notification path. + public struct Client: ClientProtocol where Transport: GRPCCore.ClientTransport { + private let client: GRPCCore.GRPCClient + + /// Creates a new client wrapping the provided `GRPCCore.GRPCClient`. + /// + /// - Parameters: + /// - client: A `GRPCCore.GRPCClient` providing a communication channel to the service. + public init(wrapping client: GRPCCore.GRPCClient) { + self.client = client + } + + /// Call the "Watch" method. + /// + /// > Source IDL Documentation: + /// > + /// > Server-streaming: the first message is the current snapshot, then one + /// > per change for as long as the client stays subscribed. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_WatchRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_WatchRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_WatchResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func watch( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.client.serverStreaming( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetStateService.Method.Watch.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + } +} + +// Helpers providing default arguments to 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetStateService.ClientProtocol { + /// Call the "Watch" method. + /// + /// > Source IDL Documentation: + /// > + /// > Server-streaming: the first message is the current snapshot, then one + /// > per change for as long as the client stays subscribed. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_WatchRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func watch( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.watch( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } +} + +// Helpers providing sugared APIs for 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetStateService.ClientProtocol { + /// Call the "Watch" method. + /// + /// > Source IDL Documentation: + /// > + /// > Server-streaming: the first message is the current snapshot, then one + /// > per change for as long as the client stays subscribed. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func watch( + _ message: Arcbox_Fleet_Control_V1_WatchRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.watch( + request: request, + options: options, + onResponse: handleResponse + ) + } +} + +// MARK: - arcbox.fleet.control.v1.FleetSettingsService + +/// Namespace containing generated types for the "arcbox.fleet.control.v1.FleetSettingsService" service. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +public enum Arcbox_Fleet_Control_V1_FleetSettingsService { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetSettingsService" service. + public static let descriptor = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetSettingsService") + /// Namespace for method metadata. + public enum Method { + /// Namespace for "GetSettings" metadata. + public enum GetSettings { + /// Request type for "GetSettings". + public typealias Input = Arcbox_Fleet_Control_V1_GetSettingsRequest + /// Response type for "GetSettings". + public typealias Output = Arcbox_Fleet_Control_V1_GetSettingsResponse + /// Descriptor for "GetSettings". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetSettingsService"), + method: "GetSettings" + ) + } + /// Namespace for "UpdateSettings" metadata. + public enum UpdateSettings { + /// Request type for "UpdateSettings". + public typealias Input = Arcbox_Fleet_Control_V1_UpdateSettingsRequest + /// Response type for "UpdateSettings". + public typealias Output = Arcbox_Fleet_Control_V1_UpdateSettingsResponse + /// Descriptor for "UpdateSettings". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetSettingsService"), + method: "UpdateSettings" + ) + } + /// Descriptors for all methods in the "arcbox.fleet.control.v1.FleetSettingsService" service. + public static let descriptors: [GRPCCore.MethodDescriptor] = [ + GetSettings.descriptor, + UpdateSettings.descriptor + ] + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension GRPCCore.ServiceDescriptor { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetSettingsService" service. + public static let arcbox_fleet_control_v1_FleetSettingsService = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetSettingsService") +} + +// MARK: arcbox.fleet.control.v1.FleetSettingsService (server) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetSettingsService { + /// Streaming variant of the service protocol for the "arcbox.fleet.control.v1.FleetSettingsService" service. + /// + /// This protocol is the lowest-level of the service protocols generated for this service + /// giving you the most flexibility over the implementation of your service. This comes at + /// the cost of more verbose and less strict APIs. Each RPC requires you to implement it in + /// terms of a request stream and response stream. Where only a single request or response + /// message is expected, you are responsible for enforcing this invariant is maintained. + /// + /// Where possible, prefer using the stricter, less-verbose ``ServiceProtocol`` + /// or ``SimpleServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Persisted, settable configuration. The desktop holds no state of its + /// > own, so this rides the same Watch stream as everything else above + /// > instead of needing its own notification path. + public protocol StreamingServiceProtocol: GRPCCore.RegistrableRPCService { + /// Handle the "GetSettings" method. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_GetSettingsRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_GetSettingsResponse` messages. + func getSettings( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "UpdateSettings" method. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_UpdateSettingsResponse` messages. + func updateSettings( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + } + + /// Service protocol for the "arcbox.fleet.control.v1.FleetSettingsService" service. + /// + /// This protocol is higher level than ``StreamingServiceProtocol`` but lower level than + /// the ``SimpleServiceProtocol``, it provides access to request and response metadata and + /// trailing response metadata. If you don't need these then consider using + /// the ``SimpleServiceProtocol``. If you need fine grained control over your RPCs then + /// use ``StreamingServiceProtocol``. + /// + /// > Source IDL Documentation: + /// > + /// > Persisted, settable configuration. The desktop holds no state of its + /// > own, so this rides the same Watch stream as everything else above + /// > instead of needing its own notification path. + public protocol ServiceProtocol: Arcbox_Fleet_Control_V1_FleetSettingsService.StreamingServiceProtocol { + /// Handle the "GetSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetSettingsRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_GetSettingsResponse` message. + func getSettings( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + + /// Handle the "UpdateSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Arcbox_Fleet_Control_V1_UpdateSettingsResponse` message. + func updateSettings( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + } + + /// Simple service protocol for the "arcbox.fleet.control.v1.FleetSettingsService" service. + /// + /// This is the highest level protocol for the service. The API is the easiest to use but + /// doesn't provide access to request or response metadata. If you need access to these + /// then use ``ServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Persisted, settable configuration. The desktop holds no state of its + /// > own, so this rides the same Watch stream as everything else above + /// > instead of needing its own notification path. + public protocol SimpleServiceProtocol: Arcbox_Fleet_Control_V1_FleetSettingsService.ServiceProtocol { + /// Handle the "GetSettings" method. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_GetSettingsRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_GetSettingsResponse` to respond with. + func getSettings( + request: Arcbox_Fleet_Control_V1_GetSettingsRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_GetSettingsResponse + + /// Handle the "UpdateSettings" method. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Arcbox_Fleet_Control_V1_UpdateSettingsResponse` to respond with. + func updateSettings( + request: Arcbox_Fleet_Control_V1_UpdateSettingsRequest, + context: GRPCCore.ServerContext + ) async throws -> Arcbox_Fleet_Control_V1_UpdateSettingsResponse + } +} + +// Default implementation of 'registerMethods(with:)'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetSettingsService.StreamingServiceProtocol { + public func registerMethods(with router: inout GRPCCore.RPCRouter) where Transport: GRPCCore.ServerTransport { + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetSettingsService.Method.GetSettings.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.getSettings( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetSettingsService.Method.UpdateSettings.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.updateSettings( + request: request, + context: context + ) + } + ) + } +} + +// Default implementation of streaming methods from 'StreamingServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetSettingsService.ServiceProtocol { + public func getSettings( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.getSettings( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + + public func updateSettings( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.updateSettings( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } +} + +// Default implementation of methods from 'ServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetSettingsService.SimpleServiceProtocol { + public func getSettings( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.getSettings( + request: request.message, + context: context + ), + metadata: [:] + ) + } + + public func updateSettings( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.updateSettings( + request: request.message, + context: context + ), + metadata: [:] + ) + } +} + +// MARK: arcbox.fleet.control.v1.FleetSettingsService (client) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetSettingsService { + /// Generated client protocol for the "arcbox.fleet.control.v1.FleetSettingsService" service. + /// + /// You don't need to implement this protocol directly, use the generated + /// implementation, ``Client``. + /// + /// > Source IDL Documentation: + /// > + /// > Persisted, settable configuration. The desktop holds no state of its + /// > own, so this rides the same Watch stream as everything else above + /// > instead of needing its own notification path. + public protocol ClientProtocol: Sendable { + /// Call the "GetSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetSettingsRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_GetSettingsRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_GetSettingsResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func getSettings( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "UpdateSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_UpdateSettingsResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func updateSettings( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + } + + /// Generated client for the "arcbox.fleet.control.v1.FleetSettingsService" service. + /// + /// The ``Client`` provides an implementation of ``ClientProtocol`` which wraps + /// a `GRPCCore.GRPCCClient`. The underlying `GRPCClient` provides the long-lived + /// means of communication with the remote peer. + /// + /// > Source IDL Documentation: + /// > + /// > Persisted, settable configuration. The desktop holds no state of its + /// > own, so this rides the same Watch stream as everything else above + /// > instead of needing its own notification path. + public struct Client: ClientProtocol where Transport: GRPCCore.ClientTransport { + private let client: GRPCCore.GRPCClient + + /// Creates a new client wrapping the provided `GRPCCore.GRPCClient`. + /// + /// - Parameters: + /// - client: A `GRPCCore.GRPCClient` providing a communication channel to the service. + public init(wrapping client: GRPCCore.GRPCClient) { + self.client = client + } + + /// Call the "GetSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetSettingsRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_GetSettingsRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_GetSettingsResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getSettings( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetSettingsService.Method.GetSettings.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "UpdateSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_UpdateSettingsResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func updateSettings( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetSettingsService.Method.UpdateSettings.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + } +} + +// Helpers providing default arguments to 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetSettingsService.ClientProtocol { + /// Call the "GetSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_GetSettingsRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getSettings( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.getSettings( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "UpdateSettings" method. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_UpdateSettingsRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func updateSettings( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.updateSettings( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } +} + +// Helpers providing sugared APIs for 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetSettingsService.ClientProtocol { + /// Call the "GetSettings" method. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func getSettings( + _ message: Arcbox_Fleet_Control_V1_GetSettingsRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.getSettings( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "UpdateSettings" method. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func updateSettings( + _ message: Arcbox_Fleet_Control_V1_UpdateSettingsRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.updateSettings( + request: request, + options: options, + onResponse: handleResponse + ) + } +} + +// MARK: - arcbox.fleet.control.v1.FleetImageService + +/// Namespace containing generated types for the "arcbox.fleet.control.v1.FleetImageService" service. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +public enum Arcbox_Fleet_Control_V1_FleetImageService { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetImageService" service. + public static let descriptor = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetImageService") + /// Namespace for method metadata. + public enum Method { + /// Namespace for "Prepare" metadata. + public enum Prepare { + /// Request type for "Prepare". + public typealias Input = Arcbox_Fleet_Control_V1_PrepareRequest + /// Response type for "Prepare". + public typealias Output = Arcbox_Fleet_Control_V1_PrepareResponse + /// Descriptor for "Prepare". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetImageService"), + method: "Prepare" + ) + } + /// Descriptors for all methods in the "arcbox.fleet.control.v1.FleetImageService" service. + public static let descriptors: [GRPCCore.MethodDescriptor] = [ + Prepare.descriptor + ] + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension GRPCCore.ServiceDescriptor { + /// Service descriptor for the "arcbox.fleet.control.v1.FleetImageService" service. + public static let arcbox_fleet_control_v1_FleetImageService = GRPCCore.ServiceDescriptor(fullyQualifiedService: "arcbox.fleet.control.v1.FleetImageService") +} + +// MARK: arcbox.fleet.control.v1.FleetImageService (server) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetImageService { + /// Streaming variant of the service protocol for the "arcbox.fleet.control.v1.FleetImageService" service. + /// + /// This protocol is the lowest-level of the service protocols generated for this service + /// giving you the most flexibility over the implementation of your service. This comes at + /// the cost of more verbose and less strict APIs. Each RPC requires you to implement it in + /// terms of a request stream and response stream. Where only a single request or response + /// message is expected, you are responsible for enforcing this invariant is maintained. + /// + /// Where possible, prefer using the stricter, less-verbose ``ServiceProtocol`` + /// or ``SimpleServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Long-running image preparation, split out of FleetSettingsService so + /// > quick state reads/writes never share a service with RPCs that stream a + /// > multi-gigabyte transfer's progress. + public protocol StreamingServiceProtocol: GRPCCore.RegistrableRPCService { + /// Handle the "Prepare" method. + /// + /// > Source IDL Documentation: + /// > + /// > Converge each requested image setting's `current` onto its `target`: + /// > fetch and verify the target artifact through the runtime that owns it + /// > (the Docker socket today; the arcbox-daemon socket once the macOS VM + /// > backend lands), then promote `current` — all-or-nothing per kind. + /// > Cancelling the stream (client disconnect) abandons the promotion; + /// > re-running Prepare resumes from whatever the runtime already cached. + /// + /// - Parameters: + /// - request: A streaming request of `Arcbox_Fleet_Control_V1_PrepareRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_PrepareResponse` messages. + func prepare( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + } + + /// Service protocol for the "arcbox.fleet.control.v1.FleetImageService" service. + /// + /// This protocol is higher level than ``StreamingServiceProtocol`` but lower level than + /// the ``SimpleServiceProtocol``, it provides access to request and response metadata and + /// trailing response metadata. If you don't need these then consider using + /// the ``SimpleServiceProtocol``. If you need fine grained control over your RPCs then + /// use ``StreamingServiceProtocol``. + /// + /// > Source IDL Documentation: + /// > + /// > Long-running image preparation, split out of FleetSettingsService so + /// > quick state reads/writes never share a service with RPCs that stream a + /// > multi-gigabyte transfer's progress. + public protocol ServiceProtocol: Arcbox_Fleet_Control_V1_FleetImageService.StreamingServiceProtocol { + /// Handle the "Prepare" method. + /// + /// > Source IDL Documentation: + /// > + /// > Converge each requested image setting's `current` onto its `target`: + /// > fetch and verify the target artifact through the runtime that owns it + /// > (the Docker socket today; the arcbox-daemon socket once the macOS VM + /// > backend lands), then promote `current` — all-or-nothing per kind. + /// > Cancelling the stream (client disconnect) abandons the promotion; + /// > re-running Prepare resumes from whatever the runtime already cached. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_PrepareRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Arcbox_Fleet_Control_V1_PrepareResponse` messages. + func prepare( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + } + + /// Simple service protocol for the "arcbox.fleet.control.v1.FleetImageService" service. + /// + /// This is the highest level protocol for the service. The API is the easiest to use but + /// doesn't provide access to request or response metadata. If you need access to these + /// then use ``ServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Long-running image preparation, split out of FleetSettingsService so + /// > quick state reads/writes never share a service with RPCs that stream a + /// > multi-gigabyte transfer's progress. + public protocol SimpleServiceProtocol: Arcbox_Fleet_Control_V1_FleetImageService.ServiceProtocol { + /// Handle the "Prepare" method. + /// + /// > Source IDL Documentation: + /// > + /// > Converge each requested image setting's `current` onto its `target`: + /// > fetch and verify the target artifact through the runtime that owns it + /// > (the Docker socket today; the arcbox-daemon socket once the macOS VM + /// > backend lands), then promote `current` — all-or-nothing per kind. + /// > Cancelling the stream (client disconnect) abandons the promotion; + /// > re-running Prepare resumes from whatever the runtime already cached. + /// + /// - Parameters: + /// - request: A `Arcbox_Fleet_Control_V1_PrepareRequest` message. + /// - response: A response stream of `Arcbox_Fleet_Control_V1_PrepareResponse` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + func prepare( + request: Arcbox_Fleet_Control_V1_PrepareRequest, + response: GRPCCore.RPCWriter, + context: GRPCCore.ServerContext + ) async throws + } +} + +// Default implementation of 'registerMethods(with:)'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetImageService.StreamingServiceProtocol { + public func registerMethods(with router: inout GRPCCore.RPCRouter) where Transport: GRPCCore.ServerTransport { + router.registerHandler( + forMethod: Arcbox_Fleet_Control_V1_FleetImageService.Method.Prepare.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.prepare( + request: request, + context: context + ) + } + ) + } +} + +// Default implementation of streaming methods from 'StreamingServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetImageService.ServiceProtocol { + public func prepare( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.prepare( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return response + } +} + +// Default implementation of methods from 'ServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetImageService.SimpleServiceProtocol { + public func prepare( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + return GRPCCore.StreamingServerResponse( + metadata: [:], + producer: { writer in + try await self.prepare( + request: request.message, + response: writer, + context: context + ) + return [:] + } + ) + } +} + +// MARK: arcbox.fleet.control.v1.FleetImageService (client) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetImageService { + /// Generated client protocol for the "arcbox.fleet.control.v1.FleetImageService" service. + /// + /// You don't need to implement this protocol directly, use the generated + /// implementation, ``Client``. + /// + /// > Source IDL Documentation: + /// > + /// > Long-running image preparation, split out of FleetSettingsService so + /// > quick state reads/writes never share a service with RPCs that stream a + /// > multi-gigabyte transfer's progress. + public protocol ClientProtocol: Sendable { + /// Call the "Prepare" method. + /// + /// > Source IDL Documentation: + /// > + /// > Converge each requested image setting's `current` onto its `target`: + /// > fetch and verify the target artifact through the runtime that owns it + /// > (the Docker socket today; the arcbox-daemon socket once the macOS VM + /// > backend lands), then promote `current` — all-or-nothing per kind. + /// > Cancelling the stream (client disconnect) abandons the promotion; + /// > re-running Prepare resumes from whatever the runtime already cached. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_PrepareRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_PrepareRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_PrepareResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func prepare( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + } + + /// Generated client for the "arcbox.fleet.control.v1.FleetImageService" service. + /// + /// The ``Client`` provides an implementation of ``ClientProtocol`` which wraps + /// a `GRPCCore.GRPCCClient`. The underlying `GRPCClient` provides the long-lived + /// means of communication with the remote peer. + /// + /// > Source IDL Documentation: + /// > + /// > Long-running image preparation, split out of FleetSettingsService so + /// > quick state reads/writes never share a service with RPCs that stream a + /// > multi-gigabyte transfer's progress. + public struct Client: ClientProtocol where Transport: GRPCCore.ClientTransport { + private let client: GRPCCore.GRPCClient + + /// Creates a new client wrapping the provided `GRPCCore.GRPCClient`. + /// + /// - Parameters: + /// - client: A `GRPCCore.GRPCClient` providing a communication channel to the service. + public init(wrapping client: GRPCCore.GRPCClient) { + self.client = client + } + + /// Call the "Prepare" method. + /// + /// > Source IDL Documentation: + /// > + /// > Converge each requested image setting's `current` onto its `target`: + /// > fetch and verify the target artifact through the runtime that owns it + /// > (the Docker socket today; the arcbox-daemon socket once the macOS VM + /// > backend lands), then promote `current` — all-or-nothing per kind. + /// > Cancelling the stream (client disconnect) abandons the promotion; + /// > re-running Prepare resumes from whatever the runtime already cached. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_PrepareRequest` message. + /// - serializer: A serializer for `Arcbox_Fleet_Control_V1_PrepareRequest` messages. + /// - deserializer: A deserializer for `Arcbox_Fleet_Control_V1_PrepareResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func prepare( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.client.serverStreaming( + request: request, + descriptor: Arcbox_Fleet_Control_V1_FleetImageService.Method.Prepare.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + } +} + +// Helpers providing default arguments to 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetImageService.ClientProtocol { + /// Call the "Prepare" method. + /// + /// > Source IDL Documentation: + /// > + /// > Converge each requested image setting's `current` onto its `target`: + /// > fetch and verify the target artifact through the runtime that owns it + /// > (the Docker socket today; the arcbox-daemon socket once the macOS VM + /// > backend lands), then promote `current` — all-or-nothing per kind. + /// > Cancelling the stream (client disconnect) abandons the promotion; + /// > re-running Prepare resumes from whatever the runtime already cached. + /// + /// - Parameters: + /// - request: A request containing a single `Arcbox_Fleet_Control_V1_PrepareRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func prepare( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.prepare( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } +} + +// Helpers providing sugared APIs for 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Arcbox_Fleet_Control_V1_FleetImageService.ClientProtocol { + /// Call the "Prepare" method. + /// + /// > Source IDL Documentation: + /// > + /// > Converge each requested image setting's `current` onto its `target`: + /// > fetch and verify the target artifact through the runtime that owns it + /// > (the Docker socket today; the arcbox-daemon socket once the macOS VM + /// > backend lands), then promote `current` — all-or-nothing per kind. + /// > Cancelling the stream (client disconnect) abandons the promotion; + /// > re-running Prepare resumes from whatever the runtime already cached. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func prepare( + _ message: Arcbox_Fleet_Control_V1_PrepareRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.prepare( + request: request, + options: options, + onResponse: handleResponse + ) + } +} \ No newline at end of file diff --git a/Packages/FleetControlClient/Sources/FleetControlClient/Generated/arcbox/fleet/control/v1/control.pb.swift b/Packages/FleetControlClient/Sources/FleetControlClient/Generated/arcbox/fleet/control/v1/control.pb.swift new file mode 100644 index 00000000..6108e237 --- /dev/null +++ b/Packages/FleetControlClient/Sources/FleetControlClient/Generated/arcbox/fleet/control/v1/control.pb.swift @@ -0,0 +1,2212 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: arcbox/fleet/control/v1/control.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate nonisolated struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +/// Lifecycle state of the agent's relationship to the gateway. Distinguishing +/// a live gateway connection from a reconnect-in-progress ("attaching" vs +/// "attached", per RUN-35's live state-watch group) needs a connectivity +/// signal this RPC does not have; that lands with the state-watch API. +public nonisolated enum Arcbox_Fleet_Control_V1_ConnectionState: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unspecified // = 0 + + /// No machine credential is persisted; Enroll is required. + case unenrolled // = 1 + + /// Credential persisted; the agent attaches (and reconnects) in the + /// background. + case enrolled // = 2 + + /// Enrolled but not accepting new offers. + case draining // = 3 + + /// Enrolled but deliberately offline: the participate setting is off. + case detached // = 4 + + /// The gateway rejected the credential (machine decommissioned + /// server-side); parked until an explicit Unenroll. + case credentialRejected // = 5 + case UNRECOGNIZED(Int) + + public init() { + self = .unspecified + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .unenrolled + case 2: self = .enrolled + case 3: self = .draining + case 4: self = .detached + case 5: self = .credentialRejected + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unspecified: return 0 + case .unenrolled: return 1 + case .enrolled: return 2 + case .draining: return 3 + case .detached: return 4 + case .credentialRejected: return 5 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Arcbox_Fleet_Control_V1_ConnectionState] = [ + .unspecified, + .unenrolled, + .enrolled, + .draining, + .detached, + .credentialRejected, + ] + +} + +/// Distinguishes a live gateway connection from a reconnect-in-progress, +/// which GetStatus's coarser ConnectionState cannot (see its comment above). +public nonisolated enum Arcbox_Fleet_Control_V1_Enrollment: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unspecified // = 0 + case unenrolled // = 1 + + /// Enrolled, dialing or reconnecting to the gateway. + case attaching // = 2 + + /// Enrolled, attach stream live. + case attached // = 3 + + /// Enrolled — the credential is kept — but deliberately not attached: + /// the participate setting is off. The machine shows Offline server-side; + /// participate=true reattaches with the same identity. + case detached // = 4 + + /// The gateway rejected the credential (revoked server-side — the machine + /// was decommissioned). The agent stops reconnecting and keeps the + /// credential on disk until an explicit Unenroll, so a server-side auth + /// regression can never make agents wipe their own credentials. + case credentialRejected // = 5 + + /// The gateway pinned a different agent build and pushed its download; the + /// agent is draining in-flight jobs, then swaps its binary and re-execs. + /// Transient: the replacement process reports Attaching → Attached. + case updating // = 6 + case UNRECOGNIZED(Int) + + public init() { + self = .unspecified + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .unenrolled + case 2: self = .attaching + case 3: self = .attached + case 4: self = .detached + case 5: self = .credentialRejected + case 6: self = .updating + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unspecified: return 0 + case .unenrolled: return 1 + case .attaching: return 2 + case .attached: return 3 + case .detached: return 4 + case .credentialRejected: return 5 + case .updating: return 6 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Arcbox_Fleet_Control_V1_Enrollment] = [ + .unspecified, + .unenrolled, + .attaching, + .attached, + .detached, + .credentialRejected, + .updating, + ] + +} + +/// What execution backend serves a capability. Deliberately redefined here +/// rather than imported from arcbox-fleet-proto (the vendored, externally +/// owned gateway contract) — this keeps the two contracts free to evolve +/// independently even though the shapes overlap. +public nonisolated enum Arcbox_Fleet_Control_V1_Backend: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unspecified // = 0 + case hostRunner // = 1 + case docker // = 2 + case vm // = 3 + case UNRECOGNIZED(Int) + + public init() { + self = .unspecified + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .hostRunner + case 2: self = .docker + case 3: self = .vm + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unspecified: return 0 + case .hostRunner: return 1 + case .docker: return 2 + case .vm: return 3 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Arcbox_Fleet_Control_V1_Backend] = [ + .unspecified, + .hostRunner, + .docker, + .vm, + ] + +} + +public nonisolated enum Arcbox_Fleet_Control_V1_DockerMode: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unspecified // = 0 + case auto // = 1 + case enabled // = 2 + case disabled // = 3 + case UNRECOGNIZED(Int) + + public init() { + self = .unspecified + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .auto + case 2: self = .enabled + case 3: self = .disabled + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unspecified: return 0 + case .auto: return 1 + case .enabled: return 2 + case .disabled: return 3 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Arcbox_Fleet_Control_V1_DockerMode] = [ + .unspecified, + .auto, + .enabled, + .disabled, + ] + +} + +/// Whether darwin jobs run in disposable macOS VMs provisioned through the +/// local arcbox-daemon (isolation), instead of the pre-installed host +/// runner. Restart-scoped, like DockerMode. +public nonisolated enum Arcbox_Fleet_Control_V1_VmMode: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unspecified // = 0 + + /// Probe the daemon at startup; fall back to the host runner if the + /// daemon is unreachable or the macOS runner image is not installed. + case auto // = 1 + + /// Require the daemon; fail startup if it cannot serve VMs. + case enabled // = 2 + + /// Never run darwin jobs in VMs, even if the daemon could. + case disabled // = 3 + case UNRECOGNIZED(Int) + + public init() { + self = .unspecified + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .auto + case 2: self = .enabled + case 3: self = .disabled + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unspecified: return 0 + case .auto: return 1 + case .enabled: return 2 + case .disabled: return 3 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Arcbox_Fleet_Control_V1_VmMode] = [ + .unspecified, + .auto, + .enabled, + .disabled, + ] + +} + +/// Which image setting a Prepare call (or event) concerns. One value per +/// preparable image setting. +public nonisolated enum Arcbox_Fleet_Control_V1_ImageKind: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unspecified // = 0 + + /// `linux_runner_image`, prepared by pulling it for every + /// currently-advertised Docker-served Linux arch. + case linuxRunnerImage // = 1 + + /// `macos_runner_image`, prepared by pulling it through the local + /// arcbox-daemon's MacosService.ImagePull. + case macosRunnerImage // = 2 + case UNRECOGNIZED(Int) + + public init() { + self = .unspecified + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .linuxRunnerImage + case 2: self = .macosRunnerImage + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unspecified: return 0 + case .linuxRunnerImage: return 1 + case .macosRunnerImage: return 2 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Arcbox_Fleet_Control_V1_ImageKind] = [ + .unspecified, + .linuxRunnerImage, + .macosRunnerImage, + ] + +} + +public nonisolated struct Arcbox_Fleet_Control_V1_GetAgentInfoRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_GetAgentInfoResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// `CARGO_PKG_VERSION` of the running agent. + public var agentVersion: String = String() + + /// Bumped only on breaking control-API changes. + public var apiVersion: UInt32 = 0 + + /// Forward-compatible capability flags a client can probe for instead of + /// gating behavior on `api_version` alone. + public var features: [String] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_EnrollRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var enrollmentToken: String = String() + + /// Gateway URL override for this enrollment; empty uses the agent's + /// configured default (ARCBOX_FLEET_GATEWAY / DEFAULT_GATEWAY). + public var controlPlane: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_EnrollResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Prefixed machine id (`fltm_...`). + public var machineID: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_DrainRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_DrainResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_ResumeRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_ResumeResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_UnenrollRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_UnenrollResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_GetStatusRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_GetStatusResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var state: Arcbox_Fleet_Control_V1_ConnectionState = .unspecified + + /// Empty when unenrolled. + public var machineID: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_WatchRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_WatchResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var snapshot: Arcbox_Fleet_Control_V1_AgentStateSnapshot { + get {_snapshot ?? Arcbox_Fleet_Control_V1_AgentStateSnapshot()} + set {_snapshot = newValue} + } + /// Returns true if `snapshot` has been explicitly set. + public var hasSnapshot: Bool {self._snapshot != nil} + /// Clears the value of `snapshot`. Subsequent reads from it will return its default value. + public mutating func clearSnapshot() {self._snapshot = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _snapshot: Arcbox_Fleet_Control_V1_AgentStateSnapshot? = nil +} + +public nonisolated struct Arcbox_Fleet_Control_V1_Capability: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var os: String = String() + + public var arch: String = String() + + public var backedBy: Arcbox_Fleet_Control_V1_Backend = .unspecified + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_InFlightJob: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var jobID: String = String() + + public var os: String = String() + + public var arch: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_OfferVerdict: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var jobID: String = String() + + public var accepted: Bool = false + + /// Empty when accepted. + public var reason: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_HostTelemetry: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var loadAvg1M: Double = 0 + + public var cpuCount: UInt32 = 0 + + public var memTotalMib: UInt64 = 0 + + public var memAvailableMib: UInt64 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_AgentStateSnapshot: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var enrollment: Arcbox_Fleet_Control_V1_Enrollment = .unspecified + + /// Empty when unenrolled. + public var machineID: String = String() + + public var draining: Bool = false + + public var capabilities: [Arcbox_Fleet_Control_V1_Capability] = [] + + public var inFlight: [Arcbox_Fleet_Control_V1_InFlightJob] = [] + + /// Bounded ring buffer, most recent last (see AgentState::push_verdict). + public var recentVerdicts: [Arcbox_Fleet_Control_V1_OfferVerdict] = [] + + /// Absent until the first heartbeat tick after attaching. Singular message + /// fields are presence-tracked by default in proto3 (no `optional` needed). + public var telemetry: Arcbox_Fleet_Control_V1_HostTelemetry { + get {_telemetry ?? Arcbox_Fleet_Control_V1_HostTelemetry()} + set {_telemetry = newValue} + } + /// Returns true if `telemetry` has been explicitly set. + public var hasTelemetry: Bool {self._telemetry != nil} + /// Clears the value of `telemetry`. Subsequent reads from it will return its default value. + public mutating func clearTelemetry() {self._telemetry = nil} + + public var settings: Arcbox_Fleet_Control_V1_AgentSettings { + get {_settings ?? Arcbox_Fleet_Control_V1_AgentSettings()} + set {_settings = newValue} + } + /// Returns true if `settings` has been explicitly set. + public var hasSettings: Bool {self._settings != nil} + /// Clears the value of `settings`. Subsequent reads from it will return its default value. + public mutating func clearSettings() {self._settings = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _telemetry: Arcbox_Fleet_Control_V1_HostTelemetry? = nil + fileprivate var _settings: Arcbox_Fleet_Control_V1_AgentSettings? = nil +} + +public nonisolated struct Arcbox_Fleet_Control_V1_GetSettingsRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_GetSettingsResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var settings: Arcbox_Fleet_Control_V1_AgentSettings { + get {_settings ?? Arcbox_Fleet_Control_V1_AgentSettings()} + set {_settings = newValue} + } + /// Returns true if `settings` has been explicitly set. + public var hasSettings: Bool {self._settings != nil} + /// Clears the value of `settings`. Subsequent reads from it will return its default value. + public mutating func clearSettings() {self._settings = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _settings: Arcbox_Fleet_Control_V1_AgentSettings? = nil +} + +public nonisolated struct Arcbox_Fleet_Control_V1_UpdateSettingsResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var settings: Arcbox_Fleet_Control_V1_AgentSettings { + get {_settings ?? Arcbox_Fleet_Control_V1_AgentSettings()} + set {_settings = newValue} + } + /// Returns true if `settings` has been explicitly set. + public var hasSettings: Bool {self._settings != nil} + /// Clears the value of `settings`. Subsequent reads from it will return its default value. + public mutating func clearSettings() {self._settings = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _settings: Arcbox_Fleet_Control_V1_AgentSettings? = nil +} + +/// A client only ever writes `target`; `current` is engine-observed and +/// only changes because the engine itself reports a new value. Rejected +/// with INVALID_ARGUMENT as a whole (no partial apply) if the resulting +/// docker_mode/runner_script combination would leave nothing servable — +/// see FleetSettingsService.UpdateSettings's implementation. +public nonisolated struct Arcbox_Fleet_Control_V1_UpdateSettingsRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var loadCeiling: Double { + get {_loadCeiling ?? 0} + set {_loadCeiling = newValue} + } + /// Returns true if `loadCeiling` has been explicitly set. + public var hasLoadCeiling: Bool {self._loadCeiling != nil} + /// Clears the value of `loadCeiling`. Subsequent reads from it will return its default value. + public mutating func clearLoadCeiling() {self._loadCeiling = nil} + + public var memFloorMib: UInt64 { + get {_memFloorMib ?? 0} + set {_memFloorMib = newValue} + } + /// Returns true if `memFloorMib` has been explicitly set. + public var hasMemFloorMib: Bool {self._memFloorMib != nil} + /// Clears the value of `memFloorMib`. Subsequent reads from it will return its default value. + public mutating func clearMemFloorMib() {self._memFloorMib = nil} + + /// Container image Linux jobs run in ("linux" names the jobs' platform; + /// macOS jobs get their own image setting alongside the VM backend). + /// Writes `target` only — the image becomes `current` when + /// FleetImageService.Prepare verifies it, never as a side effect of this + /// call. + public var linuxRunnerImage: String { + get {_linuxRunnerImage ?? String()} + set {_linuxRunnerImage = newValue} + } + /// Returns true if `linuxRunnerImage` has been explicitly set. + public var hasLinuxRunnerImage: Bool {self._linuxRunnerImage != nil} + /// Clears the value of `linuxRunnerImage`. Subsequent reads from it will return its default value. + public mutating func clearLinuxRunnerImage() {self._linuxRunnerImage = nil} + + public var gateway: String { + get {_gateway ?? String()} + set {_gateway = newValue} + } + /// Returns true if `gateway` has been explicitly set. + public var hasGateway: Bool {self._gateway != nil} + /// Clears the value of `gateway`. Subsequent reads from it will return its default value. + public mutating func clearGateway() {self._gateway = nil} + + public var dockerMode: Arcbox_Fleet_Control_V1_DockerMode { + get {_dockerMode ?? .unspecified} + set {_dockerMode = newValue} + } + /// Returns true if `dockerMode` has been explicitly set. + public var hasDockerMode: Bool {self._dockerMode != nil} + /// Clears the value of `dockerMode`. Subsequent reads from it will return its default value. + public mutating func clearDockerMode() {self._dockerMode = nil} + + /// Direct path to the runner entry point (run.sh), not its containing + /// directory. + public var runnerScript: String { + get {_runnerScript ?? String()} + set {_runnerScript = newValue} + } + /// Returns true if `runnerScript` has been explicitly set. + public var hasRunnerScript: Bool {self._runnerScript != nil} + /// Clears the value of `runnerScript`. Subsequent reads from it will return its default value. + public mutating func clearRunnerScript() {self._runnerScript = nil} + + /// Whether this machine takes part in the fleet. false detaches — the + /// credential is kept and the machine shows Offline server-side — and + /// true reattaches with the same identity. Writes `target` only; the + /// supervisor reconciles, and `current` flips when the attach/detach + /// actually completes. This is deliberately the one lifecycle-touching + /// setting (see FleetSettingsService's doc). + public var participate: Bool { + get {_participate ?? false} + set {_participate = newValue} + } + /// Returns true if `participate` has been explicitly set. + public var hasParticipate: Bool {self._participate != nil} + /// Clears the value of `participate`. Subsequent reads from it will return its default value. + public mutating func clearParticipate() {self._participate = nil} + + /// macOS base-image stream for darwin VM jobs. Writes `target` only — + /// FleetImageService.Prepare pulls it through the daemon and promotes. + public var macosRunnerImage: String { + get {_macosRunnerImage ?? String()} + set {_macosRunnerImage = newValue} + } + /// Returns true if `macosRunnerImage` has been explicitly set. + public var hasMacosRunnerImage: Bool {self._macosRunnerImage != nil} + /// Clears the value of `macosRunnerImage`. Subsequent reads from it will return its default value. + public mutating func clearMacosRunnerImage() {self._macosRunnerImage = nil} + + public var vmMode: Arcbox_Fleet_Control_V1_VmMode { + get {_vmMode ?? .unspecified} + set {_vmMode = newValue} + } + /// Returns true if `vmMode` has been explicitly set. + public var hasVmMode: Bool {self._vmMode != nil} + /// Clears the value of `vmMode`. Subsequent reads from it will return its default value. + public mutating func clearVmMode() {self._vmMode = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _loadCeiling: Double? = nil + fileprivate var _memFloorMib: UInt64? = nil + fileprivate var _linuxRunnerImage: String? = nil + fileprivate var _gateway: String? = nil + fileprivate var _dockerMode: Arcbox_Fleet_Control_V1_DockerMode? = nil + fileprivate var _runnerScript: String? = nil + fileprivate var _participate: Bool? = nil + fileprivate var _macosRunnerImage: String? = nil + fileprivate var _vmMode: Arcbox_Fleet_Control_V1_VmMode? = nil +} + +/// Reused across every setting regardless of type, so a client's rendering +/// logic ("show current; if current != target, show pending") never needs +/// to special-case a field. +public nonisolated struct Arcbox_Fleet_Control_V1_DoubleSetting: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var current: Double = 0 + + public var target: Double = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_Uint64Setting: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var current: UInt64 = 0 + + public var target: UInt64 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_StringSetting: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var current: String = String() + + public var target: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_DockerModeSetting: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var current: Arcbox_Fleet_Control_V1_DockerMode = .unspecified + + public var target: Arcbox_Fleet_Control_V1_DockerMode = .unspecified + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_VmModeSetting: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var current: Arcbox_Fleet_Control_V1_VmMode = .unspecified + + public var target: Arcbox_Fleet_Control_V1_VmMode = .unspecified + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_BoolSetting: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var current: Bool = false + + public var target: Bool = false + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Arcbox_Fleet_Control_V1_AgentSettings: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var loadCeiling: Arcbox_Fleet_Control_V1_DoubleSetting { + get {_storage._loadCeiling ?? Arcbox_Fleet_Control_V1_DoubleSetting()} + set {_uniqueStorage()._loadCeiling = newValue} + } + /// Returns true if `loadCeiling` has been explicitly set. + public var hasLoadCeiling: Bool {_storage._loadCeiling != nil} + /// Clears the value of `loadCeiling`. Subsequent reads from it will return its default value. + public mutating func clearLoadCeiling() {_uniqueStorage()._loadCeiling = nil} + + public var memFloorMib: Arcbox_Fleet_Control_V1_Uint64Setting { + get {_storage._memFloorMib ?? Arcbox_Fleet_Control_V1_Uint64Setting()} + set {_uniqueStorage()._memFloorMib = newValue} + } + /// Returns true if `memFloorMib` has been explicitly set. + public var hasMemFloorMib: Bool {_storage._memFloorMib != nil} + /// Clears the value of `memFloorMib`. Subsequent reads from it will return its default value. + public mutating func clearMemFloorMib() {_uniqueStorage()._memFloorMib = nil} + + /// For image settings, `target` is operator intent and may float (a + /// moving tag or an unpinned stream); `current` is the verified artifact + /// jobs actually use. FleetImageService.Prepare is what converges the + /// two — including re-resolving a floating target that moved. + public var linuxRunnerImage: Arcbox_Fleet_Control_V1_StringSetting { + get {_storage._linuxRunnerImage ?? Arcbox_Fleet_Control_V1_StringSetting()} + set {_uniqueStorage()._linuxRunnerImage = newValue} + } + /// Returns true if `linuxRunnerImage` has been explicitly set. + public var hasLinuxRunnerImage: Bool {_storage._linuxRunnerImage != nil} + /// Clears the value of `linuxRunnerImage`. Subsequent reads from it will return its default value. + public mutating func clearLinuxRunnerImage() {_uniqueStorage()._linuxRunnerImage = nil} + + public var gateway: Arcbox_Fleet_Control_V1_StringSetting { + get {_storage._gateway ?? Arcbox_Fleet_Control_V1_StringSetting()} + set {_uniqueStorage()._gateway = newValue} + } + /// Returns true if `gateway` has been explicitly set. + public var hasGateway: Bool {_storage._gateway != nil} + /// Clears the value of `gateway`. Subsequent reads from it will return its default value. + public mutating func clearGateway() {_uniqueStorage()._gateway = nil} + + public var dockerMode: Arcbox_Fleet_Control_V1_DockerModeSetting { + get {_storage._dockerMode ?? Arcbox_Fleet_Control_V1_DockerModeSetting()} + set {_uniqueStorage()._dockerMode = newValue} + } + /// Returns true if `dockerMode` has been explicitly set. + public var hasDockerMode: Bool {_storage._dockerMode != nil} + /// Clears the value of `dockerMode`. Subsequent reads from it will return its default value. + public mutating func clearDockerMode() {_uniqueStorage()._dockerMode = nil} + + public var runnerScript: Arcbox_Fleet_Control_V1_StringSetting { + get {_storage._runnerScript ?? Arcbox_Fleet_Control_V1_StringSetting()} + set {_uniqueStorage()._runnerScript = newValue} + } + /// Returns true if `runnerScript` has been explicitly set. + public var hasRunnerScript: Bool {_storage._runnerScript != nil} + /// Clears the value of `runnerScript`. Subsequent reads from it will return its default value. + public mutating func clearRunnerScript() {_uniqueStorage()._runnerScript = nil} + + public var participate: Arcbox_Fleet_Control_V1_BoolSetting { + get {_storage._participate ?? Arcbox_Fleet_Control_V1_BoolSetting()} + set {_uniqueStorage()._participate = newValue} + } + /// Returns true if `participate` has been explicitly set. + public var hasParticipate: Bool {_storage._participate != nil} + /// Clears the value of `participate`. Subsequent reads from it will return its default value. + public mutating func clearParticipate() {_uniqueStorage()._participate = nil} + + /// macOS base-image stream darwin VM jobs boot from (e.g. "tahoe-base", + /// optionally pinned: "tahoe-base@2026.07.02"). Same target/current + /// semantics as linux_runner_image; prepared through the daemon. + public var macosRunnerImage: Arcbox_Fleet_Control_V1_StringSetting { + get {_storage._macosRunnerImage ?? Arcbox_Fleet_Control_V1_StringSetting()} + set {_uniqueStorage()._macosRunnerImage = newValue} + } + /// Returns true if `macosRunnerImage` has been explicitly set. + public var hasMacosRunnerImage: Bool {_storage._macosRunnerImage != nil} + /// Clears the value of `macosRunnerImage`. Subsequent reads from it will return its default value. + public mutating func clearMacosRunnerImage() {_uniqueStorage()._macosRunnerImage = nil} + + public var vmMode: Arcbox_Fleet_Control_V1_VmModeSetting { + get {_storage._vmMode ?? Arcbox_Fleet_Control_V1_VmModeSetting()} + set {_uniqueStorage()._vmMode = newValue} + } + /// Returns true if `vmMode` has been explicitly set. + public var hasVmMode: Bool {_storage._vmMode != nil} + /// Clears the value of `vmMode`. Subsequent reads from it will return its default value. + public mutating func clearVmMode() {_uniqueStorage()._vmMode = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _storage = _StorageClass.defaultInstance +} + +public nonisolated struct Arcbox_Fleet_Control_V1_PrepareRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Image settings to converge; empty prepares every kind this agent + /// supports. An unrecognized kind (from a newer client) is rejected with + /// INVALID_ARGUMENT rather than silently skipped. + public var kinds: [Arcbox_Fleet_Control_V1_ImageKind] = [] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// Progress for one preparation step. Human-oriented: `detail`/`stage` are +/// display strings ("linux/arm64", "pulling"), not a machine contract. +public nonisolated struct Arcbox_Fleet_Control_V1_PrepareResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var kind: Arcbox_Fleet_Control_V1_ImageKind = .unspecified + + /// What within the kind is progressing — a platform for Docker pulls. + public var detail: String = String() + + /// Coarse stage label; "promoted" is always the kind's final event. + public var stage: String = String() + + /// Completion fraction within the stage (0.0..=1.0). + public var fraction: Double = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate nonisolated let _protobuf_package = "arcbox.fleet.control.v1" + +nonisolated extension Arcbox_Fleet_Control_V1_ConnectionState: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0CONNECTION_STATE_UNSPECIFIED\0\u{1}CONNECTION_STATE_UNENROLLED\0\u{1}CONNECTION_STATE_ENROLLED\0\u{1}CONNECTION_STATE_DRAINING\0\u{1}CONNECTION_STATE_DETACHED\0\u{1}CONNECTION_STATE_CREDENTIAL_REJECTED\0") +} + +nonisolated extension Arcbox_Fleet_Control_V1_Enrollment: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0ENROLLMENT_UNSPECIFIED\0\u{1}ENROLLMENT_UNENROLLED\0\u{1}ENROLLMENT_ATTACHING\0\u{1}ENROLLMENT_ATTACHED\0\u{1}ENROLLMENT_DETACHED\0\u{1}ENROLLMENT_CREDENTIAL_REJECTED\0\u{1}ENROLLMENT_UPDATING\0") +} + +nonisolated extension Arcbox_Fleet_Control_V1_Backend: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BACKEND_UNSPECIFIED\0\u{1}BACKEND_HOST_RUNNER\0\u{1}BACKEND_DOCKER\0\u{1}BACKEND_VM\0") +} + +nonisolated extension Arcbox_Fleet_Control_V1_DockerMode: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0DOCKER_MODE_UNSPECIFIED\0\u{1}DOCKER_MODE_AUTO\0\u{1}DOCKER_MODE_ENABLED\0\u{1}DOCKER_MODE_DISABLED\0") +} + +nonisolated extension Arcbox_Fleet_Control_V1_VmMode: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0VM_MODE_UNSPECIFIED\0\u{1}VM_MODE_AUTO\0\u{1}VM_MODE_ENABLED\0\u{1}VM_MODE_DISABLED\0") +} + +nonisolated extension Arcbox_Fleet_Control_V1_ImageKind: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0IMAGE_KIND_UNSPECIFIED\0\u{1}IMAGE_KIND_LINUX_RUNNER_IMAGE\0\u{1}IMAGE_KIND_MACOS_RUNNER_IMAGE\0") +} + +nonisolated extension Arcbox_Fleet_Control_V1_GetAgentInfoRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".GetAgentInfoRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_GetAgentInfoRequest, rhs: Arcbox_Fleet_Control_V1_GetAgentInfoRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_GetAgentInfoResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".GetAgentInfoResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}agent_version\0\u{3}api_version\0\u{1}features\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.agentVersion) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self.apiVersion) }() + case 3: try { try decoder.decodeRepeatedStringField(value: &self.features) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.agentVersion.isEmpty { + try visitor.visitSingularStringField(value: self.agentVersion, fieldNumber: 1) + } + if self.apiVersion != 0 { + try visitor.visitSingularUInt32Field(value: self.apiVersion, fieldNumber: 2) + } + if !self.features.isEmpty { + try visitor.visitRepeatedStringField(value: self.features, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_GetAgentInfoResponse, rhs: Arcbox_Fleet_Control_V1_GetAgentInfoResponse) -> Bool { + if lhs.agentVersion != rhs.agentVersion {return false} + if lhs.apiVersion != rhs.apiVersion {return false} + if lhs.features != rhs.features {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_EnrollRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".EnrollRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}enrollment_token\0\u{3}control_plane\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.enrollmentToken) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.controlPlane) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.enrollmentToken.isEmpty { + try visitor.visitSingularStringField(value: self.enrollmentToken, fieldNumber: 1) + } + if !self.controlPlane.isEmpty { + try visitor.visitSingularStringField(value: self.controlPlane, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_EnrollRequest, rhs: Arcbox_Fleet_Control_V1_EnrollRequest) -> Bool { + if lhs.enrollmentToken != rhs.enrollmentToken {return false} + if lhs.controlPlane != rhs.controlPlane {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_EnrollResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".EnrollResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}machine_id\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.machineID) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.machineID.isEmpty { + try visitor.visitSingularStringField(value: self.machineID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_EnrollResponse, rhs: Arcbox_Fleet_Control_V1_EnrollResponse) -> Bool { + if lhs.machineID != rhs.machineID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_DrainRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".DrainRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_DrainRequest, rhs: Arcbox_Fleet_Control_V1_DrainRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_DrainResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".DrainResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_DrainResponse, rhs: Arcbox_Fleet_Control_V1_DrainResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_ResumeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ResumeRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_ResumeRequest, rhs: Arcbox_Fleet_Control_V1_ResumeRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_ResumeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ResumeResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_ResumeResponse, rhs: Arcbox_Fleet_Control_V1_ResumeResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_UnenrollRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".UnenrollRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_UnenrollRequest, rhs: Arcbox_Fleet_Control_V1_UnenrollRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_UnenrollResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".UnenrollResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_UnenrollResponse, rhs: Arcbox_Fleet_Control_V1_UnenrollResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_GetStatusRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".GetStatusRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_GetStatusRequest, rhs: Arcbox_Fleet_Control_V1_GetStatusRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_GetStatusResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".GetStatusResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}state\0\u{3}machine_id\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.state) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.machineID) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.state != .unspecified { + try visitor.visitSingularEnumField(value: self.state, fieldNumber: 1) + } + if !self.machineID.isEmpty { + try visitor.visitSingularStringField(value: self.machineID, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_GetStatusResponse, rhs: Arcbox_Fleet_Control_V1_GetStatusResponse) -> Bool { + if lhs.state != rhs.state {return false} + if lhs.machineID != rhs.machineID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_WatchRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WatchRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_WatchRequest, rhs: Arcbox_Fleet_Control_V1_WatchRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_WatchResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WatchResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}snapshot\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._snapshot) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._snapshot { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_WatchResponse, rhs: Arcbox_Fleet_Control_V1_WatchResponse) -> Bool { + if lhs._snapshot != rhs._snapshot {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_Capability: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Capability" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}os\0\u{1}arch\0\u{3}backed_by\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.os) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.arch) }() + case 3: try { try decoder.decodeSingularEnumField(value: &self.backedBy) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.os.isEmpty { + try visitor.visitSingularStringField(value: self.os, fieldNumber: 1) + } + if !self.arch.isEmpty { + try visitor.visitSingularStringField(value: self.arch, fieldNumber: 2) + } + if self.backedBy != .unspecified { + try visitor.visitSingularEnumField(value: self.backedBy, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_Capability, rhs: Arcbox_Fleet_Control_V1_Capability) -> Bool { + if lhs.os != rhs.os {return false} + if lhs.arch != rhs.arch {return false} + if lhs.backedBy != rhs.backedBy {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_InFlightJob: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".InFlightJob" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}job_id\0\u{1}os\0\u{1}arch\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.jobID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.os) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.arch) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.jobID.isEmpty { + try visitor.visitSingularStringField(value: self.jobID, fieldNumber: 1) + } + if !self.os.isEmpty { + try visitor.visitSingularStringField(value: self.os, fieldNumber: 2) + } + if !self.arch.isEmpty { + try visitor.visitSingularStringField(value: self.arch, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_InFlightJob, rhs: Arcbox_Fleet_Control_V1_InFlightJob) -> Bool { + if lhs.jobID != rhs.jobID {return false} + if lhs.os != rhs.os {return false} + if lhs.arch != rhs.arch {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_OfferVerdict: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".OfferVerdict" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}job_id\0\u{1}accepted\0\u{1}reason\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.jobID) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.accepted) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.reason) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.jobID.isEmpty { + try visitor.visitSingularStringField(value: self.jobID, fieldNumber: 1) + } + if self.accepted != false { + try visitor.visitSingularBoolField(value: self.accepted, fieldNumber: 2) + } + if !self.reason.isEmpty { + try visitor.visitSingularStringField(value: self.reason, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_OfferVerdict, rhs: Arcbox_Fleet_Control_V1_OfferVerdict) -> Bool { + if lhs.jobID != rhs.jobID {return false} + if lhs.accepted != rhs.accepted {return false} + if lhs.reason != rhs.reason {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_HostTelemetry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".HostTelemetry" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}load_avg_1m\0\u{3}cpu_count\0\u{3}mem_total_mib\0\u{3}mem_available_mib\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularDoubleField(value: &self.loadAvg1M) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self.cpuCount) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self.memTotalMib) }() + case 4: try { try decoder.decodeSingularUInt64Field(value: &self.memAvailableMib) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.loadAvg1M.bitPattern != 0 { + try visitor.visitSingularDoubleField(value: self.loadAvg1M, fieldNumber: 1) + } + if self.cpuCount != 0 { + try visitor.visitSingularUInt32Field(value: self.cpuCount, fieldNumber: 2) + } + if self.memTotalMib != 0 { + try visitor.visitSingularUInt64Field(value: self.memTotalMib, fieldNumber: 3) + } + if self.memAvailableMib != 0 { + try visitor.visitSingularUInt64Field(value: self.memAvailableMib, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_HostTelemetry, rhs: Arcbox_Fleet_Control_V1_HostTelemetry) -> Bool { + if lhs.loadAvg1M != rhs.loadAvg1M {return false} + if lhs.cpuCount != rhs.cpuCount {return false} + if lhs.memTotalMib != rhs.memTotalMib {return false} + if lhs.memAvailableMib != rhs.memAvailableMib {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_AgentStateSnapshot: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".AgentStateSnapshot" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}enrollment\0\u{3}machine_id\0\u{1}draining\0\u{1}capabilities\0\u{3}in_flight\0\u{3}recent_verdicts\0\u{1}telemetry\0\u{1}settings\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.enrollment) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.machineID) }() + case 3: try { try decoder.decodeSingularBoolField(value: &self.draining) }() + case 4: try { try decoder.decodeRepeatedMessageField(value: &self.capabilities) }() + case 5: try { try decoder.decodeRepeatedMessageField(value: &self.inFlight) }() + case 6: try { try decoder.decodeRepeatedMessageField(value: &self.recentVerdicts) }() + case 7: try { try decoder.decodeSingularMessageField(value: &self._telemetry) }() + case 8: try { try decoder.decodeSingularMessageField(value: &self._settings) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.enrollment != .unspecified { + try visitor.visitSingularEnumField(value: self.enrollment, fieldNumber: 1) + } + if !self.machineID.isEmpty { + try visitor.visitSingularStringField(value: self.machineID, fieldNumber: 2) + } + if self.draining != false { + try visitor.visitSingularBoolField(value: self.draining, fieldNumber: 3) + } + if !self.capabilities.isEmpty { + try visitor.visitRepeatedMessageField(value: self.capabilities, fieldNumber: 4) + } + if !self.inFlight.isEmpty { + try visitor.visitRepeatedMessageField(value: self.inFlight, fieldNumber: 5) + } + if !self.recentVerdicts.isEmpty { + try visitor.visitRepeatedMessageField(value: self.recentVerdicts, fieldNumber: 6) + } + try { if let v = self._telemetry { + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + } }() + try { if let v = self._settings { + try visitor.visitSingularMessageField(value: v, fieldNumber: 8) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_AgentStateSnapshot, rhs: Arcbox_Fleet_Control_V1_AgentStateSnapshot) -> Bool { + if lhs.enrollment != rhs.enrollment {return false} + if lhs.machineID != rhs.machineID {return false} + if lhs.draining != rhs.draining {return false} + if lhs.capabilities != rhs.capabilities {return false} + if lhs.inFlight != rhs.inFlight {return false} + if lhs.recentVerdicts != rhs.recentVerdicts {return false} + if lhs._telemetry != rhs._telemetry {return false} + if lhs._settings != rhs._settings {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_GetSettingsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".GetSettingsRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_GetSettingsRequest, rhs: Arcbox_Fleet_Control_V1_GetSettingsRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_GetSettingsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".GetSettingsResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}settings\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._settings) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._settings { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_GetSettingsResponse, rhs: Arcbox_Fleet_Control_V1_GetSettingsResponse) -> Bool { + if lhs._settings != rhs._settings {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_UpdateSettingsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".UpdateSettingsResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}settings\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._settings) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._settings { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_UpdateSettingsResponse, rhs: Arcbox_Fleet_Control_V1_UpdateSettingsResponse) -> Bool { + if lhs._settings != rhs._settings {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_UpdateSettingsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".UpdateSettingsRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}load_ceiling\0\u{3}mem_floor_mib\0\u{3}linux_runner_image\0\u{1}gateway\0\u{3}docker_mode\0\u{3}runner_script\0\u{1}participate\0\u{3}macos_runner_image\0\u{3}vm_mode\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularDoubleField(value: &self._loadCeiling) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self._memFloorMib) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._linuxRunnerImage) }() + case 4: try { try decoder.decodeSingularStringField(value: &self._gateway) }() + case 5: try { try decoder.decodeSingularEnumField(value: &self._dockerMode) }() + case 6: try { try decoder.decodeSingularStringField(value: &self._runnerScript) }() + case 7: try { try decoder.decodeSingularBoolField(value: &self._participate) }() + case 8: try { try decoder.decodeSingularStringField(value: &self._macosRunnerImage) }() + case 9: try { try decoder.decodeSingularEnumField(value: &self._vmMode) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._loadCeiling { + try visitor.visitSingularDoubleField(value: v, fieldNumber: 1) + } }() + try { if let v = self._memFloorMib { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 2) + } }() + try { if let v = self._linuxRunnerImage { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() + try { if let v = self._gateway { + try visitor.visitSingularStringField(value: v, fieldNumber: 4) + } }() + try { if let v = self._dockerMode { + try visitor.visitSingularEnumField(value: v, fieldNumber: 5) + } }() + try { if let v = self._runnerScript { + try visitor.visitSingularStringField(value: v, fieldNumber: 6) + } }() + try { if let v = self._participate { + try visitor.visitSingularBoolField(value: v, fieldNumber: 7) + } }() + try { if let v = self._macosRunnerImage { + try visitor.visitSingularStringField(value: v, fieldNumber: 8) + } }() + try { if let v = self._vmMode { + try visitor.visitSingularEnumField(value: v, fieldNumber: 9) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_UpdateSettingsRequest, rhs: Arcbox_Fleet_Control_V1_UpdateSettingsRequest) -> Bool { + if lhs._loadCeiling != rhs._loadCeiling {return false} + if lhs._memFloorMib != rhs._memFloorMib {return false} + if lhs._linuxRunnerImage != rhs._linuxRunnerImage {return false} + if lhs._gateway != rhs._gateway {return false} + if lhs._dockerMode != rhs._dockerMode {return false} + if lhs._runnerScript != rhs._runnerScript {return false} + if lhs._participate != rhs._participate {return false} + if lhs._macosRunnerImage != rhs._macosRunnerImage {return false} + if lhs._vmMode != rhs._vmMode {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_DoubleSetting: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".DoubleSetting" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}current\0\u{1}target\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularDoubleField(value: &self.current) }() + case 2: try { try decoder.decodeSingularDoubleField(value: &self.target) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.current.bitPattern != 0 { + try visitor.visitSingularDoubleField(value: self.current, fieldNumber: 1) + } + if self.target.bitPattern != 0 { + try visitor.visitSingularDoubleField(value: self.target, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_DoubleSetting, rhs: Arcbox_Fleet_Control_V1_DoubleSetting) -> Bool { + if lhs.current != rhs.current {return false} + if lhs.target != rhs.target {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_Uint64Setting: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Uint64Setting" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}current\0\u{1}target\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt64Field(value: &self.current) }() + case 2: try { try decoder.decodeSingularUInt64Field(value: &self.target) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.current != 0 { + try visitor.visitSingularUInt64Field(value: self.current, fieldNumber: 1) + } + if self.target != 0 { + try visitor.visitSingularUInt64Field(value: self.target, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_Uint64Setting, rhs: Arcbox_Fleet_Control_V1_Uint64Setting) -> Bool { + if lhs.current != rhs.current {return false} + if lhs.target != rhs.target {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_StringSetting: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".StringSetting" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}current\0\u{1}target\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.current) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.target) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.current.isEmpty { + try visitor.visitSingularStringField(value: self.current, fieldNumber: 1) + } + if !self.target.isEmpty { + try visitor.visitSingularStringField(value: self.target, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_StringSetting, rhs: Arcbox_Fleet_Control_V1_StringSetting) -> Bool { + if lhs.current != rhs.current {return false} + if lhs.target != rhs.target {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_DockerModeSetting: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".DockerModeSetting" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}current\0\u{1}target\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.current) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.target) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.current != .unspecified { + try visitor.visitSingularEnumField(value: self.current, fieldNumber: 1) + } + if self.target != .unspecified { + try visitor.visitSingularEnumField(value: self.target, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_DockerModeSetting, rhs: Arcbox_Fleet_Control_V1_DockerModeSetting) -> Bool { + if lhs.current != rhs.current {return false} + if lhs.target != rhs.target {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_VmModeSetting: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".VmModeSetting" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}current\0\u{1}target\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.current) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.target) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.current != .unspecified { + try visitor.visitSingularEnumField(value: self.current, fieldNumber: 1) + } + if self.target != .unspecified { + try visitor.visitSingularEnumField(value: self.target, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_VmModeSetting, rhs: Arcbox_Fleet_Control_V1_VmModeSetting) -> Bool { + if lhs.current != rhs.current {return false} + if lhs.target != rhs.target {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_BoolSetting: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".BoolSetting" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}current\0\u{1}target\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBoolField(value: &self.current) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.target) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.current != false { + try visitor.visitSingularBoolField(value: self.current, fieldNumber: 1) + } + if self.target != false { + try visitor.visitSingularBoolField(value: self.target, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_BoolSetting, rhs: Arcbox_Fleet_Control_V1_BoolSetting) -> Bool { + if lhs.current != rhs.current {return false} + if lhs.target != rhs.target {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_AgentSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".AgentSettings" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}load_ceiling\0\u{3}mem_floor_mib\0\u{3}linux_runner_image\0\u{1}gateway\0\u{3}docker_mode\0\u{3}runner_script\0\u{1}participate\0\u{3}macos_runner_image\0\u{3}vm_mode\0") + + fileprivate class _StorageClass { + var _loadCeiling: Arcbox_Fleet_Control_V1_DoubleSetting? = nil + var _memFloorMib: Arcbox_Fleet_Control_V1_Uint64Setting? = nil + var _linuxRunnerImage: Arcbox_Fleet_Control_V1_StringSetting? = nil + var _gateway: Arcbox_Fleet_Control_V1_StringSetting? = nil + var _dockerMode: Arcbox_Fleet_Control_V1_DockerModeSetting? = nil + var _runnerScript: Arcbox_Fleet_Control_V1_StringSetting? = nil + var _participate: Arcbox_Fleet_Control_V1_BoolSetting? = nil + var _macosRunnerImage: Arcbox_Fleet_Control_V1_StringSetting? = nil + var _vmMode: Arcbox_Fleet_Control_V1_VmModeSetting? = nil + + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _loadCeiling = source._loadCeiling + _memFloorMib = source._memFloorMib + _linuxRunnerImage = source._linuxRunnerImage + _gateway = source._gateway + _dockerMode = source._dockerMode + _runnerScript = source._runnerScript + _participate = source._participate + _macosRunnerImage = source._macosRunnerImage + _vmMode = source._vmMode + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + + public mutating func decodeMessage(decoder: inout D) throws { + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &_storage._loadCeiling) }() + case 2: try { try decoder.decodeSingularMessageField(value: &_storage._memFloorMib) }() + case 3: try { try decoder.decodeSingularMessageField(value: &_storage._linuxRunnerImage) }() + case 4: try { try decoder.decodeSingularMessageField(value: &_storage._gateway) }() + case 5: try { try decoder.decodeSingularMessageField(value: &_storage._dockerMode) }() + case 6: try { try decoder.decodeSingularMessageField(value: &_storage._runnerScript) }() + case 7: try { try decoder.decodeSingularMessageField(value: &_storage._participate) }() + case 8: try { try decoder.decodeSingularMessageField(value: &_storage._macosRunnerImage) }() + case 9: try { try decoder.decodeSingularMessageField(value: &_storage._vmMode) }() + default: break + } + } + } + } + + public func traverse(visitor: inout V) throws { + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = _storage._loadCeiling { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try { if let v = _storage._memFloorMib { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try { if let v = _storage._linuxRunnerImage { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() + try { if let v = _storage._gateway { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + } }() + try { if let v = _storage._dockerMode { + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + } }() + try { if let v = _storage._runnerScript { + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + } }() + try { if let v = _storage._participate { + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + } }() + try { if let v = _storage._macosRunnerImage { + try visitor.visitSingularMessageField(value: v, fieldNumber: 8) + } }() + try { if let v = _storage._vmMode { + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + } }() + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_AgentSettings, rhs: Arcbox_Fleet_Control_V1_AgentSettings) -> Bool { + if lhs._storage !== rhs._storage { + let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let rhs_storage = _args.1 + if _storage._loadCeiling != rhs_storage._loadCeiling {return false} + if _storage._memFloorMib != rhs_storage._memFloorMib {return false} + if _storage._linuxRunnerImage != rhs_storage._linuxRunnerImage {return false} + if _storage._gateway != rhs_storage._gateway {return false} + if _storage._dockerMode != rhs_storage._dockerMode {return false} + if _storage._runnerScript != rhs_storage._runnerScript {return false} + if _storage._participate != rhs_storage._participate {return false} + if _storage._macosRunnerImage != rhs_storage._macosRunnerImage {return false} + if _storage._vmMode != rhs_storage._vmMode {return false} + return true + } + if !storagesAreEqual {return false} + } + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_PrepareRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".PrepareRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}kinds\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedEnumField(value: &self.kinds) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.kinds.isEmpty { + try visitor.visitPackedEnumField(value: self.kinds, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_PrepareRequest, rhs: Arcbox_Fleet_Control_V1_PrepareRequest) -> Bool { + if lhs.kinds != rhs.kinds {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Arcbox_Fleet_Control_V1_PrepareResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".PrepareResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}kind\0\u{1}detail\0\u{1}stage\0\u{1}fraction\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.kind) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.detail) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.stage) }() + case 4: try { try decoder.decodeSingularDoubleField(value: &self.fraction) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.kind != .unspecified { + try visitor.visitSingularEnumField(value: self.kind, fieldNumber: 1) + } + if !self.detail.isEmpty { + try visitor.visitSingularStringField(value: self.detail, fieldNumber: 2) + } + if !self.stage.isEmpty { + try visitor.visitSingularStringField(value: self.stage, fieldNumber: 3) + } + if self.fraction.bitPattern != 0 { + try visitor.visitSingularDoubleField(value: self.fraction, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Arcbox_Fleet_Control_V1_PrepareResponse, rhs: Arcbox_Fleet_Control_V1_PrepareResponse) -> Bool { + if lhs.kind != rhs.kind {return false} + if lhs.detail != rhs.detail {return false} + if lhs.stage != rhs.stage {return false} + if lhs.fraction != rhs.fraction {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Packages/FleetControlClient/Sources/FleetControlClient/Logging.swift b/Packages/FleetControlClient/Sources/FleetControlClient/Logging.swift new file mode 100644 index 00000000..b62544ac --- /dev/null +++ b/Packages/FleetControlClient/Sources/FleetControlClient/Logging.swift @@ -0,0 +1,7 @@ +import OSLog + +enum FleetControlLog { + private static let subsystem = "com.arcboxlabs.desktop" + + static let grpc = Logger(subsystem: subsystem, category: "fleet-grpc") +} diff --git a/Packages/FleetControlClient/Sources/FleetControlClient/Module.swift b/Packages/FleetControlClient/Sources/FleetControlClient/Module.swift new file mode 100644 index 00000000..7c982a01 --- /dev/null +++ b/Packages/FleetControlClient/Sources/FleetControlClient/Module.swift @@ -0,0 +1,2 @@ +/// Namespace marker for the FleetControlClient Swift package. +public enum FleetControlClientModule {} diff --git a/Packages/FleetControlClient/Tests/FleetControlClientTests/FleetControlClientTests.swift b/Packages/FleetControlClient/Tests/FleetControlClientTests/FleetControlClientTests.swift new file mode 100644 index 00000000..bb78c479 --- /dev/null +++ b/Packages/FleetControlClient/Tests/FleetControlClientTests/FleetControlClientTests.swift @@ -0,0 +1,193 @@ +import Testing + +@testable import FleetControlClient + +@Test func settingsUpdatePreservesOptionalPresence() { + let update = FleetSettingsUpdate( + loadCeiling: 0, + linuxRunnerImage: "", + dockerMode: .disabled, + participate: false, + macosRunnerImage: "tahoe-base", + vmMode: .enabled + ) + + let request = update.protoValue + + #expect(request.hasLoadCeiling) + #expect(request.loadCeiling == 0) + #expect(!request.hasMemFloorMib) + #expect(request.hasLinuxRunnerImage) + #expect(request.linuxRunnerImage.isEmpty) + #expect(!request.hasGateway) + #expect(request.hasDockerMode) + #expect(request.dockerMode == .disabled) + #expect(!request.hasRunnerScript) + #expect(request.hasParticipate) + #expect(!request.participate) + #expect(request.hasMacosRunnerImage) + #expect(request.macosRunnerImage == "tahoe-base") + #expect(request.hasVmMode) + #expect(request.vmMode == .enabled) + #expect(!update.isEmpty) + #expect(FleetSettingsUpdate().isEmpty) +} + +@Test func settingsMappingPreservesCurrentTargetAndPresence() { + var proto = Arcbox_Fleet_Control_V1_AgentSettings() + + var load = Arcbox_Fleet_Control_V1_DoubleSetting() + load.current = 0.7 + load.target = 0.9 + proto.loadCeiling = load + + var dockerMode = Arcbox_Fleet_Control_V1_DockerModeSetting() + dockerMode.current = .auto + dockerMode.target = .disabled + proto.dockerMode = dockerMode + + var linuxRunnerImage = Arcbox_Fleet_Control_V1_StringSetting() + linuxRunnerImage.current = "arcbox/runner@sha256:current" + linuxRunnerImage.target = "arcbox/runner:latest" + proto.linuxRunnerImage = linuxRunnerImage + + var participate = Arcbox_Fleet_Control_V1_BoolSetting() + participate.current = false + participate.target = true + proto.participate = participate + + var macosRunnerImage = Arcbox_Fleet_Control_V1_StringSetting() + macosRunnerImage.current = "tahoe-base@2026.07.02" + macosRunnerImage.target = "tahoe-base" + proto.macosRunnerImage = macosRunnerImage + + var vmMode = Arcbox_Fleet_Control_V1_VmModeSetting() + vmMode.current = .disabled + vmMode.target = .auto + proto.vmMode = vmMode + + let settings = FleetAgentSettings(proto: proto) + + #expect(settings.loadCeiling == FleetSetting(current: 0.7, target: 0.9)) + #expect(settings.memFloorMib == nil) + #expect( + settings.linuxRunnerImage + == FleetSetting( + current: "arcbox/runner@sha256:current", + target: "arcbox/runner:latest" + )) + #expect(settings.dockerMode == FleetSetting(current: .auto, target: .disabled)) + #expect(settings.participate == FleetSetting(current: false, target: true)) + #expect( + settings.macosRunnerImage + == FleetSetting(current: "tahoe-base@2026.07.02", target: "tahoe-base")) + #expect(settings.vmMode == FleetSetting(current: .disabled, target: .auto)) + #expect(settings.hasPendingChanges) +} + +@Test func newSettingsFieldsContributeToPendingStateIndependently() { + let imagePending = FleetAgentSettings( + macosRunnerImage: FleetSetting(current: "tahoe-base@old", target: "tahoe-base") + ) + let vmModePending = FleetAgentSettings( + vmMode: FleetSetting(current: .disabled, target: .enabled) + ) + + #expect(imagePending.hasPendingChanges) + #expect(vmModePending.hasPendingChanges) +} + +@Test func newLifecycleStatesMapWithoutLosingMeaning() { + #expect(FleetConnectionState(proto: .detached) == .detached) + #expect(FleetConnectionState(proto: .credentialRejected) == .credentialRejected) + #expect(FleetEnrollmentState(proto: .detached) == .detached) + #expect(FleetEnrollmentState(proto: .credentialRejected) == .credentialRejected) + #expect(FleetEnrollmentState(proto: .updating) == .updating) +} + +@Test func imagePreparationMappingPreservesProgressAndUnknownKinds() { + var proto = Arcbox_Fleet_Control_V1_PrepareResponse() + proto.kind = .UNRECOGNIZED(42) + proto.detail = "linux/arm64" + proto.stage = "pulling" + proto.fraction = 0.75 + + let event = FleetImagePreparationEvent(proto: proto) + + #expect( + event + == FleetImagePreparationEvent( + kind: .unrecognized(42), + detail: "linux/arm64", + stage: "pulling", + fraction: 0.75 + )) + #expect(event.kind.protoValue == .UNRECOGNIZED(42)) + #expect(FleetImageKind(proto: .macosRunnerImage) == .macosRunnerImage) + #expect(FleetImageKind.macosRunnerImage.protoValue == .macosRunnerImage) +} + +@Test func snapshotMappingPreservesOptionalTelemetryAndSettings() { + var proto = Arcbox_Fleet_Control_V1_AgentStateSnapshot() + proto.enrollment = .attached + proto.draining = true + + var capability = Arcbox_Fleet_Control_V1_Capability() + capability.os = "macos" + capability.arch = "arm64" + capability.backedBy = .vm + proto.capabilities = [capability] + + var inFlight = Arcbox_Fleet_Control_V1_InFlightJob() + inFlight.jobID = "job_123" + inFlight.os = "macos" + inFlight.arch = "arm64" + proto.inFlight = [inFlight] + + var verdict = Arcbox_Fleet_Control_V1_OfferVerdict() + verdict.jobID = "job_456" + verdict.accepted = false + verdict.reason = "draining" + proto.recentVerdicts = [verdict] + + var telemetry = Arcbox_Fleet_Control_V1_HostTelemetry() + telemetry.loadAvg1M = 1.25 + telemetry.cpuCount = 10 + telemetry.memTotalMib = 32768 + telemetry.memAvailableMib = 8192 + proto.telemetry = telemetry + + let snapshot = FleetAgentSnapshot(proto: proto) + + #expect(snapshot.enrollment == .attached) + #expect(snapshot.machineID == nil) + #expect(snapshot.isDraining) + #expect(snapshot.capabilities == [FleetCapability(os: "macos", arch: "arm64", backend: .vm)]) + #expect(snapshot.inFlightJobs == [FleetInFlightJob(jobID: "job_123", os: "macos", arch: "arm64")]) + #expect( + snapshot.recentVerdicts == [ + FleetOfferVerdict(jobID: "job_456", accepted: false, reason: "draining") + ]) + #expect( + snapshot.telemetry + == FleetHostTelemetry( + loadAverage1Minute: 1.25, + cpuCount: 10, + memoryTotalMib: 32768, + memoryAvailableMib: 8192 + )) + #expect(snapshot.settings == nil) +} + +@Test func unrecognizedWritableModesRoundTripWithoutLosingValues() { + let dockerMode = FleetDockerMode(proto: .UNRECOGNIZED(42)) + let vmMode = FleetVmMode(proto: .UNRECOGNIZED(43)) + let request = FleetSettingsUpdate(dockerMode: dockerMode, vmMode: vmMode).protoValue + + #expect(dockerMode == .unrecognized(42)) + #expect(request.hasDockerMode) + #expect(request.dockerMode == .UNRECOGNIZED(42)) + #expect(vmMode == .unrecognized(43)) + #expect(request.hasVmMode) + #expect(request.vmMode == .UNRECOGNIZED(43)) +} diff --git a/Packages/FleetControlClient/generate.sh b/Packages/FleetControlClient/generate.sh new file mode 100755 index 00000000..0461989e --- /dev/null +++ b/Packages/FleetControlClient/generate.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Generate Swift protobuf and gRPC code from the fleet agent local control API. +# +# Prerequisites: +# brew install protobuf +# +# Usage: +# cd Packages/FleetControlClient && ./generate.sh +# FLEET_CONTROL_PROTO_REPO=/path/to/arcbox ./generate.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OUT_DIR="${SCRIPT_DIR}/Sources/FleetControlClient/Generated" +PROTO_ROOT_RELATIVE="fleet/arcbox-fleet-control-proto/proto" +PROTO_RELATIVE="${PROTO_ROOT_RELATIVE}/arcbox/fleet/control/v1/control.proto" +SOURCE_FILE="${SCRIPT_DIR}/PROTO_SOURCE" + +PROTOS=( + "arcbox/fleet/control/v1/control.proto" +) + +find_local_repo() { + local candidates=( + "${SCRIPT_DIR}/../../../arcbox" + ) + for dir in "${candidates[@]}"; do + if [ -d "$dir" ]; then + echo "$(cd "$dir" && pwd)" + return 0 + fi + done + return 1 +} + +PROTO_REPO="${FLEET_CONTROL_PROTO_REPO:-}" +if [ -z "$PROTO_REPO" ]; then + PROTO_REPO="$(find_local_repo)" || { + echo "Error: local arcbox repository not found" >&2 + echo "Expected sibling checkout at ../../../arcbox or FLEET_CONTROL_PROTO_REPO" >&2 + exit 1 + } +else + PROTO_REPO="$(cd "$PROTO_REPO" && pwd)" +fi + +PROTO_ROOT="${PROTO_REPO}/${PROTO_ROOT_RELATIVE}" +PROTO_FILE="${PROTO_REPO}/${PROTO_RELATIVE}" + +if [ ! -f "$PROTO_FILE" ]; then + echo "Error: fleet control proto not found at $PROTO_FILE" >&2 + exit 1 +fi + +SOURCE_COMMIT="$(git -C "$PROTO_REPO" rev-parse HEAD)" +if ! git -C "$PROTO_REPO" diff --quiet HEAD -- "$PROTO_RELATIVE"; then + echo "Error: fleet control proto has uncommitted changes" >&2 + exit 1 +fi +PROTO_SHA256="$(shasum -a 256 "$PROTO_FILE" | cut -d ' ' -f 1)" + +mkdir -p "$OUT_DIR" + +echo "Using local fleet control proto: $PROTO_ROOT" +echo "Source commit: $SOURCE_COMMIT" +echo "Proto SHA-256: $PROTO_SHA256" +echo "Output dir: $OUT_DIR" + +echo "" +echo "Building protoc plugins..." +cd "$SCRIPT_DIR" +swift build --product protoc-gen-swift 2>&1 | tail -1 +swift build --product protoc-gen-grpc-swift 2>&1 | tail -1 + +PLUGIN_DIR="$(swift build --show-bin-path)" +export PATH="${PLUGIN_DIR}:${PATH}" + +echo "Using protoc-gen-swift: $(which protoc-gen-swift)" +echo "Using protoc-gen-grpc-swift: $(which protoc-gen-grpc-swift)" + +find "$OUT_DIR" -type f -name '*.swift' -delete + +echo "" +echo "Generating Swift protobuf code..." +printf ' %s\n' "${PROTOS[@]}" +protoc \ + --proto_path="$PROTO_ROOT" \ + --swift_out="$OUT_DIR" \ + --swift_opt=Visibility=Public \ + --grpc-swift_out="$OUT_DIR" \ + --grpc-swift_opt=Visibility=Public \ + "${PROTOS[@]/#/$PROTO_ROOT/}" + +cat > "$SOURCE_FILE" < (Data, URLResponse) +} + +extension URLSession: HTTPDataLoading {} + +/// Authenticated REST client for the Platform operations needed by Fleet onboarding. +public final class FleetPlatformClient: Sendable { + private let configuration: FleetPlatformConfiguration + private let accessTokenProvider: any AccessTokenProviding + private let http: any HTTPDataLoading + + public init( + configuration: FleetPlatformConfiguration = .current, + accessTokenProvider: any AccessTokenProviding, + session: URLSession = .shared + ) { + self.configuration = configuration + self.accessTokenProvider = accessTokenProvider + self.http = session + } + + init( + configuration: FleetPlatformConfiguration, + accessTokenProvider: any AccessTokenProviding, + http: any HTTPDataLoading + ) { + self.configuration = configuration + self.accessTokenProvider = accessTokenProvider + self.http = http + } + + /// List workspaces the current Platform identity belongs to. + public func listWorkspaces() async throws -> [FleetWorkspace] { + try await send(path: "v1/workspaces", method: "GET") + } + + /// Create and return the workspace's one-hour Fleet enrollment token, + /// invalidating any token previously issued for the workspace. + public func createEnrollmentToken(workspaceID: String) async throws -> FleetEnrollmentToken { + try await send( + path: "v1/fleet/enrollment-token", + method: "POST", + workspaceID: workspaceID + ) + } + + /// List machines enrolled in a workspace. + public func listMachines(workspaceID: String) async throws -> [FleetMachine] { + try await send( + path: "v1/fleet/machines", + method: "GET", + workspaceID: workspaceID + ) + } + + /// Get one machine enrolled in a workspace. + public func getMachine(id: String, workspaceID: String) async throws -> FleetMachine { + try await send( + path: "v1/fleet/machines/\(id)", + method: "GET", + workspaceID: workspaceID + ) + } + + /// List one cursor-based page of runner jobs, newest first. + public func listJobs( + workspaceID: String, + machineID: String? = nil, + status: FleetRunnerJobStatus? = nil, + cursor: String? = nil, + limit: Int? = nil + ) async throws -> FleetRunnerJobPage { + var queryItems: [URLQueryItem] = [] + if let machineID { + queryItems.append(URLQueryItem(name: "machine_id", value: machineID)) + } + if let status { + queryItems.append(URLQueryItem(name: "status", value: status.rawValue)) + } + if let cursor { + queryItems.append(URLQueryItem(name: "cursor", value: cursor)) + } + if let limit { + queryItems.append(URLQueryItem(name: "limit", value: String(limit))) + } + + return try await send( + path: "v1/fleet/jobs", + method: "GET", + workspaceID: workspaceID, + queryItems: queryItems + ) + } + + /// Get one runner job recorded in a workspace. + public func getJob(id: String, workspaceID: String) async throws -> FleetRunnerJob { + try await send( + path: "v1/fleet/jobs/\(id)", + method: "GET", + workspaceID: workspaceID + ) + } + + /// Convert transport/domain errors into text suitable for the Fleet UI. + public static func userMessage(for error: Error) -> String { + if let error = error as? FleetPlatformError { + return error.localizedDescription + } + if error is CancellationError { + return "The Platform request was cancelled." + } + return error.localizedDescription + } + + private func send( + path: String, + method: String, + workspaceID: String? = nil, + queryItems: [URLQueryItem] = [] + ) async throws -> Response { + let accessToken = try await accessTokenProvider.accessToken() + let endpoint = configuration.baseURL.appending(path: path) + guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { + throw FleetPlatformError.invalidResponse + } + components.queryItems = queryItems.isEmpty ? nil : queryItems + guard let url = components.url else { + throw FleetPlatformError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + if let workspaceID { + request.setValue(workspaceID, forHTTPHeaderField: "X-Workspace-Id") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await http.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch let error as URLError { + throw FleetPlatformError.transport(code: error.code) + } + guard let httpResponse = response as? HTTPURLResponse else { + throw FleetPlatformError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw Self.apiError(statusCode: httpResponse.statusCode) + } + + do { + return try Self.decoder.decode(Response.self, from: data) + } catch { + throw FleetPlatformError.malformedResponse + } + } + + private static func apiError(statusCode: Int) -> FleetPlatformError { + switch statusCode { + case 401: + return .authenticationRequired + case 403: + return .forbidden + case 404: + return .notFound + case 409: + return .conflict + case 429: + return .rateLimited + case 500..<600: + return .serverError(statusCode: statusCode) + default: + return .api(statusCode: statusCode) + } + } + + private static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .custom { decoder in + let value = try decoder.singleValueContainer().decode(String.self) + let fractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + if let date = try? fractional.parse(value) { + return date + } + let wholeSeconds = Date.ISO8601FormatStyle() + if let date = try? wholeSeconds.parse(value) { + return date + } + throw DecodingError.dataCorruptedError( + in: try decoder.singleValueContainer(), + debugDescription: "Invalid RFC 3339 date" + ) + } + return decoder + } +} diff --git a/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformConfiguration.swift b/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformConfiguration.swift new file mode 100644 index 00000000..d282801f --- /dev/null +++ b/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformConfiguration.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Resolves the ArcBox Platform API endpoint independently of OIDC discovery. +public struct FleetPlatformConfiguration: Sendable, Equatable { + public let baseURL: URL + + public init(baseURL: URL) { + self.baseURL = baseURL + } + + public static let production = FleetPlatformConfiguration( + baseURL: URL(string: "https://api.arcbox.dev")! + ) + + /// Configuration injected through `FleetPlatformBaseURL` in Info.plist. + /// Production is the safe default because this client performs no work + /// until an authenticated call is explicitly requested. + public static let current = + resolve( + baseURL: Bundle.main.object(forInfoDictionaryKey: "FleetPlatformBaseURL") as? String + ) ?? .production + + static func resolve(baseURL: String?) -> FleetPlatformConfiguration? { + guard let baseURL = configuredValue(baseURL), + let url = URL(string: baseURL), + let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http", + url.host() != nil + else { return nil } + + return FleetPlatformConfiguration(baseURL: url) + } + + private static func configuredValue(_ raw: String?) -> String? { + guard let raw, !raw.isEmpty, !raw.hasPrefix("$("), !raw.hasPrefix("YOUR_") else { + return nil + } + return raw + } +} diff --git a/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformError.swift b/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformError.swift new file mode 100644 index 00000000..de693a71 --- /dev/null +++ b/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformError.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Stable errors from the ArcBox Platform REST boundary. +public enum FleetPlatformError: Error, Sendable, Equatable { + case authenticationRequired + case forbidden + case notFound + case conflict + case rateLimited + case serverError(statusCode: Int) + case api(statusCode: Int) + case invalidResponse + case malformedResponse + case transport(code: URLError.Code) +} + +extension FleetPlatformError: LocalizedError { + public var errorDescription: String? { + switch self { + case .authenticationRequired: + "The ArcBox Platform did not accept the current sign-in session." + case .forbidden: + "You do not have access to this ArcBox workspace." + case .notFound: + "The requested ArcBox Platform resource was not found." + case .conflict: + "The ArcBox Platform could not complete the request because the resource changed." + case .rateLimited: + "The ArcBox Platform is receiving too many requests. Try again later." + case .serverError(let statusCode): + "The ArcBox Platform is unavailable (HTTP \(statusCode))." + case .api(let statusCode): + "The ArcBox Platform request failed (HTTP \(statusCode))." + case .invalidResponse: + "The ArcBox Platform returned a non-HTTP response." + case .malformedResponse: + "The ArcBox Platform returned an unreadable response." + case .transport(let code): + "Could not reach the ArcBox Platform (\(code.rawValue))." + } + } +} diff --git a/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformModels.swift b/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformModels.swift new file mode 100644 index 00000000..0ed2dacd --- /dev/null +++ b/Packages/FleetPlatformClient/Sources/FleetPlatformClient/FleetPlatformModels.swift @@ -0,0 +1,235 @@ +import Foundation + +/// A Platform workspace available to the signed-in user. +public struct FleetWorkspace: Codable, Identifiable, Sendable, Equatable { + public let id: String + public let name: String + public let plan: String + public let createdAt: Date + public let updatedAt: Date + + public init(id: String, name: String, plan: String, createdAt: Date, updatedAt: Date) { + self.id = id + self.name = name + self.plan = plan + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +/// Short-lived Platform credential presented once to the local Fleet Agent. +public struct FleetEnrollmentToken: Codable, Sendable, Equatable { + public let token: String + public let expiresAt: Date + + public init(token: String, expiresAt: Date) { + self.token = token + self.expiresAt = expiresAt + } +} + +/// Lifecycle state reported by the Platform for an enrolled Fleet machine. +public enum FleetMachineStatus: String, Decodable, Sendable, Equatable { + case enrolled + case online + case offline + case draining + case decommissioned +} + +/// Operating system targeted by a runner job or served by a machine pool. +public enum FleetRunnerOS: String, Decodable, Sendable, Equatable { + case darwin + case linux + case windows +} + +/// CPU architecture targeted by a runner job or served by a machine pool. +public enum FleetRunnerArchitecture: String, Decodable, Sendable, Equatable { + case arm64 + case amd64 +} + +/// Execution backend serving a machine pool. +public enum FleetRunnerBackend: String, Decodable, Sendable, Equatable { + case hostRunner = "host_runner" + case docker + case vm +} + +/// One runner capability advertised by a Fleet machine. +public struct FleetMachinePool: Decodable, Sendable, Equatable { + public let os: FleetRunnerOS + public let arch: FleetRunnerArchitecture + public let backedBy: FleetRunnerBackend + + public init( + os: FleetRunnerOS, + arch: FleetRunnerArchitecture, + backedBy: FleetRunnerBackend + ) { + self.os = os + self.arch = arch + self.backedBy = backedBy + } +} + +/// Latest host utilization snapshot received by the Platform. +public struct FleetMachineTelemetry: Decodable, Sendable, Equatable { + public let loadAvg1m: Double + public let cpuCount: Int + public let memTotalMib: Int64 + public let memAvailableMib: Int64 + + public init( + loadAvg1m: Double, + cpuCount: Int, + memTotalMib: Int64, + memAvailableMib: Int64 + ) { + self.loadAvg1m = loadAvg1m + self.cpuCount = cpuCount + self.memTotalMib = memTotalMib + self.memAvailableMib = memAvailableMib + } + + // Keys match the names produced by JSONDecoder.convertFromSnakeCase. + private enum CodingKeys: String, CodingKey { + case loadAvg1m = "loadAvg1M" + case cpuCount + case memTotalMib + case memAvailableMib + } +} + +/// A machine enrolled in a Platform workspace. +public struct FleetMachine: Decodable, Identifiable, Sendable, Equatable { + public let id: String + public let name: String + public let status: FleetMachineStatus + public let arch: String + public let cpu: Int + public let memMib: Int64 + public let tags: [String] + public let createdAt: Date + public let enrolledAt: Date? + public let lastSeen: Date? + public let agentVersion: String? + public let pools: [FleetMachinePool] + public let telemetry: FleetMachineTelemetry? + + public init( + id: String, + name: String, + status: FleetMachineStatus, + arch: String, + cpu: Int, + memMib: Int64, + tags: [String], + createdAt: Date, + enrolledAt: Date?, + lastSeen: Date?, + agentVersion: String?, + pools: [FleetMachinePool], + telemetry: FleetMachineTelemetry? + ) { + self.id = id + self.name = name + self.status = status + self.arch = arch + self.cpu = cpu + self.memMib = memMib + self.tags = tags + self.createdAt = createdAt + self.enrolledAt = enrolledAt + self.lastSeen = lastSeen + self.agentVersion = agentVersion + self.pools = pools + self.telemetry = telemetry + } +} + +/// Lifecycle state reported by the Platform for a runner job. +public enum FleetRunnerJobStatus: String, Decodable, Sendable, Equatable { + case queued + case provisioning + case running + case completed + case failed + case canceled +} + +/// A GitHub Actions runner job recorded by the Platform. +public struct FleetRunnerJob: Decodable, Identifiable, Sendable, Equatable { + public let id: String + public let repo: String + public let status: FleetRunnerJobStatus + public let os: FleetRunnerOS + public let arch: FleetRunnerArchitecture + public let githubRunID: Int64 + public let githubJobID: Int64 + public let labels: [String] + public let machineID: String? + public let jitRunnerName: String? + public let createdAt: Date + public let startedAt: Date? + public let finishedAt: Date? + + public init( + id: String, + repo: String, + status: FleetRunnerJobStatus, + os: FleetRunnerOS, + arch: FleetRunnerArchitecture, + githubRunID: Int64, + githubJobID: Int64, + labels: [String], + machineID: String?, + jitRunnerName: String?, + createdAt: Date, + startedAt: Date?, + finishedAt: Date? + ) { + self.id = id + self.repo = repo + self.status = status + self.os = os + self.arch = arch + self.githubRunID = githubRunID + self.githubJobID = githubJobID + self.labels = labels + self.machineID = machineID + self.jitRunnerName = jitRunnerName + self.createdAt = createdAt + self.startedAt = startedAt + self.finishedAt = finishedAt + } + + // Keys match the names produced by JSONDecoder.convertFromSnakeCase. + private enum CodingKeys: String, CodingKey { + case id + case repo + case status + case os + case arch + case githubRunID = "ghRunId" + case githubJobID = "ghJobId" + case labels + case machineID = "machineId" + case jitRunnerName + case createdAt + case startedAt + case finishedAt + } +} + +/// One cursor-based page of runner jobs. +public struct FleetRunnerJobPage: Decodable, Sendable, Equatable { + public let jobs: [FleetRunnerJob] + public let nextCursor: String? + + public init(jobs: [FleetRunnerJob], nextCursor: String?) { + self.jobs = jobs + self.nextCursor = nextCursor + } +} diff --git a/Packages/FleetPlatformClient/Tests/FleetPlatformClientTests/FleetPlatformClientTests.swift b/Packages/FleetPlatformClient/Tests/FleetPlatformClientTests/FleetPlatformClientTests.swift new file mode 100644 index 00000000..9c914184 --- /dev/null +++ b/Packages/FleetPlatformClient/Tests/FleetPlatformClientTests/FleetPlatformClientTests.swift @@ -0,0 +1,403 @@ +// System modules precede local packages per the repository import-order guideline. +// swift-format-ignore: OrderedImports +import Foundation +import Testing +import ArcBoxAuth + +@testable import FleetPlatformClient + +struct FleetPlatformClientTests { + private let configuration = FleetPlatformConfiguration( + baseURL: URL(string: "https://api.example.com/root")! + ) + + @Test func listWorkspacesBuildsAuthenticatedRequestAndDecodesResponse() async throws { + let json = """ + [{ + "id":"ws_123", + "name":"ArcBox Labs", + "plan":"free", + "created_at":"2026-07-14T12:34:56.123456Z", + "updated_at":"2026-07-14T13:34:56Z" + }] + """ + let http = HTTPStub { request in + #expect(request.httpMethod == "GET") + #expect(request.url?.absoluteString == "https://api.example.com/root/v1/workspaces") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oidc-token") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + return (Data(json.utf8), try response(for: request)) + } + let client = makeClient(http: http) + + let workspaces = try await client.listWorkspaces() + + #expect(workspaces.count == 1) + #expect(workspaces.first?.id == "ws_123") + #expect(workspaces.first?.name == "ArcBox Labs") + #expect(workspaces.first?.plan == "free") + } + + @Test func createEnrollmentTokenUsesWorkspaceHeaderAndDecodesResponse() async throws { + let json = #"{"token":"flet_secret","expires_at":"2026-07-14T14:34:56.123Z"}"# + let http = HTTPStub { request in + #expect(request.httpMethod == "POST") + #expect( + request.url?.absoluteString + == "https://api.example.com/root/v1/fleet/enrollment-token" + ) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oidc-token") + #expect(request.value(forHTTPHeaderField: "X-Workspace-Id") == "ws_123") + #expect(request.httpBody == nil) + return (Data(json.utf8), try response(for: request)) + } + let client = makeClient(http: http) + + let enrollment = try await client.createEnrollmentToken(workspaceID: "ws_123") + + #expect(enrollment.token == "flet_secret") + } + + @Test func listMachinesUsesWorkspaceHeaderAndDecodesResponse() async throws { + let json = """ + [{ + "id":"fltm_123", + "name":"Shuo's Mac", + "status":"online", + "arch":"arm64", + "cpu":12, + "mem_mib":24576, + "host_info":{"hostname":"studio"}, + "tags":["desktop"], + "created_at":"2026-07-24T08:00:00Z", + "enrolled_at":"2026-07-24T08:01:00Z", + "last_seen":"2026-07-24T08:02:00.123Z", + "agent_version":"0.5.1", + "pools":[{"os":"darwin","arch":"arm64","backed_by":"vm"}], + "telemetry":{ + "load_avg_1m":1.25, + "cpu_count":12, + "mem_total_mib":24576, + "mem_available_mib":16384 + } + }] + """ + let http = HTTPStub { request in + #expect(request.httpMethod == "GET") + #expect(request.url?.absoluteString == "https://api.example.com/root/v1/fleet/machines") + #expect(request.value(forHTTPHeaderField: "X-Workspace-Id") == "ws_123") + return (Data(json.utf8), try response(for: request)) + } + let client = makeClient(http: http) + + let machines = try await client.listMachines(workspaceID: "ws_123") + + let machine = try #require(machines.first) + #expect(machine.id == "fltm_123") + #expect(machine.status == .online) + #expect(machine.agentVersion == "0.5.1") + #expect(machine.pools == [.init(os: .darwin, arch: .arm64, backedBy: .vm)]) + #expect(machine.telemetry?.memAvailableMib == 16384) + } + + @Test func getMachineUsesOpaquePrefixedID() async throws { + let json = """ + { + "id":"fltm_abc", + "name":"This Mac", + "status":"enrolled", + "arch":"arm64", + "cpu":8, + "mem_mib":16384, + "host_info":{}, + "tags":[], + "created_at":"2026-07-24T08:00:00Z", + "pools":[] + } + """ + let http = HTTPStub { request in + #expect( + request.url?.absoluteString + == "https://api.example.com/root/v1/fleet/machines/fltm_abc" + ) + #expect(request.value(forHTTPHeaderField: "X-Workspace-Id") == "ws_123") + return (Data(json.utf8), try response(for: request)) + } + let client = makeClient(http: http) + + let machine = try await client.getMachine(id: "fltm_abc", workspaceID: "ws_123") + + #expect(machine.id == "fltm_abc") + } + + @Test func listJobsBuildsFiltersAndDecodesCursorPage() async throws { + let json = """ + { + "jobs":[{ + "id":"5ce49702-bb18-4a15-9ee5-91a62619799a", + "repo":"arcboxlabs/arcbox", + "status":"running", + "os":"linux", + "arch":"amd64", + "gh_run_id":123, + "gh_job_id":456, + "labels":["self-hosted","linux"], + "machine_id":"fltm_abc", + "jit_runner_name":"arcbox-123", + "created_at":"2026-07-24T08:00:00Z", + "started_at":"2026-07-24T08:00:05Z" + }], + "next_cursor":"next-page" + } + """ + let http = HTTPStub { request in + #expect(request.httpMethod == "GET") + let url = try #require(request.url) + let components = try #require( + URLComponents(url: url, resolvingAgainstBaseURL: false) + ) + #expect(components.path == "/root/v1/fleet/jobs") + #expect( + components.queryItems + == [ + URLQueryItem(name: "machine_id", value: "fltm_abc"), + URLQueryItem(name: "status", value: "running"), + URLQueryItem(name: "cursor", value: "older"), + URLQueryItem(name: "limit", value: "25"), + ] + ) + #expect(request.value(forHTTPHeaderField: "X-Workspace-Id") == "ws_123") + return (Data(json.utf8), try response(for: request)) + } + let client = makeClient(http: http) + + let page = try await client.listJobs( + workspaceID: "ws_123", + machineID: "fltm_abc", + status: .running, + cursor: "older", + limit: 25 + ) + + let job = try #require(page.jobs.first) + #expect(job.machineID == "fltm_abc") + #expect(job.githubRunID == 123) + #expect(job.githubJobID == 456) + #expect(job.status == .running) + #expect(job.os == .linux) + #expect(job.arch == .amd64) + #expect(page.nextCursor == "next-page") + } + + @Test func getJobBuildsWorkspaceScopedRequest() async throws { + let json = """ + { + "id":"5ce49702-bb18-4a15-9ee5-91a62619799a", + "repo":"arcboxlabs/arcbox", + "status":"completed", + "os":"darwin", + "arch":"arm64", + "gh_run_id":123, + "gh_job_id":456, + "labels":[], + "created_at":"2026-07-24T08:00:00Z", + "finished_at":"2026-07-24T08:01:00Z" + } + """ + let http = HTTPStub { request in + #expect( + request.url?.absoluteString + == "https://api.example.com/root/v1/fleet/jobs/" + + "5ce49702-bb18-4a15-9ee5-91a62619799a" + ) + #expect(request.value(forHTTPHeaderField: "X-Workspace-Id") == "ws_123") + return (Data(json.utf8), try response(for: request)) + } + let client = makeClient(http: http) + + let job = try await client.getJob( + id: "5ce49702-bb18-4a15-9ee5-91a62619799a", + workspaceID: "ws_123" + ) + + #expect(job.status == .completed) + #expect(job.finishedAt != nil) + } + + @Test func requestsAValidAccessTokenEveryTime() async throws { + let tokenProvider = CountingTokenProvider() + let http = HTTPStub { request in + (Data("[]".utf8), try response(for: request)) + } + let client = makeClient(accessTokenProvider: tokenProvider, http: http) + + _ = try await client.listWorkspaces() + _ = try await client.listWorkspaces() + + #expect(await tokenProvider.callCount == 2) + } + + @Test func mapsKnownHTTPStatusResponses() async { + let cases: [(Int, FleetPlatformError)] = [ + (401, .authenticationRequired), + (403, .forbidden), + (404, .notFound), + (409, .conflict), + (429, .rateLimited), + (503, .serverError(statusCode: 503)), + ] + + for (statusCode, expectedError) in cases { + let http = HTTPStub { request in + (Data(), try response(for: request, statusCode: statusCode)) + } + let client = makeClient(http: http) + + await #expect(throws: expectedError) { + try await client.listWorkspaces() + } + } + } + + @Test func serverMessageCannotExposeEnrollmentToken() async { + let secret = "flet_super_secret" + let json = """ + {"error":[{"code":422,"status":"INVALID_ARGUMENT",\ + "message":"Rejected token: \(secret)"}]} + """ + let http = HTTPStub { request in + (Data(json.utf8), try response(for: request, statusCode: 422)) + } + let client = makeClient(http: http) + + do { + _ = try await client.listWorkspaces() + Issue.record("Expected the request to fail") + } catch { + #expect(error as? FleetPlatformError == .api(statusCode: 422)) + #expect(!error.localizedDescription.contains(secret)) + #expect(!String(describing: error).contains(secret)) + } + } + + @Test func rejectsMalformedSuccessPayload() async { + let http = HTTPStub { request in + (Data("not json".utf8), try response(for: request)) + } + let client = makeClient(http: http) + + await #expect(throws: FleetPlatformError.malformedResponse) { + try await client.listWorkspaces() + } + } + + @Test func rejectsSuccessPayloadMissingRequiredFields() async { + let http = HTTPStub { request in + (Data(#"{"token":"flet_secret"}"#.utf8), try response(for: request)) + } + let client = makeClient(http: http) + + await #expect(throws: FleetPlatformError.malformedResponse) { + try await client.createEnrollmentToken(workspaceID: "ws_123") + } + } + + @Test func propagatesCancellation() async { + let http = HTTPStub { _ in throw CancellationError() } + let client = makeClient(http: http) + + await #expect(throws: CancellationError.self) { + try await client.listWorkspaces() + } + } + + @Test func mapsURLSessionTransportFailureWithoutRequestDetails() async { + let http = HTTPStub { _ in throw URLError(.cannotConnectToHost) } + let client = makeClient(http: http) + + await #expect( + throws: FleetPlatformError.transport(code: .cannotConnectToHost) + ) { + try await client.listWorkspaces() + } + } + + @Test func rejectsNonHTTPResponse() async { + let http = HTTPStub { request in + let url = try #require(request.url) + return ( + Data("[]".utf8), + URLResponse( + url: url, + mimeType: "application/json", + expectedContentLength: 2, + textEncodingName: nil + ) + ) + } + let client = makeClient(http: http) + + await #expect(throws: FleetPlatformError.invalidResponse) { + try await client.listWorkspaces() + } + } + + @Test func rejectsInvalidConfiguredBaseURL() { + #expect(FleetPlatformConfiguration.resolve(baseURL: "not-a-url") == nil) + #expect(FleetPlatformConfiguration.resolve(baseURL: "$(FLEET_PLATFORM_BASE_URL)") == nil) + #expect( + FleetPlatformConfiguration.resolve(baseURL: "http://localhost:2801")?.baseURL + == URL(string: "http://localhost:2801") + ) + } + + private func makeClient( + accessTokenProvider: any AccessTokenProviding = StubTokenProvider(), + http: any HTTPDataLoading + ) -> FleetPlatformClient { + FleetPlatformClient( + configuration: configuration, + accessTokenProvider: accessTokenProvider, + http: http + ) + } +} + +private struct StubTokenProvider: AccessTokenProviding { + func accessToken() async throws -> String { + "oidc-token" + } +} + +private actor CountingTokenProvider: AccessTokenProviding { + private(set) var callCount = 0 + + func accessToken() async throws -> String { + callCount += 1 + return "oidc-token" + } +} + +private actor HTTPStub: HTTPDataLoading { + private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + + init(handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) { + self.handler = handler + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await handler(request) + } +} + +private func response(for request: URLRequest, statusCode: Int = 200) throws -> HTTPURLResponse { + let url = try #require(request.url) + return try #require( + HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/2", + headerFields: ["Content-Type": "application/json"] + ) + ) +} diff --git a/project.yml b/project.yml index ed69428a..c5fc82f3 100644 --- a/project.yml +++ b/project.yml @@ -19,6 +19,10 @@ packages: path: Packages/ArcBoxAuth ArcBoxClient: path: Packages/ArcBoxClient + FleetControlClient: + path: Packages/FleetControlClient + FleetPlatformClient: + path: Packages/FleetPlatformClient DockerClient: path: Packages/DockerClient K8sClient: @@ -139,6 +143,8 @@ targets: dependencies: - package: ArcBoxAuth - package: ArcBoxClient + - package: FleetControlClient + - package: FleetPlatformClient - package: DockerClient - package: K8sClient - package: SwiftTerm