From f508fd3fa22a954842dbe51d8606ab2743087dff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 14:46:53 +0000 Subject: [PATCH] feat(macos): auto-enable local gateway --- .../Clawdis/ConnectionModeCoordinator.swift | 14 ++- .../Clawdis/GatewayAutostartPolicy.swift | 16 +++ .../Clawdis/GatewayProcessManager.swift | 14 +++ .../Sources/Clawdis/GeneralSettings.swift | 37 +----- apps/macos/Sources/Clawdis/Onboarding.swift | 119 +----------------- .../GatewayAutostartPolicyTests.swift | 32 +++++ docs/clawdis-mac.md | 1 + 7 files changed, 79 insertions(+), 154 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/GatewayAutostartPolicy.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/GatewayAutostartPolicyTests.swift diff --git a/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift b/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift index 65a74e2cb..46866b655 100644 --- a/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift +++ b/apps/macos/Sources/Clawdis/ConnectionModeCoordinator.swift @@ -29,10 +29,18 @@ final class ConnectionModeCoordinator { self.logger.error( "control channel local configure failed: \(error.localizedDescription, privacy: .public)") } - if paused { - GatewayProcessManager.shared.stop() - } else { + let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) + if shouldStart { GatewayProcessManager.shared.setActive(true) + if GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: paused, + attachExistingOnly: AppStateStore.attachExistingGatewayOnly) + { + Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } + } + } else { + GatewayProcessManager.shared.stop() } Task.detached { await PortGuardian.shared.sweep(mode: .local) } diff --git a/apps/macos/Sources/Clawdis/GatewayAutostartPolicy.swift b/apps/macos/Sources/Clawdis/GatewayAutostartPolicy.swift new file mode 100644 index 000000000..50f194589 --- /dev/null +++ b/apps/macos/Sources/Clawdis/GatewayAutostartPolicy.swift @@ -0,0 +1,16 @@ +import Foundation + +enum GatewayAutostartPolicy { + static func shouldStartGateway(mode: AppState.ConnectionMode, paused: Bool) -> Bool { + mode == .local && !paused + } + + static func shouldEnsureLaunchAgent( + mode: AppState.ConnectionMode, + paused: Bool, + attachExistingOnly: Bool + ) -> Bool { + shouldStartGateway(mode: mode, paused: paused) && !attachExistingOnly + } +} + diff --git a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift index f46e0a9ee..8d711e74a 100644 --- a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift @@ -61,6 +61,20 @@ final class GatewayProcessManager { } } + func ensureLaunchAgentEnabledIfNeeded() async { + guard !CommandResolver.connectionModeIsRemote() else { return } + guard !AppStateStore.attachExistingGatewayOnly else { return } + let enabled = await GatewayLaunchAgentManager.status() + guard !enabled else { return } + let bundlePath = Bundle.main.bundleURL.path + let port = GatewayEnvironment.gatewayPort() + self.appendLog("[gateway] auto-enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n") + let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) + if let err { + self.appendLog("[gateway] launchd auto-enable failed: \(err)\n") + } + } + func startIfNeeded() { guard self.desiredActive else { return } // Do not spawn in remote mode (the gateway should run on the remote host). diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index e68855c1b..e428993cd 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -14,8 +14,6 @@ struct GeneralSettings: View { @State private var cliInstalled = false @State private var cliInstallLocation: String? @State private var gatewayStatus: GatewayEnvironmentStatus = .checking - @State private var gatewayInstallMessage: String? - @State private var gatewayInstalling = false @State private var remoteStatus: RemoteStatus = .idle @State private var showRemoteAdvanced = false private let isPreview = ProcessInfo.processInfo.isPreview @@ -347,27 +345,10 @@ struct GeneralSettings: View { .foregroundStyle(.red) } - HStack(spacing: 10) { - Button { - Task { await self.installGateway() } - } label: { - if self.gatewayInstalling { - ProgressView().controlSize(.small) - } else { - Text("Enable Gateway daemon") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.gatewayInstalling) + Button("Recheck") { self.refreshGatewayStatus() } + .buttonStyle(.bordered) - Button("Recheck") { self.refreshGatewayStatus() } - .buttonStyle(.bordered) - .disabled(self.gatewayInstalling) - } - - Text(self - .gatewayInstallMessage ?? - "Enables the bundled Gateway via launchd (\(gatewayLaunchdLabel)). No Node install required.") + Text("Gateway auto-starts in local mode via launchd (\(gatewayLaunchdLabel)).") .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) @@ -404,18 +385,6 @@ struct GeneralSettings: View { } } - private func installGateway() async { - guard !self.gatewayInstalling else { return } - self.gatewayInstalling = true - defer { self.gatewayInstalling = false } - self.gatewayInstallMessage = nil - let port = GatewayEnvironment.gatewayPort() - let bundlePath = Bundle.main.bundleURL.path - let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) - self.gatewayInstallMessage = err ?? "Gateway enabled and started on port \(port)" - self.refreshGatewayStatus() - } - private var gatewayStatusColor: Color { switch self.gatewayStatus.kind { case .ok: .green diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 96413b357..5ca0df788 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -72,9 +72,6 @@ struct OnboardingView: View { @State private var identityEmoji: String = "" @State private var identityStatus: String? @State private var identityApplying = false - @State private var gatewayStatus: GatewayEnvironmentStatus = .checking - @State private var gatewayInstalling = false - @State private var gatewayInstallMessage: String? @State private var showAdvancedConnection = false @State private var preferredGatewayID: String? @State private var gatewayDiscovery: GatewayDiscoveryModel @@ -98,7 +95,7 @@ struct OnboardingView: View { case .unconfigured: [0, 1, 9] case .local: - [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + [0, 1, 2, 3, 5, 6, 7, 8, 9] } } @@ -175,7 +172,6 @@ struct OnboardingView: View { .task { await self.refreshPerms() self.refreshCLIStatus() - self.refreshGatewayStatus() self.loadWorkspaceDefaults() self.refreshAnthropicOAuthStatus() self.loadIdentityDefaults() @@ -212,8 +208,6 @@ struct OnboardingView: View { self.anthropicAuthPage() case 3: self.identityPage() - case 4: - self.gatewayPage() case 5: self.permissionsPage() case 6: @@ -291,7 +285,7 @@ struct OnboardingView: View { VStack(alignment: .leading, spacing: 10) { let localSubtitle: String = { guard let probe = self.localGatewayProbe else { - return "Run the Gateway locally." + return "Gateway starts automatically on this Mac." } let base = probe.expected ? "Existing gateway detected" @@ -800,85 +794,6 @@ struct OnboardingView: View { } } - private func gatewayPage() -> some View { - self.onboardingPage { - Text("Install the gateway") - .font(.largeTitle.weight(.semibold)) - Text( - "The Gateway is the WebSocket service that keeps Clawdis connected. " + - "Clawdis bundles it and runs it via launchd so it stays running.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - .fixedSize(horizontal: false, vertical: true) - - self.onboardingCard(spacing: 10, padding: 14) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Circle() - .fill(self.gatewayStatusColor) - .frame(width: 10, height: 10) - Text(self.gatewayStatus.message) - .font(.callout.weight(.semibold)) - .frame(maxWidth: .infinity, alignment: .leading) - } - - if let gatewayVersion = self.gatewayStatus.gatewayVersion, - let required = self.gatewayStatus.requiredGateway, - gatewayVersion != required - { - Text("Installed: \(gatewayVersion) · Required: \(required)") - .font(.caption) - .foregroundStyle(.secondary) - } else if let gatewayVersion = self.gatewayStatus.gatewayVersion { - Text("Gateway \(gatewayVersion) detected") - .font(.caption) - .foregroundStyle(.secondary) - } - - if let node = self.gatewayStatus.nodeVersion { - Text("Node \(node)") - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 12) { - Button { - Task { await self.installGateway() } - } label: { - if self.gatewayInstalling { - ProgressView() - } else { - Text("Enable Gateway daemon") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.gatewayInstalling) - - Button("Recheck") { self.refreshGatewayStatus() } - .buttonStyle(.bordered) - .disabled(self.gatewayInstalling) - } - - if let gatewayInstallMessage { - Text(gatewayInstallMessage) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } else { - Text( - "Installs a per-user LaunchAgent (\(gatewayLaunchdLabel)). " + - "The gateway listens on port 18789.") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - } - } - } - private func permissionsPage() -> some View { self.onboardingPage { Text("Grant permissions") @@ -1412,16 +1327,6 @@ struct OnboardingView: View { self.cliInstalled = installLocation != nil } - private func refreshGatewayStatus() { - Task { - let status = await Task.detached(priority: .utility) { - GatewayEnvironment.check() - }.value - self.gatewayStatus = status - await self.refreshLocalGatewayProbe() - } - } - private func refreshLocalGatewayProbe() async { let port = GatewayEnvironment.gatewayPort() let desc = await PortGuardian.shared.describe(port: port) @@ -1442,26 +1347,6 @@ struct OnboardingView: View { } } - private func installGateway() async { - guard !self.gatewayInstalling else { return } - self.gatewayInstalling = true - defer { self.gatewayInstalling = false } - self.gatewayInstallMessage = nil - let port = GatewayEnvironment.gatewayPort() - let bundlePath = Bundle.main.bundleURL.path - let err = await GatewayLaunchAgentManager.set(enabled: true, bundlePath: bundlePath, port: port) - self.gatewayInstallMessage = err ?? "Gateway enabled and started on port \(port)" - self.refreshGatewayStatus() - } - - private var gatewayStatusColor: Color { - switch self.gatewayStatus.kind { - case .ok: .green - case .checking: .secondary - case .missingNode, .missingGateway, .incompatible, .error: .orange - } - } - private func copyToPasteboard(_ text: String) { let pb = NSPasteboard.general pb.clearContents() diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayAutostartPolicyTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayAutostartPolicyTests.swift new file mode 100644 index 000000000..296b2507a --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayAutostartPolicyTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import Clawdis + +@Suite(.serialized) +struct GatewayAutostartPolicyTests { + @Test func startsGatewayOnlyWhenLocalAndNotPaused() { + #expect(GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: false)) + #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: true)) + #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .remote, paused: false)) + #expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .unconfigured, paused: false)) + } + + @Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() { + #expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: false, + attachExistingOnly: false)) + #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: false, + attachExistingOnly: true)) + #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .local, + paused: true, + attachExistingOnly: false)) + #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( + mode: .remote, + paused: false, + attachExistingOnly: false)) + } +} + diff --git a/docs/clawdis-mac.md b/docs/clawdis-mac.md index c3f54f412..2ef75fc84 100644 --- a/docs/clawdis-mac.md +++ b/docs/clawdis-mac.md @@ -52,6 +52,7 @@ Author: steipete · Status: draft spec · Date: 2025-12-20 ## Onboarding - Install CLI (symlink) → Permissions checklist → Test notification → Done. - Remote mode skips local gateway/CLI steps. +- Selecting Local auto-enables the bundled Gateway via launchd (unless “Attach only” debug mode is enabled). ## Deep links (URL scheme)