diff --git a/Sources/Services/ContainerIsolationService.swift b/Sources/Services/ContainerIsolationService.swift index 85865ed..ca0e3e6 100644 --- a/Sources/Services/ContainerIsolationService.swift +++ b/Sources/Services/ContainerIsolationService.swift @@ -1,7 +1,7 @@ import Foundation #if canImport(Containerization) -import Containerization +@preconcurrency import Containerization #endif /// Service for managing container-based isolation of GitHub Actions runners. @@ -21,6 +21,7 @@ import Containerization /// - Mounted workspace directory /// - GitHub Actions runner environment @available(macOS 26.0, *) +@MainActor class ContainerIsolationService { #if canImport(Containerization) // MARK: - Properties @@ -103,12 +104,7 @@ class ContainerIsolationService { // Determine which container image to use let imageReference = config.containerImage ?? ContainerRunnerConfiguration.defaultRunnerImage - // Create container with specified configuration - let container = try await manager.create( - id, - reference: imageReference, - rootfsSizeInBytes: config.diskSizeInBytes - ) { containerConfig in + let configuration: @Sendable (inout LinuxContainer.Configuration) -> Void = { containerConfig in // Resource allocation containerConfig.cpus = config.cpuCount containerConfig.memoryInBytes = config.memoryInBytes @@ -146,13 +142,19 @@ class ContainerIsolationService { // Set environment variables containerConfig.process.environmentVariables.append("RUNNER_ALLOW_RUNASROOT=1") - // Enable nested virtualization if requested if config.enableNestedVirtualization { - // Note: This may not be supported in all versions of the framework - // containerConfig.enableNestedVirtualization = true + // Reserved for framework support. } } + // Create container with specified configuration + let container = try await manager.create( + id, + reference: imageReference, + rootfsSizeInBytes: config.diskSizeInBytes, + configuration: configuration + ) + // Store mutated manager back self.containerManager = manager diff --git a/Sources/Services/RunnerManager.swift b/Sources/Services/RunnerManager.swift index 33f981b..51ebefe 100644 --- a/Sources/Services/RunnerManager.swift +++ b/Sources/Services/RunnerManager.swift @@ -9,6 +9,12 @@ import Containerization @MainActor class RunnerManager: ObservableObject { + enum LoginItemAction: Equatable { + case register + case unregister + case none + } + private enum UpdateStatusMessages { static let defaultAutomaticChecks = "Checks GitHub releases on launch and once per day." static let alreadyChecking = "Update check already in progress." @@ -96,7 +102,7 @@ class RunnerManager: ObservableObject { } reconcileRunnerStates() - syncLoginItem() + reconcileLoginItemSetting() startStatusPolling() } @@ -352,32 +358,51 @@ class RunnerManager: ObservableObject { // MARK: - Login Item - /// Synchronize the macOS login item registration with current settings. + /// Reconcile the persisted setting with the current macOS login item state. /// - /// First reconciles the config with the actual OS state (in case the user toggled - /// the login item via System Settings), then registers or unregisters as needed. + /// This is intentionally only performed during initialization. A user change in + /// Mac Runner must be applied to macOS before the system state can be trusted; + /// otherwise enabling the toggle is immediately overwritten by the old state. + private func reconcileLoginItemSetting() { + let osEnabled = SMAppService.mainApp.status == .enabled + guard currentSettings.startOnLogin != osEnabled else { return } + + currentSettings.startOnLogin = osEnabled + saveConfiguration() + } + + /// Apply the current setting to the macOS login item registration. private func syncLoginItem() { let service = SMAppService.mainApp - let osEnabled = service.status == .enabled - - // Reconcile: if OS state disagrees with config, trust the OS - if currentSettings.startOnLogin != osEnabled { - currentSettings.startOnLogin = osEnabled - saveConfiguration() - } do { - if currentSettings.startOnLogin { - if service.status != .enabled { - try service.register() - } - } else { - if service.status == .enabled { - try service.unregister() - } + switch Self.loginItemAction( + startOnLogin: currentSettings.startOnLogin, + isRegistered: service.status == .enabled + ) { + case .register: + try service.register() + case .unregister: + try service.unregister() + case .none: + break } } catch { - self.error = "Failed to update login item: \(error.localizedDescription)" + let loginItemError = "Failed to update login item: \(error.localizedDescription)" + currentSettings.startOnLogin = service.status == .enabled + saveConfiguration() + self.error = loginItemError + } + } + + nonisolated static func loginItemAction( + startOnLogin: Bool, + isRegistered: Bool + ) -> LoginItemAction { + switch (startOnLogin, isRegistered) { + case (true, false): return .register + case (false, true): return .unregister + default: return .none } } diff --git a/Tests/MacRunnerTests/MacRunnerTests.swift b/Tests/MacRunnerTests/MacRunnerTests.swift index 7212747..cde6555 100644 --- a/Tests/MacRunnerTests/MacRunnerTests.swift +++ b/Tests/MacRunnerTests/MacRunnerTests.swift @@ -6,6 +6,20 @@ final class MacRunnerTests: XCTestCase { XCTAssertTrue(true) } + func testEnablingLaunchAtLoginRegistersWhenSystemItemIsDisabled() { + XCTAssertEqual( + RunnerManager.loginItemAction(startOnLogin: true, isRegistered: false), + .register + ) + } + + func testDisablingLaunchAtLoginUnregistersWhenSystemItemIsEnabled() { + XCTAssertEqual( + RunnerManager.loginItemAction(startOnLogin: false, isRegistered: true), + .unregister + ) + } + func testPreferredKernelPathPrefersBundledKernel() { let bundleURL = URL(fileURLWithPath: "/Applications/MacRunner.app/Contents/Resources") let appSupportURL = URL(fileURLWithPath: "/Users/test/Library/Application Support/MacRunner") diff --git a/Tests/MacRunnerTests/UpdateCheckerTests.swift b/Tests/MacRunnerTests/UpdateCheckerTests.swift index 817d15b..a524f84 100644 --- a/Tests/MacRunnerTests/UpdateCheckerTests.swift +++ b/Tests/MacRunnerTests/UpdateCheckerTests.swift @@ -97,7 +97,13 @@ final class UpdateCheckerTests: XCTestCase { func testHomebrewInstallDetectionUsesCellarPaths() { XCTAssertEqual(UpdateChecker.installSource(for: "/opt/homebrew/Cellar/mac-runner/1.2.3/Mac Runner.app"), .homebrewFormula) - XCTAssertEqual(UpdateChecker.installSource(for: "/Applications/Mac Runner.app"), .directDownload) + XCTAssertEqual( + UpdateChecker.installSource( + for: "/Applications/Mac Runner.app", + fileExists: { _ in false } + ), + .directDownload + ) } func testHomebrewInstallDetectionUsesCaskReceipt() {