feat(macos): auto-enable local gateway
This commit is contained in:
@@ -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) }
|
||||
|
||||
|
||||
16
apps/macos/Sources/Clawdis/GatewayAutostartPolicy.swift
Normal file
16
apps/macos/Sources/Clawdis/GatewayAutostartPolicy.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user