feat(macos): auto-enable local gateway

This commit is contained in:
Peter Steinberger
2025-12-20 14:46:53 +00:00
parent cf96ad8ef9
commit f508fd3fa2
7 changed files with 79 additions and 154 deletions

View File

@@ -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) }

View File

@@ -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
}
}

View File

@@ -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).

View File

@@ -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

View File

@@ -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()

View File

@@ -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))
}
}

View File

@@ -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)