feat(macos): auto-enable local gateway
parent
cf96ad8ef9
commit
f508fd3fa2
|
|
@ -29,10 +29,18 @@ final class ConnectionModeCoordinator {
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
|
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
if paused {
|
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||||
GatewayProcessManager.shared.stop()
|
if shouldStart {
|
||||||
} else {
|
|
||||||
GatewayProcessManager.shared.setActive(true)
|
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) }
|
Task.detached { await PortGuardian.shared.sweep(mode: .local) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
func startIfNeeded() {
|
||||||
guard self.desiredActive else { return }
|
guard self.desiredActive else { return }
|
||||||
// Do not spawn in remote mode (the gateway should run on the remote host).
|
// 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 cliInstalled = false
|
||||||
@State private var cliInstallLocation: String?
|
@State private var cliInstallLocation: String?
|
||||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
@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 remoteStatus: RemoteStatus = .idle
|
||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
|
|
@ -347,27 +345,10 @@ struct GeneralSettings: View {
|
||||||
.foregroundStyle(.red)
|
.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() }
|
Button("Recheck") { self.refreshGatewayStatus() }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(self.gatewayInstalling)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(self
|
Text("Gateway auto-starts in local mode via launchd (\(gatewayLaunchdLabel)).")
|
||||||
.gatewayInstallMessage ??
|
|
||||||
"Enables the bundled Gateway via launchd (\(gatewayLaunchdLabel)). No Node install required.")
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.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 {
|
private var gatewayStatusColor: Color {
|
||||||
switch self.gatewayStatus.kind {
|
switch self.gatewayStatus.kind {
|
||||||
case .ok: .green
|
case .ok: .green
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,6 @@ struct OnboardingView: View {
|
||||||
@State private var identityEmoji: String = ""
|
@State private var identityEmoji: String = ""
|
||||||
@State private var identityStatus: String?
|
@State private var identityStatus: String?
|
||||||
@State private var identityApplying = false
|
@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 showAdvancedConnection = false
|
||||||
@State private var preferredGatewayID: String?
|
@State private var preferredGatewayID: String?
|
||||||
@State private var gatewayDiscovery: GatewayDiscoveryModel
|
@State private var gatewayDiscovery: GatewayDiscoveryModel
|
||||||
|
|
@ -98,7 +95,7 @@ struct OnboardingView: View {
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
[0, 1, 9]
|
[0, 1, 9]
|
||||||
case .local:
|
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 {
|
.task {
|
||||||
await self.refreshPerms()
|
await self.refreshPerms()
|
||||||
self.refreshCLIStatus()
|
self.refreshCLIStatus()
|
||||||
self.refreshGatewayStatus()
|
|
||||||
self.loadWorkspaceDefaults()
|
self.loadWorkspaceDefaults()
|
||||||
self.refreshAnthropicOAuthStatus()
|
self.refreshAnthropicOAuthStatus()
|
||||||
self.loadIdentityDefaults()
|
self.loadIdentityDefaults()
|
||||||
|
|
@ -212,8 +208,6 @@ struct OnboardingView: View {
|
||||||
self.anthropicAuthPage()
|
self.anthropicAuthPage()
|
||||||
case 3:
|
case 3:
|
||||||
self.identityPage()
|
self.identityPage()
|
||||||
case 4:
|
|
||||||
self.gatewayPage()
|
|
||||||
case 5:
|
case 5:
|
||||||
self.permissionsPage()
|
self.permissionsPage()
|
||||||
case 6:
|
case 6:
|
||||||
|
|
@ -291,7 +285,7 @@ struct OnboardingView: View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
let localSubtitle: String = {
|
let localSubtitle: String = {
|
||||||
guard let probe = self.localGatewayProbe else {
|
guard let probe = self.localGatewayProbe else {
|
||||||
return "Run the Gateway locally."
|
return "Gateway starts automatically on this Mac."
|
||||||
}
|
}
|
||||||
let base = probe.expected
|
let base = probe.expected
|
||||||
? "Existing gateway detected"
|
? "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 {
|
private func permissionsPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
|
|
@ -1412,16 +1327,6 @@ struct OnboardingView: View {
|
||||||
self.cliInstalled = installLocation != nil
|
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 {
|
private func refreshLocalGatewayProbe() async {
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
let desc = await PortGuardian.shared.describe(port: port)
|
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) {
|
private func copyToPasteboard(_ text: String) {
|
||||||
let pb = NSPasteboard.general
|
let pb = NSPasteboard.general
|
||||||
pb.clearContents()
|
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
|
## Onboarding
|
||||||
- Install CLI (symlink) → Permissions checklist → Test notification → Done.
|
- Install CLI (symlink) → Permissions checklist → Test notification → Done.
|
||||||
- Remote mode skips local gateway/CLI steps.
|
- 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)
|
## Deep links (URL scheme)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue