mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(macos): isolate named app profiles (#121136)
* feat(macos): isolate named app profiles * refactor(macos): isolate profile launch ownership * fix(macos): avoid overlapping approvals socket access * fix(macos): declare profile defaults concurrency ownership * fix(macos): return profiled node launch arguments * chore(i18n): refresh macOS profile source inventory * fix(macos): gate profile startup before services * test(macos): evaluate profile state before assertions * fix(daemon): skip absent launchd deactivation * fix(macos): fail closed on profile port conflicts * chore(i18n): refresh profile conflict inventory * fix(macos): ignore non-gateway launch agent claims * test(macos): stabilize profile lifecycle timing * fix(macos): remove stale dashboard URL * chore(macos): refresh native source baseline
This commit is contained in:
committed by
GitHub
parent
0ef798d28d
commit
0dbdf994b3
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched.
|
||||
- **Developer workflow:** remove the obsolete scoped-commit helper and use standard Git commands in isolated worktrees.
|
||||
- **Plugin uninstall cleanup:** remove exact recorded install paths from `plugins.load.paths` for marketplace, npm, and other managed installs while preserving parent, child, prefix, and unrelated paths.
|
||||
- Fixed Crabbox hydration on unprivileged cloud sandboxes by falling back to a user-writable pnpm store when the shared `/var/cache/crabbox` cache is unavailable, preserving the hardlink import mode after hydration, and making Docker an explicit routed capability instead of an implicit install requirement.
|
||||
|
||||
+331
-299
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,32 @@ the `--chat`/`--dashboard` auto-open helpers. Pairing, control-channel, and Mac
|
||||
node services still start. Combine it with `--attach-only` when an external
|
||||
process owns the local Gateway.
|
||||
|
||||
## App profiles
|
||||
|
||||
Launch a fully isolated app instance with the same profile name used by the CLI:
|
||||
|
||||
```bash
|
||||
OPENCLAW_PROFILE=work /Applications/OpenClaw.app/Contents/MacOS/OpenClaw
|
||||
```
|
||||
|
||||
Profile names use 1–64 lowercase letters, numbers, underscores, or hyphens and
|
||||
must start with a letter or number. `default` selects the normal app; `gateway`,
|
||||
`mac`, and `node` are reserved LaunchAgent identities.
|
||||
|
||||
`scripts/restart-mac.sh` intentionally rejects named profiles because its
|
||||
packaging cleanup is host-global. Build/package normally, then launch the named
|
||||
profile directly with the command above.
|
||||
|
||||
A named profile keeps state in `~/.openclaw-<name>`, uses its own app defaults,
|
||||
Keychain services, duplicate-instance lock, and the CLI-managed Gateway service
|
||||
`ai.openclaw.<name>`. Unless config or environment selects a port, each profile
|
||||
derives a stable port in the profile `20000...59999` range. The app does not
|
||||
install or modify the host-global Mac node
|
||||
service or OpenClaw login item while a profile is active. The runtime child node
|
||||
still runs in process as usual. App relocation, Sparkle updates, and post-update
|
||||
service repair are disabled in profile mode; update the installed app through
|
||||
the normal default-profile workflow.
|
||||
|
||||
## Packaging flow
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,7 @@ struct AboutSettings: View {
|
||||
weak var updater: UpdaterProviding?
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var iconHover = false
|
||||
@AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true
|
||||
@AppStorage("autoUpdateEnabled", store: AppDefaults.standard) private var autoCheckEnabled = true
|
||||
@State private var didLoadUpdaterState = false
|
||||
|
||||
var body: some View {
|
||||
@@ -70,7 +70,9 @@ struct AboutSettings: View {
|
||||
Button("Check for Updates…") { updater.checkForUpdates(nil) }
|
||||
}
|
||||
} else {
|
||||
Text("Updates unavailable in this build.")
|
||||
Text(AppProfile.current.isActive
|
||||
? "App updates are unavailable while a profile is active."
|
||||
: "Updates unavailable in this build.")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct AppProfile: Equatable, Sendable {
|
||||
struct ValidationError: LocalizedError, Equatable, Sendable {
|
||||
let rawValue: String
|
||||
let reason: String
|
||||
|
||||
var errorDescription: String? {
|
||||
"Invalid OPENCLAW_PROFILE \"\(self.rawValue)\": \(self.reason)"
|
||||
}
|
||||
}
|
||||
|
||||
static let current = Self(environment: ProcessInfo.processInfo.environment)
|
||||
private static let reservedLaunchAgentNames: Set<String> = ["gateway", "mac", "node"]
|
||||
|
||||
let name: String?
|
||||
let validationError: ValidationError?
|
||||
|
||||
init(environment: [String: String]) {
|
||||
let raw = environment["OPENCLAW_PROFILE"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if raw.isEmpty || raw.lowercased() == "default" {
|
||||
self.name = nil
|
||||
self.validationError = nil
|
||||
return
|
||||
}
|
||||
if let reason = Self.invalidReason(raw) {
|
||||
self.name = nil
|
||||
self.validationError = ValidationError(rawValue: raw, reason: reason)
|
||||
return
|
||||
}
|
||||
self.name = raw
|
||||
self.validationError = nil
|
||||
}
|
||||
|
||||
var isActive: Bool {
|
||||
self.name != nil
|
||||
}
|
||||
|
||||
var gatewayLaunchAgentLabel: String {
|
||||
// Keep this byte-for-byte aligned with src/daemon/constants.ts
|
||||
// resolveGatewayLaunchAgentLabel; the CLI owns the managed service.
|
||||
self.name.map { "ai.openclaw.\($0)" } ?? "ai.openclaw.gateway"
|
||||
}
|
||||
|
||||
var defaultsSuiteName: String? {
|
||||
// Named profiles need a stable domain even when dev and packaged bundle ids differ.
|
||||
self.name.map { "\(launchdLabel).profile.\($0)" }
|
||||
}
|
||||
|
||||
var keychainServiceSuffix: String {
|
||||
self.name.map { ".profile.\($0)" } ?? ""
|
||||
}
|
||||
|
||||
func keychainService(base: String) -> String {
|
||||
base + self.keychainServiceSuffix
|
||||
}
|
||||
|
||||
func stateDirectoryURL(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
|
||||
let directory = self.name.map { ".openclaw-\($0)" } ?? ".openclaw"
|
||||
return homeDirectory.appendingPathComponent(directory, isDirectory: true)
|
||||
}
|
||||
|
||||
var cliRootArguments: [String] {
|
||||
self.name.map { ["--profile", $0] } ?? []
|
||||
}
|
||||
|
||||
func localCLICommand(prefix: [String], arguments: [String]) -> [String] {
|
||||
prefix + self.cliRootArguments + arguments
|
||||
}
|
||||
|
||||
var instanceLockName: String {
|
||||
self.name.map { "ai.openclaw.mac.profile.\($0)" } ?? "ai.openclaw.mac"
|
||||
}
|
||||
|
||||
func instanceLockURL(systemTemporaryDirectory: URL = URL(fileURLWithPath: "/tmp", isDirectory: true)) -> URL {
|
||||
// Service/defaults/Keychain identity follows only the profile; state overrides must not split this lock.
|
||||
systemTemporaryDirectory
|
||||
.appendingPathComponent("openclaw-\(geteuid())-app-instances", isDirectory: true)
|
||||
.appendingPathComponent("\(self.instanceLockName).lock", isDirectory: false)
|
||||
}
|
||||
|
||||
var defaultGatewayPort: Int {
|
||||
guard let name else { return 18789 }
|
||||
var hash: UInt32 = 2_166_136_261
|
||||
for byte in name.utf8 {
|
||||
hash = (hash ^ UInt32(byte)) &* 16_777_619
|
||||
}
|
||||
return 20000 + Int(hash % 40000)
|
||||
}
|
||||
|
||||
private static func invalidReason(_ value: String) -> String? {
|
||||
// Mirror src/cli/profile-utils.ts, then apply the stricter macOS native-service rules.
|
||||
guard value.utf8.count <= 64,
|
||||
value.utf8.first.map(self.isASCIIAlphanumeric) == true,
|
||||
value.utf8.allSatisfy({ Self.isASCIIAlphanumeric($0) || $0 == 45 || $0 == 95 })
|
||||
else {
|
||||
return "use 1-64 letters, numbers, underscores, or hyphens, starting with a letter or number"
|
||||
}
|
||||
guard value == value.lowercased() else {
|
||||
return "macOS profile names must be lowercase so state and LaunchAgent identities cannot collide"
|
||||
}
|
||||
guard !self.reservedLaunchAgentNames.contains(value) else {
|
||||
return "\"\(value)\" is reserved by an existing OpenClaw LaunchAgent"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isASCIIAlphanumeric(_ byte: UInt8) -> Bool {
|
||||
(48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppDefaults {
|
||||
/// UserDefaults synchronizes access internally; the selected suite is immutable for this process.
|
||||
nonisolated(unsafe) static let standard: UserDefaults = {
|
||||
guard let suiteName = AppProfile.current.defaultsSuiteName else {
|
||||
return UserDefaults.standard
|
||||
}
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
fatalError("Could not create UserDefaults suite \(suiteName)")
|
||||
}
|
||||
return defaults
|
||||
}()
|
||||
}
|
||||
@@ -115,7 +115,7 @@ final class AppState {
|
||||
@ObservationIgnored private var activeComputerPresenceUpdateGeneration: UInt64 = 0
|
||||
|
||||
var isPaused: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||
}
|
||||
|
||||
var launchAtLogin: Bool {
|
||||
@@ -131,13 +131,13 @@ final class AppState {
|
||||
}
|
||||
|
||||
var onboardingSeen: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) }
|
||||
}
|
||||
}
|
||||
|
||||
var debugPaneEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) }
|
||||
self.ifNotPreview { AppDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) }
|
||||
CanvasManager.shared.refreshDebugStatus()
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ final class AppState {
|
||||
var nativeSettingsPanesEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.nativeSettingsPanesEnabled, forKey: nativeSettingsPanesEnabledKey)
|
||||
AppDefaults.standard.set(self.nativeSettingsPanesEnabled, forKey: nativeSettingsPanesEnabledKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ final class AppState {
|
||||
var swabbleEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
|
||||
AppDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
}
|
||||
@@ -163,7 +163,7 @@ final class AppState {
|
||||
didSet {
|
||||
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
|
||||
AppDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
|
||||
if self.swabbleEnabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
@@ -181,7 +181,7 @@ final class AppState {
|
||||
}
|
||||
|
||||
var iconAnimationsEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(
|
||||
self.iconAnimationsEnabled,
|
||||
forKey: iconAnimationsEnabledKey) } }
|
||||
}
|
||||
@@ -189,7 +189,7 @@ final class AppState {
|
||||
var showDockIcon: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
|
||||
AppDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
|
||||
AppActivationPolicy.apply(showDockIcon: self.showDockIcon)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ final class AppState {
|
||||
var voiceWakeMicID: String {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
|
||||
AppDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
|
||||
if self.swabbleEnabled, !self.talkEnabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
@@ -210,13 +210,13 @@ final class AppState {
|
||||
}
|
||||
|
||||
var voiceWakeMicName: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
|
||||
}
|
||||
|
||||
var voiceWakeLocaleID: String {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
|
||||
AppDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
|
||||
if self.swabbleEnabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
@@ -225,13 +225,13 @@ final class AppState {
|
||||
}
|
||||
|
||||
var voiceWakeAdditionalLocaleIDs: [String] {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(
|
||||
self.voiceWakeAdditionalLocaleIDs,
|
||||
forKey: voiceWakeAdditionalLocalesKey) } }
|
||||
}
|
||||
|
||||
var voicePushToTalkEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(
|
||||
self.voicePushToTalkEnabled,
|
||||
forKey: voicePushToTalkEnabledKey) } }
|
||||
}
|
||||
@@ -239,7 +239,7 @@ final class AppState {
|
||||
var voiceWakeTriggersTalkMode: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
|
||||
AppDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
|
||||
if self.swabbleEnabled {
|
||||
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
|
||||
}
|
||||
@@ -252,7 +252,7 @@ final class AppState {
|
||||
var talkEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
|
||||
AppDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
|
||||
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ final class AppState {
|
||||
var talkPhaseSoundsEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
|
||||
AppDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,7 @@ final class AppState {
|
||||
var talkShiftToStopEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
|
||||
AppDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
|
||||
Task { TalkSpeechInterruptMonitor.shared.setEnabled(self.talkShiftToStopEnabled && self.talkEnabled) }
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,7 @@ final class AppState {
|
||||
var seamColorHex: String?
|
||||
|
||||
var iconOverride: IconOverrideSelection {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
|
||||
}
|
||||
|
||||
var isWorking: Bool = false
|
||||
@@ -289,7 +289,7 @@ final class AppState {
|
||||
var heartbeatsEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
|
||||
AppDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
|
||||
Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,7 @@ final class AppState {
|
||||
|
||||
var connectionMode: ConnectionMode {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||
self.ifNotPreview { AppDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
|
||||
if oldValue != self.connectionMode {
|
||||
self.markGatewayConfigDirty([.mode])
|
||||
}
|
||||
@@ -321,11 +321,11 @@ final class AppState {
|
||||
}
|
||||
|
||||
var canvasEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
var quickChatEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.quickChatEnabled, forKey: quickChatEnabledKey) } }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.quickChatEnabled, forKey: quickChatEnabledKey) } }
|
||||
}
|
||||
|
||||
var activeComputerPresenceEnabled: Bool {
|
||||
@@ -342,7 +342,7 @@ final class AppState {
|
||||
var peekabooBridgeEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
|
||||
AppDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
|
||||
}
|
||||
self.applyPeekabooBridgeHostState()
|
||||
}
|
||||
@@ -361,7 +361,7 @@ final class AppState {
|
||||
|
||||
var remoteTarget: String {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||
self.ifNotPreview { AppDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||
if oldValue != self.remoteTarget {
|
||||
self.markGatewayConfigDirty([.remoteTarget, .remoteUrl, .remoteHostKeyPolicy])
|
||||
}
|
||||
@@ -410,7 +410,7 @@ final class AppState {
|
||||
|
||||
var remoteIdentity: String {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
|
||||
self.ifNotPreview { AppDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
|
||||
if oldValue != self.remoteIdentity {
|
||||
self.markGatewayConfigDirty([.remoteIdentity])
|
||||
}
|
||||
@@ -419,11 +419,11 @@ final class AppState {
|
||||
}
|
||||
|
||||
var remoteProjectRoot: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
|
||||
}
|
||||
|
||||
var remoteCliPath: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
|
||||
didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
|
||||
}
|
||||
|
||||
@ObservationIgnored private var earBoostTask: Task<Void, Never>?
|
||||
@@ -442,19 +442,20 @@ final class AppState {
|
||||
let isPreview = preview || ProcessInfo.processInfo.isRunningTests
|
||||
self.isPreview = isPreview
|
||||
self.bundleLocationAllowsPersistentIntegration =
|
||||
isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration()
|
||||
!AppProfile.current.isActive &&
|
||||
(isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration())
|
||||
self.execApprovalsDefaultsAsyncResolver = execApprovalsDefaultsAsyncResolver
|
||||
self.execApprovalsReadRetryDelay = execApprovalsReadRetryDelay
|
||||
self.gatewayConfigSaver = gatewayConfigSaver
|
||||
let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey)
|
||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
let onboardingSeen = AppDefaults.standard.bool(forKey: onboardingSeenKey)
|
||||
self.isPaused = AppDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.launchAtLogin = false
|
||||
self.onboardingSeen = onboardingSeen
|
||||
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: debugPaneEnabledKey)
|
||||
self.nativeSettingsPanesEnabled = UserDefaults.standard.bool(forKey: nativeSettingsPanesEnabledKey)
|
||||
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
|
||||
self.debugPaneEnabled = AppDefaults.standard.bool(forKey: debugPaneEnabledKey)
|
||||
self.nativeSettingsPanesEnabled = AppDefaults.standard.bool(forKey: nativeSettingsPanesEnabledKey)
|
||||
let savedVoiceWake = AppDefaults.standard.bool(forKey: swabbleEnabledKey)
|
||||
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
|
||||
self.swabbleTriggerWords = UserDefaults.standard
|
||||
self.swabbleTriggerWords = AppDefaults.standard
|
||||
.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
|
||||
self.voiceWakeTriggerChime = Self.loadChime(
|
||||
key: voiceWakeTriggerChimeKey,
|
||||
@@ -462,54 +463,54 @@ final class AppState {
|
||||
self.voiceWakeSendChime = Self.loadChime(
|
||||
key: voiceWakeSendChimeKey,
|
||||
fallback: .system(name: "Glass"))
|
||||
if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
|
||||
if let storedIconAnimations = AppDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
|
||||
self.iconAnimationsEnabled = storedIconAnimations
|
||||
} else {
|
||||
self.iconAnimationsEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
|
||||
AppDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
|
||||
}
|
||||
if let storedShowDockIcon = UserDefaults.standard.object(forKey: showDockIconKey) as? Bool {
|
||||
if let storedShowDockIcon = AppDefaults.standard.object(forKey: showDockIconKey) as? Bool {
|
||||
self.showDockIcon = storedShowDockIcon
|
||||
} else {
|
||||
self.showDockIcon = true
|
||||
UserDefaults.standard.set(true, forKey: showDockIconKey)
|
||||
AppDefaults.standard.set(true, forKey: showDockIconKey)
|
||||
}
|
||||
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
||||
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
|
||||
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
|
||||
self.voiceWakeMicID = AppDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
||||
self.voiceWakeMicName = AppDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
|
||||
self.voiceWakeLocaleID = AppDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||
self.voiceWakeAdditionalLocaleIDs = AppDefaults.standard
|
||||
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
||||
self.voicePushToTalkEnabled = UserDefaults.standard
|
||||
self.voicePushToTalkEnabled = AppDefaults.standard
|
||||
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
|
||||
self.voiceWakeTriggersTalkMode = UserDefaults.standard
|
||||
self.voiceWakeTriggersTalkMode = AppDefaults.standard
|
||||
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
|
||||
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
|
||||
if let storedPhaseSounds = UserDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
|
||||
self.talkEnabled = AppDefaults.standard.bool(forKey: talkEnabledKey)
|
||||
if let storedPhaseSounds = AppDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
|
||||
self.talkPhaseSoundsEnabled = storedPhaseSounds
|
||||
} else {
|
||||
self.talkPhaseSoundsEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
|
||||
AppDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
|
||||
}
|
||||
if let storedShiftToStop = UserDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
|
||||
if let storedShiftToStop = AppDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
|
||||
self.talkShiftToStopEnabled = storedShiftToStop
|
||||
} else {
|
||||
self.talkShiftToStopEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
|
||||
AppDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
|
||||
}
|
||||
self.seamColorHex = nil
|
||||
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||
if let storedHeartbeats = AppDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||
self.heartbeatsEnabled = storedHeartbeats
|
||||
} else {
|
||||
self.heartbeatsEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||
AppDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||
}
|
||||
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
|
||||
if let storedOverride = AppDefaults.standard.string(forKey: iconOverrideKey),
|
||||
let selection = IconOverrideSelection(rawValue: storedOverride)
|
||||
{
|
||||
self.iconOverride = selection
|
||||
} else {
|
||||
self.iconOverride = .system
|
||||
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
||||
AppDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
||||
}
|
||||
|
||||
let configRoot = OpenClawConfigFile.loadDict()
|
||||
@@ -528,7 +529,7 @@ final class AppState {
|
||||
let hasConfigRemoteTarget = configRemote?.keys.contains("sshTarget") == true
|
||||
let configRemoteTarget = (configRemote?["sshTarget"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
let storedRemoteTarget = AppDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
if resolvedConnectionMode == .remote,
|
||||
hasConfigRemoteTarget
|
||||
{
|
||||
@@ -551,21 +552,23 @@ final class AppState {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
self.remoteIdentity = hasConfigRemoteIdentity
|
||||
? configRemoteIdentity
|
||||
: UserDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.quickChatEnabled = UserDefaults.standard.object(forKey: quickChatEnabledKey) as? Bool ?? true
|
||||
: AppDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty ?? ""
|
||||
self.remoteProjectRoot = AppDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
|
||||
self.remoteCliPath = AppDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
|
||||
self.canvasEnabled = AppDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.quickChatEnabled = AppDefaults.standard.object(forKey: quickChatEnabledKey) as? Bool ?? true
|
||||
self.activeComputerPresenceEnabled = Self.resolveActiveComputerPresenceEnabled()
|
||||
self.execApprovalMode = .deny
|
||||
self.execApprovalPolicyLoadState = .loading
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
self.peekabooBridgeEnabled = AppDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
if !self.isPreview {
|
||||
if !self.isPreview, !AppProfile.current.isActive {
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
let current = await LaunchAgentManager.status()
|
||||
await MainActor.run { [weak self] in self?.hydrateLaunchAtLogin(current) }
|
||||
}
|
||||
} else if !self.isPreview, AppProfile.current.isActive {
|
||||
Self.logger.info("login-agent status skipped (unavailable under app profile)")
|
||||
}
|
||||
|
||||
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
|
||||
@@ -1125,7 +1128,7 @@ extension AppState {
|
||||
// MARK: - Chime persistence
|
||||
|
||||
private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime {
|
||||
guard let data = UserDefaults.standard.data(forKey: key) else { return fallback }
|
||||
guard let data = AppDefaults.standard.data(forKey: key) else { return fallback }
|
||||
if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) {
|
||||
return decoded
|
||||
}
|
||||
@@ -1134,7 +1137,7 @@ extension AppState {
|
||||
|
||||
private func storeChime(_ chime: VoiceWakeChime, key: String) {
|
||||
guard let data = try? JSONEncoder().encode(chime) else { return }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
AppDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1556,7 +1559,7 @@ extension AppState {
|
||||
}
|
||||
|
||||
extension AppState {
|
||||
static func resolveActiveComputerPresenceEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
static func resolveActiveComputerPresenceEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool {
|
||||
defaults.bool(forKey: activeComputerPresenceEnabledKey)
|
||||
}
|
||||
|
||||
@@ -1575,7 +1578,7 @@ extension AppState {
|
||||
private func scheduleActiveComputerPresenceUpdate() {
|
||||
self.ifNotPreview {
|
||||
let enabled = self.activeComputerPresenceEnabled
|
||||
UserDefaults.standard.set(enabled, forKey: activeComputerPresenceEnabledKey)
|
||||
AppDefaults.standard.set(enabled, forKey: activeComputerPresenceEnabledKey)
|
||||
PresenceReporter.shared.sendImmediate(reason: "activity-sharing-changed")
|
||||
self.activeComputerPresenceUpdateGeneration &+= 1
|
||||
let generation = self.activeComputerPresenceUpdateGeneration
|
||||
|
||||
@@ -56,9 +56,9 @@ final class CLIInstallPrompter {
|
||||
if Self.hasPendingManagedRestart() { return }
|
||||
}
|
||||
guard !status.isReady else { return }
|
||||
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||
let lastPrompt = AppDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||
guard lastPrompt != version else { return }
|
||||
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||
AppDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||
|
||||
if let target = self.installTargetForCurrentBuild(confirmStable: true) {
|
||||
Task { _ = await self.installCLI(target: target) }
|
||||
@@ -224,15 +224,15 @@ final class CLIInstallPrompter {
|
||||
}
|
||||
|
||||
static func hasPendingManagedRestart() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: cliManagedRestartPendingKey)
|
||||
AppDefaults.standard.bool(forKey: cliManagedRestartPendingKey)
|
||||
}
|
||||
|
||||
static func setPendingManagedRestart() {
|
||||
UserDefaults.standard.set(true, forKey: cliManagedRestartPendingKey)
|
||||
AppDefaults.standard.set(true, forKey: cliManagedRestartPendingKey)
|
||||
}
|
||||
|
||||
static func clearPendingManagedRestart() {
|
||||
UserDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey)
|
||||
AppDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey)
|
||||
}
|
||||
|
||||
static func shouldManageCLI(connectionMode: AppState.ConnectionMode) -> Bool {
|
||||
|
||||
@@ -22,14 +22,14 @@ enum CLIInstallBuild {
|
||||
}
|
||||
|
||||
enum CLIInstallPolicy {
|
||||
static func storedPolicy(defaults: UserDefaults = .standard) -> String? {
|
||||
static func storedPolicy(defaults: UserDefaults = AppDefaults.standard) -> String? {
|
||||
defaults.string(forKey: cliInstallPolicyKey)
|
||||
}
|
||||
|
||||
static func requiredGatewayVersionString(
|
||||
appVersion: String?,
|
||||
isDebug: Bool,
|
||||
defaults: UserDefaults = .standard) -> String?
|
||||
defaults: UserDefaults = AppDefaults.standard) -> String?
|
||||
{
|
||||
guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else {
|
||||
return appVersion
|
||||
@@ -315,8 +315,8 @@ enum CLIInstaller {
|
||||
|
||||
private static func rememberValidated(_ status: Status) {
|
||||
guard case let .ready(location, version) = status else { return }
|
||||
UserDefaults.standard.set(location, forKey: cliValidatedExecutableKey)
|
||||
UserDefaults.standard.set(version, forKey: cliValidatedVersionKey)
|
||||
AppDefaults.standard.set(location, forKey: cliValidatedExecutableKey)
|
||||
AppDefaults.standard.set(version, forKey: cliValidatedVersionKey)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -470,11 +470,13 @@ enum CLIInstaller {
|
||||
executable: String,
|
||||
targetVersion: String,
|
||||
restartGateway: Bool = true,
|
||||
repair: Bool = false) -> [String]
|
||||
repair: Bool = false,
|
||||
profile: AppProfile = .current) -> [String]
|
||||
{
|
||||
var command = repair
|
||||
? [executable, "update", "repair", "--json", "--timeout", "900", "--yes"]
|
||||
: [executable, "update", "--tag", targetVersion, "--json", "--timeout", "900"]
|
||||
let arguments = repair
|
||||
? ["update", "repair", "--json", "--timeout", "900", "--yes"]
|
||||
: ["update", "--tag", targetVersion, "--json", "--timeout", "900"]
|
||||
var command = profile.localCLICommand(prefix: [executable], arguments: arguments)
|
||||
if !restartGateway {
|
||||
command.append("--no-restart")
|
||||
}
|
||||
@@ -559,7 +561,7 @@ enum CLIInstaller {
|
||||
case .exact: "exact"
|
||||
case let .channel(channel): channel.rawValue
|
||||
}
|
||||
UserDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
|
||||
AppDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
|
||||
}
|
||||
|
||||
private static func devCheckoutLocation(prefix: String) -> String {
|
||||
|
||||
@@ -28,7 +28,7 @@ extension CanvasWindowController {
|
||||
|
||||
static func loadRestoredFrame(sessionKey: String) -> NSRect? {
|
||||
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
|
||||
guard let arr = AppDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
|
||||
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
|
||||
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
|
||||
return rect
|
||||
@@ -36,7 +36,7 @@ extension CanvasWindowController {
|
||||
|
||||
static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
|
||||
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
|
||||
UserDefaults.standard.set(
|
||||
AppDefaults.standard.set(
|
||||
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
|
||||
forKey: key)
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ enum CommandResolver {
|
||||
runtime: RuntimeResolution,
|
||||
entrypoint: String,
|
||||
subcommand: String,
|
||||
extraArgs: [String]) -> [String]
|
||||
extraArgs: [String],
|
||||
profile: AppProfile = .current) -> [String]
|
||||
{
|
||||
[runtime.path, entrypoint, subcommand] + extraArgs
|
||||
profile.localCLICommand(
|
||||
prefix: [runtime.path, entrypoint],
|
||||
arguments: [subcommand] + extraArgs)
|
||||
}
|
||||
|
||||
static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] {
|
||||
@@ -45,7 +48,7 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func projectRoot() -> URL {
|
||||
if let stored = UserDefaults.standard.string(forKey: projectRootDefaultsKey),
|
||||
if let stored = AppDefaults.standard.string(forKey: projectRootDefaultsKey),
|
||||
let url = expandPath(stored),
|
||||
FileManager().fileExists(atPath: url.path)
|
||||
{
|
||||
@@ -60,7 +63,7 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func setProjectRoot(_ path: String) {
|
||||
UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey)
|
||||
AppDefaults.standard.set(path, forKey: self.projectRootDefaultsKey)
|
||||
}
|
||||
|
||||
static func projectRootPath() -> String {
|
||||
@@ -73,7 +76,7 @@ enum CommandResolver {
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
let projectRoot = self.projectRoot()
|
||||
let validatedExecutable = self.validatedOpenClawExecutable(
|
||||
defaults: .standard,
|
||||
defaults: AppDefaults.standard,
|
||||
fileManager: .default,
|
||||
requiredVersion: GatewayEnvironment.expectedGatewayVersionString())
|
||||
return self.preferredPaths(
|
||||
@@ -267,7 +270,8 @@ enum CommandResolver {
|
||||
switch await self.runtimeResolution(searchPaths: searchPaths) {
|
||||
case let .success(runtime):
|
||||
return MacNodeHostWorkerLaunch(
|
||||
command: [runtime.path, sourceRunner.path, "node", "worker"],
|
||||
command: self.nodeHostWorkerCommand(
|
||||
prefix: [runtime.path, sourceRunner.path]),
|
||||
currentDirectoryURL: root)
|
||||
case let .failure(error):
|
||||
throw error
|
||||
@@ -277,13 +281,21 @@ enum CommandResolver {
|
||||
#endif
|
||||
}
|
||||
|
||||
static func nodeHostWorkerCommand(
|
||||
prefix: [String],
|
||||
profile: AppProfile = .current) -> [String]
|
||||
{
|
||||
profile.localCLICommand(prefix: prefix, arguments: ["node", "worker"])
|
||||
}
|
||||
|
||||
static func openclawNodeCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil,
|
||||
projectRoot: URL? = nil) async -> [String]
|
||||
projectRoot: URL? = nil,
|
||||
profile: AppProfile = .current) async -> [String]
|
||||
{
|
||||
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
|
||||
if settings.mode == .remote, settings.transport == .ssh {
|
||||
@@ -299,10 +311,10 @@ enum CommandResolver {
|
||||
|
||||
let root = projectRoot ?? self.projectRoot()
|
||||
if let openclawPath = projectOpenClawExecutable(projectRoot: root) {
|
||||
return [openclawPath, subcommand] + extraArgs
|
||||
return profile.localCLICommand(prefix: [openclawPath], arguments: [subcommand] + extraArgs)
|
||||
}
|
||||
if let openclawPath = openclawExecutable(searchPaths: searchPaths) {
|
||||
return [openclawPath, subcommand] + extraArgs
|
||||
return profile.localCLICommand(prefix: [openclawPath], arguments: [subcommand] + extraArgs)
|
||||
}
|
||||
|
||||
let runtimeResult = await self.runtimeResolution(searchPaths: searchPaths)
|
||||
@@ -313,7 +325,8 @@ enum CommandResolver {
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs)
|
||||
extraArgs: extraArgs,
|
||||
profile: profile)
|
||||
}
|
||||
case .failure:
|
||||
break
|
||||
@@ -321,7 +334,9 @@ enum CommandResolver {
|
||||
|
||||
if let pnpm = findExecutable(named: "pnpm", searchPaths: searchPaths) {
|
||||
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
|
||||
return [pnpm, "--silent", "openclaw", subcommand] + extraArgs
|
||||
return profile.localCLICommand(
|
||||
prefix: [pnpm, "--silent", "openclaw"],
|
||||
arguments: [subcommand] + extraArgs)
|
||||
}
|
||||
|
||||
switch runtimeResult {
|
||||
@@ -338,10 +353,11 @@ enum CommandResolver {
|
||||
static func openclawCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil,
|
||||
projectRoot: URL? = nil) async -> [String]
|
||||
projectRoot: URL? = nil,
|
||||
profile: AppProfile = .current) async -> [String]
|
||||
{
|
||||
await self.openclawNodeCommand(
|
||||
subcommand: subcommand,
|
||||
@@ -349,7 +365,8 @@ enum CommandResolver {
|
||||
defaults: defaults,
|
||||
configRoot: configRoot,
|
||||
searchPaths: searchPaths,
|
||||
projectRoot: projectRoot)
|
||||
projectRoot: projectRoot,
|
||||
profile: profile)
|
||||
}
|
||||
|
||||
// MARK: - SSH helpers
|
||||
@@ -503,7 +520,7 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func connectionSettings(
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
configRoot: [String: Any]? = nil) -> RemoteSettings
|
||||
{
|
||||
let root = configRoot ?? OpenClawConfigFile.loadDict()
|
||||
@@ -545,7 +562,7 @@ enum CommandResolver {
|
||||
sshHostKeyPolicy: sshHostKeyPolicy)
|
||||
}
|
||||
|
||||
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
|
||||
static func connectionModeIsRemote(defaults: UserDefaults = AppDefaults.standard) -> Bool {
|
||||
self.connectionSettings(defaults: defaults).mode == .remote
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ struct EffectiveConnectionMode: Equatable {
|
||||
enum ConnectionModeResolver {
|
||||
static func resolve(
|
||||
root: [String: Any],
|
||||
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
|
||||
defaults: UserDefaults = AppDefaults.standard) -> EffectiveConnectionMode
|
||||
{
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let configModeRaw = (gateway?["mode"] as? String) ?? ""
|
||||
|
||||
@@ -3,7 +3,10 @@ import Foundation
|
||||
// Stable identifier used for both the macOS LaunchAgent label and Nix-managed defaults suite.
|
||||
// nix-openclaw writes app defaults into this suite to survive app bundle identifier churn.
|
||||
let launchdLabel = "ai.openclaw.mac"
|
||||
let gatewayLaunchdLabel = "ai.openclaw.gateway"
|
||||
var gatewayLaunchdLabel: String {
|
||||
AppProfile.current.gatewayLaunchAgentLabel
|
||||
}
|
||||
|
||||
let nodeLaunchdLabel = "ai.openclaw.node"
|
||||
let onboardingVersionKey = "openclaw.onboardingVersion"
|
||||
let onboardingSeenKey = "openclaw.onboardingSeen"
|
||||
@@ -41,7 +44,7 @@ let quickChatEnabledKey = "openclaw.quickChatEnabled"
|
||||
let cameraEnabledKey = "openclaw.cameraEnabled"
|
||||
let computerControlEnabledKey = "openclaw.computerControlEnabled"
|
||||
|
||||
func isComputerControlEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
func isComputerControlEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool {
|
||||
// object(forKey:) preserves an explicit false; bool(forKey:) would conflate it with an unset default.
|
||||
defaults.object(forKey: computerControlEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
}
|
||||
|
||||
private func persistedLinkBrowserWidth() -> CGFloat? {
|
||||
guard let number = UserDefaults.standard.object(
|
||||
guard let number = AppDefaults.standard.object(
|
||||
forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey) as? NSNumber
|
||||
else { return nil }
|
||||
let width = CGFloat(number.doubleValue)
|
||||
@@ -492,7 +492,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
guard !self.linkBrowserItem.isCollapsed else { return }
|
||||
let width = self.linkBrowser.frame.width
|
||||
guard width.isFinite, width >= DashboardWindowLayout.linkBrowserMinWidth else { return }
|
||||
UserDefaults.standard.set(Double(width), forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey)
|
||||
AppDefaults.standard.set(Double(width), forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey)
|
||||
}
|
||||
|
||||
private func requestBrowserProfileImportOfferIfNeeded() {
|
||||
|
||||
@@ -159,12 +159,12 @@ enum DebugActions {
|
||||
}
|
||||
|
||||
static var verboseLoggingEnabledMain: Bool {
|
||||
UserDefaults.standard.bool(forKey: self.verboseDefaultsKey)
|
||||
AppDefaults.standard.bool(forKey: self.verboseDefaultsKey)
|
||||
}
|
||||
|
||||
static func toggleVerboseLoggingMain() async -> Bool {
|
||||
let newValue = !self.verboseLoggingEnabledMain
|
||||
UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey)
|
||||
AppDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey)
|
||||
_ = try? await ControlChannel.shared.request(
|
||||
method: "system-event",
|
||||
params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")])
|
||||
@@ -177,15 +177,25 @@ enum DebugActions {
|
||||
let task = Process()
|
||||
// Relaunch shortly after this instance exits so we get a true restart even in debug.
|
||||
task.launchPath = "/bin/sh"
|
||||
task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path]
|
||||
if let profile = AppProfile.current.name {
|
||||
task.arguments = [
|
||||
"-c",
|
||||
"sleep 0.2; open -n --env OPENCLAW_PROFILE=\"$2\" \"$1\"",
|
||||
"_",
|
||||
url.path,
|
||||
profile,
|
||||
]
|
||||
} else {
|
||||
task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path]
|
||||
}
|
||||
try? task.run()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func restartOnboarding() {
|
||||
UserDefaults.standard.set(false, forKey: self.onboardingSeenKey)
|
||||
UserDefaults.standard.set(0, forKey: onboardingVersionKey)
|
||||
AppDefaults.standard.set(false, forKey: self.onboardingSeenKey)
|
||||
AppDefaults.standard.set(0, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = false
|
||||
OnboardingController.shared.restart()
|
||||
}
|
||||
|
||||
@@ -511,13 +511,19 @@ struct DebugSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startOpenClaw()
|
||||
} label: {
|
||||
Label("Restart OpenClaw", systemImage: "arrow.counterclockwise")
|
||||
if AppProfile.current.isActive {
|
||||
Text("Login-agent restart is unavailable under a profile.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Button {
|
||||
LaunchdManager.startOpenClaw()
|
||||
} label: {
|
||||
Label("Restart OpenClaw", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
|
||||
@@ -150,7 +150,7 @@ final class DeepLinkHandler {
|
||||
}
|
||||
|
||||
private static func expectedKey() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
let defaults = AppDefaults.standard
|
||||
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
|
||||
return key
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ actor DiagnosticsFileLog {
|
||||
}
|
||||
|
||||
nonisolated static func isEnabled() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
AppDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
}
|
||||
|
||||
nonisolated static func logDirectoryURL() -> URL {
|
||||
|
||||
@@ -28,7 +28,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
|
||||
return
|
||||
}
|
||||
|
||||
let userWantsDockHidden = (UserDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false
|
||||
let userWantsDockHidden = (AppDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false
|
||||
let visibleWindows = NSApp?.windows.filter { window in
|
||||
window.isVisible &&
|
||||
window.frame.width > 1 &&
|
||||
@@ -107,7 +107,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
|
||||
@objc
|
||||
private func dockPreferenceChanged(_ notification: Notification) {
|
||||
guard let userDefaults = notification.object as? UserDefaults,
|
||||
userDefaults == UserDefaults.standard
|
||||
userDefaults == AppDefaults.standard
|
||||
else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -146,7 +146,34 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func socketPath() -> String {
|
||||
self.stateDirURL().appendingPathComponent("exec-approvals.sock").path
|
||||
self.socketPath(
|
||||
stateDirectoryURL: self.stateDirURL(),
|
||||
profileActive: AppProfile.current.isActive)
|
||||
}
|
||||
|
||||
static func socketPath(stateDirectoryURL: URL, profileActive: Bool) -> String {
|
||||
let canonical = stateDirectoryURL.appendingPathComponent("exec-approvals.sock").path
|
||||
let maximumLength = MemoryLayout.size(ofValue: sockaddr_un().sun_path)
|
||||
guard canonical.utf8.count >= maximumLength, profileActive else {
|
||||
return canonical
|
||||
}
|
||||
let digest = SHA256.hash(data: Data(canonical.utf8))
|
||||
.prefix(8)
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
return "/tmp/openclaw-\(geteuid())/exec-approvals-\(digest).sock"
|
||||
}
|
||||
|
||||
static func resolvedPersistedSocketPath(
|
||||
existing: String?,
|
||||
stateDirectoryURL: URL,
|
||||
computed: String) -> String
|
||||
{
|
||||
let existing = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let oldCanonical = stateDirectoryURL.appendingPathComponent("exec-approvals.sock").path
|
||||
return existing.isEmpty || (existing == oldCanonical && computed != oldCanonical)
|
||||
? computed
|
||||
: existing
|
||||
}
|
||||
|
||||
private static func homeURL() -> URL {
|
||||
@@ -163,7 +190,7 @@ enum ExecApprovalsStore {
|
||||
return scopedStateDirectoryURL
|
||||
}
|
||||
guard let configured = OpenClawEnv.path("OPENCLAW_STATE_DIR") else {
|
||||
return self.homeURL().appendingPathComponent(".openclaw", isDirectory: true)
|
||||
return AppProfile.current.stateDirectoryURL(homeDirectory: self.homeURL())
|
||||
}
|
||||
let home = self.homeURL().path
|
||||
let expanded: String = if configured == "~" {
|
||||
@@ -293,10 +320,12 @@ enum ExecApprovalsStore {
|
||||
if file.socket == nil {
|
||||
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
|
||||
}
|
||||
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if path.isEmpty {
|
||||
file.socket?.path = self.socketPath()
|
||||
}
|
||||
let existingSocketPath = file.socket?.path
|
||||
let resolvedSocketPath = self.resolvedPersistedSocketPath(
|
||||
existing: existingSocketPath,
|
||||
stateDirectoryURL: self.stateDirURL(),
|
||||
computed: self.socketPath())
|
||||
file.socket?.path = resolvedSocketPath
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if token.isEmpty {
|
||||
file.socket?.token = self.generateToken()
|
||||
|
||||
@@ -66,8 +66,17 @@ struct GeneralStatusPresentation: Equatable {
|
||||
static func resolve(
|
||||
mode: AppState.ConnectionMode,
|
||||
isPaused: Bool,
|
||||
controlState: ControlChannel.ConnectionState) -> Self
|
||||
controlState: ControlChannel.ConnectionState,
|
||||
localFailure: String? = nil) -> Self
|
||||
{
|
||||
if mode == .local, let localFailure {
|
||||
return Self(
|
||||
title: String(localized: "OpenClaw needs attention"),
|
||||
subtitle: localFailure,
|
||||
symbolName: "exclamationmark.triangle.fill",
|
||||
tone: .attention,
|
||||
showsConnectionAction: true)
|
||||
}
|
||||
if isPaused {
|
||||
return Self(
|
||||
title: String(localized: "OpenClaw paused"),
|
||||
|
||||
@@ -16,10 +16,14 @@ enum GatewayActivationBindingKeyStore {
|
||||
// keychain password on every read. DEBUG is a config heuristic, not a
|
||||
// signing check — same accepted tradeoff as MacGatewayProfileStore.service.
|
||||
#if DEBUG
|
||||
private static let service = "ai.openclaw.onboarding-route-binding.debug"
|
||||
private static let baseService = "ai.openclaw.onboarding-route-binding.debug"
|
||||
#else
|
||||
private static let service = "ai.openclaw.onboarding-route-binding"
|
||||
private static let baseService = "ai.openclaw.onboarding-route-binding"
|
||||
#endif
|
||||
static var service: String {
|
||||
AppProfile.current.keychainService(base: self.baseService)
|
||||
}
|
||||
|
||||
private static let account = "credential-binding-v1"
|
||||
private static let byteCount = 32
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ enum GatewayDiscoveryPreferences {
|
||||
private static let preferredRouteBindingKey = "gateway.preferredStableIDRouteBinding.v1"
|
||||
|
||||
static func preferredStableID() -> String? {
|
||||
let defaults = UserDefaults.standard
|
||||
let defaults = AppDefaults.standard
|
||||
let raw = defaults.string(forKey: self.preferredStableIDKey)
|
||||
?? defaults.string(forKey: self.legacyPreferredStableIDKey)
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -16,19 +16,19 @@ enum GatewayDiscoveryPreferences {
|
||||
static func setPreferredStableID(_ stableID: String?) {
|
||||
// A caller without an endpoint binding cannot prove that a prior binding
|
||||
// belongs to this id. The bound overload installs a fresh one below.
|
||||
UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
|
||||
AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
|
||||
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
|
||||
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
|
||||
AppDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
|
||||
AppDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
|
||||
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
|
||||
AppDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
|
||||
AppDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func preferredRouteBinding() -> String? {
|
||||
let raw = UserDefaults.standard.string(forKey: self.preferredRouteBindingKey)
|
||||
let raw = AppDefaults.standard.string(forKey: self.preferredRouteBindingKey)
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed?.isEmpty == false ? trimmed : nil
|
||||
}
|
||||
@@ -38,10 +38,10 @@ enum GatewayDiscoveryPreferences {
|
||||
guard self.preferredStableID() != nil,
|
||||
let routeBinding = self.normalized(routeBinding)
|
||||
else {
|
||||
UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
|
||||
AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
|
||||
return
|
||||
}
|
||||
UserDefaults.standard.set(routeBinding, forKey: self.preferredRouteBindingKey)
|
||||
AppDefaults.standard.set(routeBinding, forKey: self.preferredRouteBindingKey)
|
||||
}
|
||||
|
||||
/// Discovery ids name one concrete Gateway. Persist the non-secret fallback
|
||||
@@ -97,7 +97,7 @@ enum GatewayDiscoveryPreferences {
|
||||
@discardableResult
|
||||
static func clearPreferredStableIDIfRouteBindingMismatch(_ currentRouteBinding: String?) -> Bool {
|
||||
guard self.preferredStableID() != nil else {
|
||||
UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
|
||||
AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
|
||||
return false
|
||||
}
|
||||
guard let stored = self.preferredRouteBinding(),
|
||||
|
||||
@@ -86,6 +86,7 @@ actor GatewayEndpointStore {
|
||||
let token: @Sendable () -> String?
|
||||
let password: @Sendable () -> String?
|
||||
let localPort: @Sendable () -> Int
|
||||
let localUnavailableReason: @Sendable () -> String?
|
||||
let remoteRouteIfRunning: @Sendable () async -> RemoteTunnelManager.Route?
|
||||
let remoteRouteIsCurrent: @Sendable (RemoteTunnelManager.Route) async -> Bool
|
||||
let canStartRemoteTunnel: @Sendable () -> Bool
|
||||
@@ -113,6 +114,7 @@ actor GatewayEndpointStore {
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
localPort: { GatewayEnvironment.gatewayPort() },
|
||||
localUnavailableReason: { GatewayEnvironment.profileGatewayPortConflict() },
|
||||
remoteRouteIfRunning: { await RemoteTunnelManager.shared.controlTunnelRouteIfRunning() },
|
||||
remoteRouteIsCurrent: { await RemoteTunnelManager.shared.isCurrentRoute($0) },
|
||||
canStartRemoteTunnel: { GatewayEndpointStore.primaryAppLaunchAdmitted.withValue { $0 } },
|
||||
@@ -364,19 +366,21 @@ actor GatewayEndpointStore {
|
||||
private var endpointRevision: UInt64 = 0
|
||||
private var resolutionGeneration: UInt64 = 0
|
||||
private var activeSource: SourceSnapshot?
|
||||
private var localUnavailableReason: String?
|
||||
|
||||
init(deps: Deps = .live) {
|
||||
self.deps = deps
|
||||
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let modeRaw = AppDefaults.standard.string(forKey: connectionModeKey)
|
||||
let initialMode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = UserDefaults.standard.bool(forKey: "openclaw.onboardingSeen")
|
||||
let seen = AppDefaults.standard.bool(forKey: "openclaw.onboardingSeen")
|
||||
initialMode = seen ? .local : .unconfigured
|
||||
}
|
||||
|
||||
let port = deps.localPort()
|
||||
self.localUnavailableReason = deps.localUnavailableReason()
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: root,
|
||||
@@ -398,6 +402,10 @@ actor GatewayEndpointStore {
|
||||
remoteTarget: "")
|
||||
switch initialMode {
|
||||
case .local:
|
||||
if let reason = self.localUnavailableReason {
|
||||
self.state = .unavailable(mode: .local, reason: reason)
|
||||
return
|
||||
}
|
||||
let url = URL(string: "\(scheme)://\(host):\(port)")!
|
||||
self.endpointRevision = 1
|
||||
self.state = .ready(
|
||||
@@ -440,6 +448,13 @@ actor GatewayEndpointStore {
|
||||
self.state
|
||||
}
|
||||
|
||||
func setLocalUnavailableReason(_ reason: String?) {
|
||||
self.localUnavailableReason = reason
|
||||
if let reason {
|
||||
self.setState(.unavailable(mode: .local, reason: reason))
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
_ = await self.refreshIfCurrent()
|
||||
}
|
||||
@@ -506,6 +521,10 @@ actor GatewayEndpointStore {
|
||||
case .local:
|
||||
self.cancelRemoteEnsure()
|
||||
guard await self.sourceIsCurrent(source, generation: generation) else { return }
|
||||
if let reason = self.localUnavailableReason {
|
||||
self.setState(.unavailable(mode: .local, reason: reason))
|
||||
return
|
||||
}
|
||||
let url = URL(string: "\(source.scheme)://\(source.localHost):\(source.localPort)")!
|
||||
self.publishReadyEndpoint(source: source, url: url)
|
||||
case .remote:
|
||||
@@ -914,6 +933,7 @@ extension GatewayEndpointStore {
|
||||
generationIsCurrent: { generation in
|
||||
AppStateStore.shared.gatewayRoutingGeneration == generation
|
||||
},
|
||||
profile: .current,
|
||||
beforeConfigRead: {})
|
||||
}
|
||||
|
||||
@@ -933,6 +953,7 @@ extension GatewayEndpointStore {
|
||||
private static func liveSourceSnapshot(
|
||||
appSnapshot: @escaping @MainActor @Sendable () -> LiveAppSnapshot,
|
||||
generationIsCurrent: @escaping @MainActor @Sendable (UInt64) -> Bool,
|
||||
profile: AppProfile,
|
||||
beforeConfigRead: @escaping @Sendable () async -> Void) async -> SourceSnapshot
|
||||
{
|
||||
// Capture MainActor-owned selection facts before reading config. The
|
||||
@@ -994,7 +1015,7 @@ extension GatewayEndpointStore {
|
||||
env: env,
|
||||
launchdSnapshot: launchdSnapshot),
|
||||
deviceAuthGatewayID: deviceAuthGatewayID,
|
||||
localPort: self.resolveGatewayPort(root: root, env: env),
|
||||
localPort: self.resolveGatewayPort(root: root, env: env, profile: profile),
|
||||
localHost: self.resolveLocalGatewayHost(
|
||||
bindMode: bindMode,
|
||||
customBindHost: customBindHost,
|
||||
@@ -1039,16 +1060,11 @@ extension GatewayEndpointStore {
|
||||
private static func resolveGatewayPort(
|
||||
root: [String: Any],
|
||||
env: [String: String],
|
||||
defaults: UserDefaults = .standard) -> Int
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
profile: AppProfile) -> Int
|
||||
{
|
||||
if let raw = env["OPENCLAW_GATEWAY_PORT"],
|
||||
let port = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
port > 0
|
||||
{
|
||||
return port
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any] {
|
||||
let port: Int? = switch gateway["port"] {
|
||||
let configPort: Int? = if let gateway = root["gateway"] as? [String: Any] {
|
||||
switch gateway["port"] {
|
||||
case let value as Int:
|
||||
value
|
||||
case let value as NSNumber:
|
||||
@@ -1058,12 +1074,14 @@ extension GatewayEndpointStore {
|
||||
default:
|
||||
nil
|
||||
}
|
||||
if let port, port > 0 {
|
||||
return port
|
||||
}
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let stored = defaults.integer(forKey: "gatewayPort")
|
||||
return stored > 0 ? stored : 18789
|
||||
return GatewayEnvironment.resolvedGatewayPort(
|
||||
environment: env,
|
||||
configPort: configPort,
|
||||
storedPort: defaults.integer(forKey: "gatewayPort"),
|
||||
profile: profile)
|
||||
}
|
||||
|
||||
private static func resolveGatewayBindMode(
|
||||
@@ -1249,6 +1267,7 @@ extension GatewayEndpointStore {
|
||||
@MainActor
|
||||
static func _testLiveSourceSnapshot(
|
||||
state: AppState,
|
||||
profile: AppProfile = .current,
|
||||
beforeConfigRead: @escaping @Sendable () async -> Void) async -> SourceSnapshot
|
||||
{
|
||||
await self.liveSourceSnapshot(
|
||||
@@ -1262,6 +1281,7 @@ extension GatewayEndpointStore {
|
||||
generationIsCurrent: { generation in
|
||||
state.gatewayRoutingGeneration == generation
|
||||
},
|
||||
profile: profile,
|
||||
beforeConfigRead: beforeConfigRead)
|
||||
}
|
||||
|
||||
|
||||
@@ -108,17 +108,42 @@ enum GatewayEnvironment {
|
||||
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.env")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let profilePortReservation: ProfileGatewayPortReservation = .acquire(
|
||||
profile: .current,
|
||||
port: GatewayEnvironment.selectedGatewayPort())
|
||||
|
||||
static func gatewayPort() -> Int {
|
||||
if let raw = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_PORT"] {
|
||||
guard AppProfile.current.isActive else { return self.selectedGatewayPort() }
|
||||
return self.profilePortReservation.port
|
||||
}
|
||||
|
||||
static func profileGatewayPortConflict() -> String? {
|
||||
guard AppProfile.current.isActive else { return nil }
|
||||
return self.profilePortReservation.conflict
|
||||
}
|
||||
|
||||
private static func selectedGatewayPort() -> Int {
|
||||
self.resolvedGatewayPort(
|
||||
environment: ProcessInfo.processInfo.environment,
|
||||
configPort: OpenClawConfigFile.gatewayPort(),
|
||||
storedPort: AppDefaults.standard.integer(forKey: "gatewayPort"),
|
||||
profile: .current)
|
||||
}
|
||||
|
||||
static func resolvedGatewayPort(
|
||||
environment: [String: String],
|
||||
configPort: Int?,
|
||||
storedPort: Int,
|
||||
profile: AppProfile) -> Int
|
||||
{
|
||||
if let raw = environment["OPENCLAW_GATEWAY_PORT"] {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let parsed = Int(trimmed), parsed > 0 { return parsed }
|
||||
}
|
||||
if let configPort = OpenClawConfigFile.gatewayPort(), configPort > 0 {
|
||||
if let configPort, configPort > 0 {
|
||||
return configPort
|
||||
}
|
||||
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
|
||||
return stored > 0 ? stored : 18789
|
||||
return storedPort > 0 ? storedPort : profile.defaultGatewayPort
|
||||
}
|
||||
|
||||
static func expectedGatewayVersion() -> Semver? {
|
||||
@@ -238,6 +263,7 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func resolveGatewayCommand(
|
||||
profile: AppProfile = .current,
|
||||
searchPathsProvider: @Sendable () async -> [String] = CommandResolver.preferredPathsAsync) async
|
||||
-> GatewayCommandResolution
|
||||
{
|
||||
@@ -261,16 +287,26 @@ enum GatewayEnvironment {
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
switch environment.commandSource {
|
||||
case let .executable(gatewayBin):
|
||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
let cmd = self.gatewayCommand(prefix: [gatewayBin], port: port, bind: bind, profile: profile)
|
||||
return GatewayCommandResolution(status: environment.status, command: cmd)
|
||||
case let .project(runtime, entrypoint):
|
||||
let cmd = [runtime.path, entrypoint, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
let cmd = self.gatewayCommand(
|
||||
prefix: [runtime.path, entrypoint],
|
||||
port: port,
|
||||
bind: bind,
|
||||
profile: profile)
|
||||
return GatewayCommandResolution(status: environment.status, command: cmd)
|
||||
case nil:
|
||||
return GatewayCommandResolution(status: environment.status, command: nil)
|
||||
}
|
||||
}
|
||||
|
||||
static func gatewayCommand(prefix: [String], port: Int, bind: String, profile: AppProfile) -> [String] {
|
||||
profile.localCLICommand(
|
||||
prefix: prefix,
|
||||
arguments: ["gateway", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
|
||||
private static func preferredGatewayBind() -> String? {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,7 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd")
|
||||
private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent"
|
||||
private static let disableLaunchAgentMarker = "disable-launchagent"
|
||||
/// A first-run daemon command may wait behind state integrity checks and the shared startup-
|
||||
/// migration lease. Keep the app from killing healthy migration work before it can finish.
|
||||
static let startupMigrationTolerance: TimeInterval = 120
|
||||
@@ -18,13 +18,75 @@ enum GatewayLaunchAgentManager {
|
||||
return testingDisableLaunchAgentMarkerURL
|
||||
}
|
||||
#endif
|
||||
return FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
let root = AppProfile.current.isActive
|
||||
? OpenClawPaths.stateDirURL
|
||||
: FileManager().homeDirectoryForCurrentUser.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
return root.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
self.plistURL(
|
||||
homeDirectory: FileManager().homeDirectoryForCurrentUser,
|
||||
profile: .current)
|
||||
}
|
||||
|
||||
static func plistURL(homeDirectory: URL, profile: AppProfile) -> URL {
|
||||
homeDirectory.appendingPathComponent(
|
||||
"Library/LaunchAgents/\(profile.gatewayLaunchAgentLabel).plist")
|
||||
}
|
||||
|
||||
static func conflictingProfileClaimOwner(
|
||||
port: Int,
|
||||
excludingLabel: String,
|
||||
homeDirectory: URL) -> String?
|
||||
{
|
||||
let directory = homeDirectory.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
|
||||
guard FileManager.default.fileExists(atPath: directory.path) else { return nil }
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil)
|
||||
else {
|
||||
return "installed profile Gateway claims cannot be inspected"
|
||||
}
|
||||
for url in entries {
|
||||
guard url.pathExtension == "plist" else { continue }
|
||||
let label = url.deletingPathExtension().lastPathComponent
|
||||
guard label != excludingLabel,
|
||||
let profile = self.profile(forLaunchAgentLabel: label)
|
||||
else { continue }
|
||||
let owner = profile.name ?? "default"
|
||||
let artifacts = self.generatedEnvironmentArtifacts(
|
||||
directory: profile.stateDirectoryURL(homeDirectory: homeDirectory)
|
||||
.appendingPathComponent("service-env", isDirectory: true),
|
||||
profile: profile)
|
||||
guard let snapshot = LaunchAgentPlist.snapshot(
|
||||
url: url,
|
||||
generatedEnvironmentFileURL: artifacts.environment,
|
||||
generatedEnvironmentWrapperURL: artifacts.wrapper),
|
||||
self.isCanonicalGatewayClaim(snapshot)
|
||||
else { continue }
|
||||
guard let claimedPort = snapshot.port else {
|
||||
return "profile \"\(owner)\" has an unreadable Gateway reservation"
|
||||
}
|
||||
if claimedPort == port { return "profile \"\(owner)\" already reserves it" }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func profile(forLaunchAgentLabel label: String) -> AppProfile? {
|
||||
let base = AppProfile(environment: [:])
|
||||
if label == base.gatewayLaunchAgentLabel { return base }
|
||||
let prefix = "ai.openclaw."
|
||||
guard label.hasPrefix(prefix) else { return nil }
|
||||
let name = String(label.dropFirst(prefix.count))
|
||||
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": name])
|
||||
return profile.name == name && profile.gatewayLaunchAgentLabel == label ? profile : nil
|
||||
}
|
||||
|
||||
private static func isCanonicalGatewayClaim(_ snapshot: LaunchAgentPlistSnapshot) -> Bool {
|
||||
snapshot.environment["OPENCLAW_SERVICE_MARKER"] == "openclaw" &&
|
||||
snapshot.environment["OPENCLAW_SERVICE_KIND"] == "gateway" &&
|
||||
snapshot.programArguments.contains("gateway")
|
||||
}
|
||||
|
||||
private static var generatedEnvironmentDirectoryURL: URL {
|
||||
@@ -138,11 +200,20 @@ enum GatewayLaunchAgentManager {
|
||||
|
||||
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
||||
let directory = self.generatedEnvironmentDirectoryURL
|
||||
let artifacts = self.generatedEnvironmentArtifacts(directory: directory, profile: .current)
|
||||
return LaunchAgentPlist.snapshot(
|
||||
url: self.plistURL,
|
||||
generatedEnvironmentFileURL: directory.appendingPathComponent("\(gatewayLaunchdLabel).env"),
|
||||
generatedEnvironmentWrapperURL: directory.appendingPathComponent(
|
||||
"\(gatewayLaunchdLabel)-env-wrapper.sh"))
|
||||
generatedEnvironmentFileURL: artifacts.environment,
|
||||
generatedEnvironmentWrapperURL: artifacts.wrapper)
|
||||
}
|
||||
|
||||
static func generatedEnvironmentArtifacts(
|
||||
directory: URL,
|
||||
profile: AppProfile) -> (environment: URL, wrapper: URL)
|
||||
{
|
||||
(
|
||||
directory.appendingPathComponent("\(profile.gatewayLaunchAgentLabel).env"),
|
||||
directory.appendingPathComponent("\(profile.gatewayLaunchAgentLabel)-env-wrapper.sh"))
|
||||
}
|
||||
|
||||
/// Empty means no Gateway LaunchAgent. Nil preserves an unreadable
|
||||
|
||||
@@ -113,6 +113,7 @@ final class GatewayProcessManager {
|
||||
private var launchAgentReadinessRevision: UInt64 = 0
|
||||
private var launchAgentInstallGeneration: UInt64?
|
||||
private var launchAgentFreshInstallGeneration: UInt64?
|
||||
private var profilePortConflict: String?
|
||||
private var lastObservedGatewayPID: Int32?
|
||||
/// Async readiness audits may outlive stop/restart. Only the current generation may publish
|
||||
/// their failure state or retain a PID for a later repair.
|
||||
@@ -146,6 +147,16 @@ final class GatewayProcessManager {
|
||||
self.logger.info("gateway process skipped: remote mode active")
|
||||
return
|
||||
}
|
||||
if active, self.profilePortConflict != nil {
|
||||
self.profilePortConflict = nil
|
||||
Task { await GatewayEndpointStore.shared.setLocalUnavailableReason(nil) }
|
||||
}
|
||||
if active, let conflict = GatewayEnvironment.profileGatewayPortConflict() {
|
||||
self.desiredActive = false
|
||||
self.recordProfilePortConflict(conflict)
|
||||
Task { await GatewayEndpointStore.shared.setLocalUnavailableReason(conflict) }
|
||||
return
|
||||
}
|
||||
self.logger.debug("gateway active requested active=\(active)")
|
||||
self.desiredActive = active
|
||||
self.refreshEnvironmentStatus()
|
||||
@@ -159,6 +170,7 @@ final class GatewayProcessManager {
|
||||
func ensureLaunchAgentEnabledIfNeeded() async -> Bool {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return false }
|
||||
guard self.desiredActive else { return false }
|
||||
guard self.profilePortConflict == nil else { return false }
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
||||
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
||||
@@ -522,6 +534,11 @@ final class GatewayProcessManager {
|
||||
guard self.isCurrentGatewayStart(startGeneration) else { return true }
|
||||
let instanceText = instance.map { self.describe(instance: $0) }
|
||||
let hasListener = instance != nil
|
||||
if hasListener,
|
||||
await !(self.profileOwnsGateway(instance, port: port))
|
||||
{
|
||||
return true
|
||||
}
|
||||
|
||||
let attemptAttach = {
|
||||
try await self.probeGatewayHealth(timeoutMs: 2000)
|
||||
@@ -534,6 +551,9 @@ final class GatewayProcessManager {
|
||||
guard self.isCurrentGatewayStart(startGeneration) else { return true }
|
||||
let attachedInstance = await PortGuardian.shared.describe(port: port)
|
||||
guard self.isCurrentGatewayStart(startGeneration) else { return true }
|
||||
if await !(self.profileOwnsGateway(attachedInstance, port: port)) {
|
||||
return true
|
||||
}
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let attachedInstanceText = attachedInstance.map { self.describe(instance: $0) }
|
||||
let details = self.describe(details: attachedInstanceText, port: port, snap: snap)
|
||||
@@ -581,6 +601,45 @@ final class GatewayProcessManager {
|
||||
return false
|
||||
}
|
||||
|
||||
static func profileAllowsExistingGatewayAttachment(
|
||||
profile: AppProfile,
|
||||
listenerPID: Int32?,
|
||||
managedServicePID: Int32?) -> Bool
|
||||
{
|
||||
guard profile.isActive else { return true }
|
||||
guard let listenerPID, let managedServicePID else { return false }
|
||||
return listenerPID == managedServicePID
|
||||
}
|
||||
|
||||
private func profileOwnsGateway(_ instance: PortGuardian.Descriptor?, port: Int) async -> Bool {
|
||||
guard AppProfile.current.isActive else { return true }
|
||||
let managedPID = await GatewayLaunchAgentManager.runningGatewayPID()
|
||||
guard Self.profileAllowsExistingGatewayAttachment(
|
||||
profile: .current,
|
||||
listenerPID: instance?.pid,
|
||||
managedServicePID: managedPID)
|
||||
else {
|
||||
await self.failProfilePortOwnership(port: port)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func failProfilePortOwnership(port: Int) async {
|
||||
let message = "Gateway port \(port) is already owned by another process or OpenClaw profile. " +
|
||||
"Set gateway.port to a free port for profile \(AppProfile.current.name ?? "named")."
|
||||
self.recordProfilePortConflict(message)
|
||||
await GatewayEndpointStore.shared.setLocalUnavailableReason(message)
|
||||
}
|
||||
|
||||
private func recordProfilePortConflict(_ message: String) {
|
||||
self.profilePortConflict = message
|
||||
self.status = .failed(message)
|
||||
self.lastFailureReason = message
|
||||
self.appendLog("[gateway] \(message)\n")
|
||||
self.logger.error("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
@@ -737,6 +796,7 @@ extension GatewayProcessManager {
|
||||
_ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs))
|
||||
guard !Task.isCancelled else { return }
|
||||
let instance = await PortGuardian.shared.describe(port: context.port)
|
||||
guard await self.profileOwnsGateway(instance, port: context.port) else { return }
|
||||
guard self.publishLaunchdGatewayReady(
|
||||
instance: instance,
|
||||
context: context,
|
||||
@@ -1000,6 +1060,7 @@ extension GatewayProcessManager {
|
||||
_ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs))
|
||||
guard !Task.isCancelled else { return false }
|
||||
let instance = await PortGuardian.shared.describe(port: readinessPort)
|
||||
guard await self.profileOwnsGateway(instance, port: readinessPort) else { return false }
|
||||
return self.publishGatewayReadinessSuccess(
|
||||
instance: instance,
|
||||
startGeneration: startGeneration,
|
||||
|
||||
@@ -88,11 +88,11 @@ struct GeneralSettings: View {
|
||||
SettingsCardGroup("App") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Launch at login",
|
||||
subtitle: self.state.bundleLocationAllowsPersistentIntegration
|
||||
? "Automatically start OpenClaw after you sign in."
|
||||
: "Move OpenClaw to Applications before enabling launch at login.",
|
||||
subtitle: .verbatim(
|
||||
self
|
||||
.launchAtLoginPresentation.subtitle),
|
||||
binding: self.$state.launchAtLogin)
|
||||
.disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin)
|
||||
.disabled(self.launchAtLoginPresentation.isDisabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Show Dock icon",
|
||||
@@ -221,7 +221,8 @@ struct GeneralSettings: View {
|
||||
let presentation = GeneralStatusPresentation.resolve(
|
||||
mode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused,
|
||||
controlState: ControlChannel.shared.state)
|
||||
controlState: ControlChannel.shared.state,
|
||||
localFailure: self.gatewayManager.lastFailureReason)
|
||||
|
||||
return HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
@@ -371,6 +372,7 @@ struct GeneralSettings: View {
|
||||
Spacer(minLength: 18)
|
||||
|
||||
if ControlChannel.shared.state == .connected,
|
||||
self.localGatewayFailure == nil,
|
||||
let ping = ControlChannel.shared.lastPingMs
|
||||
{
|
||||
Text("\(Int(ping)) ms")
|
||||
@@ -399,9 +401,10 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
private var connectionStatusTint: Color {
|
||||
if self.localGatewayFailure != nil { return .red }
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: .green
|
||||
case .connecting, .disconnected, .degraded: .orange
|
||||
case .connected: return .green
|
||||
case .connecting, .disconnected, .degraded: return .orange
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +417,7 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
private var connectionStatusSubtitle: String {
|
||||
if let failure = self.localGatewayFailure { return failure }
|
||||
switch self.state.connectionMode {
|
||||
case .local:
|
||||
return "OpenClaw starts and monitors the Gateway on this Mac."
|
||||
@@ -429,6 +433,10 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var localGatewayFailure: String? {
|
||||
self.state.connectionMode == .local ? self.gatewayManager.lastFailureReason : nil
|
||||
}
|
||||
|
||||
private var gatewayModeGroup: some View {
|
||||
SettingsCardGroup("Gateway") {
|
||||
SettingsCardRow(
|
||||
@@ -805,10 +813,11 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
private var gatewayStatusColor: Color {
|
||||
if self.localGatewayFailure != nil { return .red }
|
||||
switch self.gatewayStatus.kind {
|
||||
case .ok: .green
|
||||
case .checking: .secondary
|
||||
case .missingNode, .missingGateway, .incompatible, .error: .orange
|
||||
case .ok: return .green
|
||||
case .checking: return .secondary
|
||||
case .missingNode, .missingGateway, .incompatible, .error: return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -887,12 +896,41 @@ extension GeneralSettings {
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
private var launchAtLoginPresentation: LaunchAtLoginPresentation {
|
||||
.resolve(
|
||||
profile: .current,
|
||||
bundleLocationAllowsPersistentIntegration: self.state.bundleLocationAllowsPersistentIntegration,
|
||||
isEnabled: self.state.launchAtLogin)
|
||||
}
|
||||
|
||||
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
|
||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID, state: self.state)
|
||||
}
|
||||
}
|
||||
|
||||
struct LaunchAtLoginPresentation: Equatable {
|
||||
let subtitle: String
|
||||
let isDisabled: Bool
|
||||
|
||||
static func resolve(
|
||||
profile: AppProfile,
|
||||
bundleLocationAllowsPersistentIntegration: Bool,
|
||||
isEnabled: Bool) -> Self
|
||||
{
|
||||
if profile.isActive {
|
||||
return Self(
|
||||
subtitle: String(localized: "Launch at login is unavailable while an app profile is active."),
|
||||
isDisabled: true)
|
||||
}
|
||||
return Self(
|
||||
subtitle: bundleLocationAllowsPersistentIntegration
|
||||
? String(localized: "Automatically start OpenClaw after you sign in.")
|
||||
: String(localized: "Move OpenClaw to Applications before enabling launch at login."),
|
||||
isDisabled: !bundleLocationAllowsPersistentIntegration && !isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct GeneralSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum LaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "app.login-agent")
|
||||
private static var plistURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/ai.openclaw.mac.plist")
|
||||
}
|
||||
|
||||
static func status() async -> Bool {
|
||||
static func status(profile: AppProfile = .current) async -> Bool {
|
||||
if profile.isActive {
|
||||
self.logger.info("login-agent status skipped (unavailable under app profile)")
|
||||
return false
|
||||
}
|
||||
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
|
||||
return await self.isLoaded()
|
||||
}
|
||||
@@ -20,9 +26,14 @@ enum LaunchAgentManager {
|
||||
static func set(
|
||||
enabled: Bool,
|
||||
bundlePath: String,
|
||||
profile: AppProfile = .current,
|
||||
loaded: Bool? = nil,
|
||||
writePlist: ((String) -> Void)? = nil) async -> Bool
|
||||
{
|
||||
if profile.isActive {
|
||||
self.logger.info("login-agent change skipped (unavailable under app profile)")
|
||||
return false
|
||||
}
|
||||
if enabled {
|
||||
let persist = writePlist ?? { self.writePlist(bundlePath: $0) }
|
||||
persist(bundlePath)
|
||||
@@ -107,6 +118,9 @@ enum LaunchAgentManager {
|
||||
|
||||
@discardableResult
|
||||
private static func runLaunchctl(_ args: [String]) async -> Int32 {
|
||||
#if DEBUG
|
||||
self.testingLaunchctlCalls.append(args)
|
||||
#endif
|
||||
do {
|
||||
return try await BoundedProcess.run(
|
||||
path: "/bin/launchctl",
|
||||
@@ -117,3 +131,17 @@ enum LaunchAgentManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension LaunchAgentManager {
|
||||
private nonisolated(unsafe) static var testingLaunchctlCalls: [[String]] = []
|
||||
|
||||
static func _testResetLaunchctlCalls() {
|
||||
self.testingLaunchctlCalls = []
|
||||
}
|
||||
|
||||
static func _testLaunchctlCallSnapshot() -> [[String]] {
|
||||
self.testingLaunchctlCalls
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum LaunchdManager {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "app.login-agent")
|
||||
private static func runLaunchctl(_ args: [String]) {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
@@ -9,6 +11,10 @@ enum LaunchdManager {
|
||||
}
|
||||
|
||||
static func startOpenClaw() {
|
||||
guard !AppProfile.current.isActive else {
|
||||
self.logger.info("login-agent restart skipped (unavailable under app profile)")
|
||||
return
|
||||
}
|
||||
let userTarget = "gui/\(getuid())/\(launchdLabel)"
|
||||
self.runLaunchctl(["kickstart", "-k", userTarget])
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ enum LogLocator {
|
||||
}
|
||||
|
||||
private static var gatewayLog: URL {
|
||||
logDir.appendingPathComponent("openclaw-gateway.log")
|
||||
let suffix = AppProfile.current.name.map { "-\($0)" } ?? ""
|
||||
return logDir.appendingPathComponent("openclaw-gateway\(suffix).log")
|
||||
}
|
||||
|
||||
private static func ensureLogDirExists() {
|
||||
|
||||
@@ -9,7 +9,7 @@ enum AppLogSettings {
|
||||
static let logLevelKey = appLogLevelKey
|
||||
|
||||
static func logLevel() -> Logger.Level {
|
||||
if let raw = UserDefaults.standard.string(forKey: self.logLevelKey),
|
||||
if let raw = AppDefaults.standard.string(forKey: self.logLevelKey),
|
||||
let level = Logger.Level(rawValue: raw)
|
||||
{
|
||||
return level
|
||||
@@ -18,11 +18,11 @@ enum AppLogSettings {
|
||||
}
|
||||
|
||||
static func setLogLevel(_ level: Logger.Level) {
|
||||
UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
|
||||
AppDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
|
||||
}
|
||||
|
||||
static func fileLoggingEnabled() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
AppDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,10 +62,14 @@ actor MacGatewayProfileStore {
|
||||
// poisoning path. Release-config ad-hoc builds stay out of scope; running
|
||||
// those against saved Keychain items is already unsupported.
|
||||
#if DEBUG
|
||||
private static let service = "ai.openclaw.gateway-profiles.debug"
|
||||
private static let baseService = "ai.openclaw.gateway-profiles.debug"
|
||||
#else
|
||||
private static let service = "ai.openclaw.gateway-profiles"
|
||||
private static let baseService = "ai.openclaw.gateway-profiles"
|
||||
#endif
|
||||
static var service: String {
|
||||
AppProfile.current.keychainService(base: self.baseService)
|
||||
}
|
||||
|
||||
private static let registryAccount = "registry-v1"
|
||||
private static let currentLegacyPrimaryMigrationVersion = 1
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Dispatch
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
@@ -13,14 +15,25 @@ struct OpenClawApp: App {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var state: AppState
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "app")
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private var gatewayManager: GatewayProcessManager {
|
||||
.shared
|
||||
}
|
||||
|
||||
private var controlChannel: ControlChannel {
|
||||
.shared
|
||||
}
|
||||
|
||||
private var activityStore: WorkActivityStore {
|
||||
.shared
|
||||
}
|
||||
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var statusItemMouseRouter = StatusItemMouseRouter()
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isChatWindowVisible = false
|
||||
@State private var tailscaleService = TailscaleService.shared
|
||||
private var tailscaleService: TailscaleService {
|
||||
.shared
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateStatusHighlight() {
|
||||
@@ -28,6 +41,22 @@ struct OpenClawApp: App {
|
||||
}
|
||||
|
||||
init() {
|
||||
if let error = AppProfile.current.validationError {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "OpenClaw profile is invalid"
|
||||
alert.informativeText = error.localizedDescription
|
||||
alert.runModal()
|
||||
Darwin.exit(2)
|
||||
}
|
||||
if AppProfile.current.isActive,
|
||||
!DeviceIdentityStore.configureStateDirectory(OpenClawPaths.stateDirURL)
|
||||
{
|
||||
fatalError("Device identity state root was already used before app profile configuration")
|
||||
}
|
||||
guard GatewayTLSStore.configureKeychainServiceSuffix(AppProfile.current.keychainServiceSuffix) else {
|
||||
fatalError("Gateway TLS Keychain namespace was already used by another app profile")
|
||||
}
|
||||
OpenClawLogging.bootstrapIfNeeded()
|
||||
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
@@ -366,11 +395,11 @@ private struct SettingsWindowOpenRegistrar: View {
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private static let dashboardURL = URL(string: "openclaw://dashboard")!
|
||||
private var state: AppState?
|
||||
private var terminationCleanupTask: Task<Void, Never>?
|
||||
private var terminationDeadlineTask: Task<Void, Never>?
|
||||
private var terminationCleanupFinished = false
|
||||
private var profileInstanceLock: AppInstanceLock?
|
||||
private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat")
|
||||
var nodeTerminationCleanup: @MainActor () async -> Void = {
|
||||
await TalkMLXSpeechSynthesizer.shared.shutdown()
|
||||
@@ -386,7 +415,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
var openDashboardAction: @MainActor () -> Void = { AppNavigationActions.openDashboard() }
|
||||
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||
let updaterController: UpdaterProviding
|
||||
|
||||
override init() {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let hasReplacementMetadata = ApplicationRelocator.hasReplacementHandoffMetadata(
|
||||
environment: environment)
|
||||
let isReplacementHandoff = hasReplacementMetadata &&
|
||||
ApplicationRelocator.acceptReplacementHandoff(environment: environment)
|
||||
if hasReplacementMetadata, !isReplacementHandoff {
|
||||
fputs("OpenClaw replacement handoff authentication failed.\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
let ownership = AppInstanceLock.acquire(
|
||||
url: AppProfile.current.instanceLockURL(),
|
||||
waitMilliseconds: isReplacementHandoff ? 5000 : 0)
|
||||
if let exitCode = Self.processExitCode(for: ownership) {
|
||||
fputs("OpenClaw profile is already running.\n", stderr)
|
||||
Darwin.exit(exitCode)
|
||||
}
|
||||
var profileInstanceLock: AppInstanceLock?
|
||||
var instanceOwnershipFailure: String?
|
||||
switch ownership {
|
||||
case let .acquired(lock):
|
||||
profileInstanceLock = lock
|
||||
case .busy:
|
||||
break
|
||||
case let .failed(message):
|
||||
instanceOwnershipFailure = message
|
||||
}
|
||||
self.profileInstanceLock = profileInstanceLock
|
||||
self.updaterController = instanceOwnershipFailure == nil
|
||||
? makeUpdaterController()
|
||||
: DisabledUpdaterController()
|
||||
super.init()
|
||||
if let instanceOwnershipFailure {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "OpenClaw could not claim its instance lock"
|
||||
alert.informativeText = instanceOwnershipFailure
|
||||
alert.runModal()
|
||||
Darwin.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
static func processExitCode(for ownership: AppInstanceLockAcquisition) -> Int32? {
|
||||
if case .busy = ownership { return 0 }
|
||||
return nil
|
||||
}
|
||||
|
||||
func applicationWillFinishLaunching(_: Notification) {
|
||||
// URL/reopen callbacks can create the dashboard before didFinishLaunching.
|
||||
@@ -471,34 +547,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let launchPolicy = AppLaunchPresentationPolicy.current
|
||||
let hasReplacementHandoff = ApplicationRelocator.hasReplacementHandoffMetadata(
|
||||
environment: environment)
|
||||
let isReplacementHandoff = ApplicationRelocator.acceptReplacementHandoff(
|
||||
environment: environment)
|
||||
if hasReplacementHandoff, !isReplacementHandoff {
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
// Only a child whose signed parent and inherited readiness pipe authenticate
|
||||
// may overlap the old process during replacement handoff.
|
||||
if !isReplacementHandoff, self.isDuplicateInstance() {
|
||||
if launchPolicy.allowsAutomaticPresentation {
|
||||
NSWorkspace.shared.open(Self.dashboardURL)
|
||||
}
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
switch ApplicationRelocator.handleLaunch() {
|
||||
case .terminating:
|
||||
return
|
||||
case let .continueLaunch(startUpdater):
|
||||
if startUpdater {
|
||||
if OpenClawConfigFile.gatewayUpdateChannel() == nil {
|
||||
self.updaterController.startAfterResolvingGatewayUpdateChannel()
|
||||
} else {
|
||||
self.updaterController.start()
|
||||
if !AppProfile.current.isActive {
|
||||
switch ApplicationRelocator.handleLaunch() {
|
||||
case .terminating:
|
||||
return
|
||||
case let .continueLaunch(startUpdater):
|
||||
if startUpdater {
|
||||
if OpenClawConfigFile.gatewayUpdateChannel() == nil {
|
||||
self.updaterController.startAfterResolvingGatewayUpdateChannel()
|
||||
} else {
|
||||
self.updaterController.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -741,7 +801,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
expectedConnectionMode: AppState.ConnectionMode,
|
||||
expectedRouteIdentity: String?)
|
||||
{
|
||||
let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey)
|
||||
let seenVersion = AppDefaults.standard.integer(forKey: onboardingVersionKey)
|
||||
let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen
|
||||
guard shouldShow else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
||||
@@ -756,11 +816,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
OnboardingController.shared.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isDuplicateInstance() -> Bool {
|
||||
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
|
||||
let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID }
|
||||
return running.count > 1
|
||||
enum AppInstanceLockAcquisition {
|
||||
case acquired(AppInstanceLock)
|
||||
case busy
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
final class AppInstanceLock {
|
||||
/// Keep the descriptor open for the process lifetime. Never unlink the path:
|
||||
/// another opener could then lock a different inode and admit a duplicate.
|
||||
private let descriptor: Int32
|
||||
|
||||
private init(descriptor: Int32) {
|
||||
self.descriptor = descriptor
|
||||
}
|
||||
|
||||
static func acquire(url: URL, waitMilliseconds: Int = 0) -> AppInstanceLockAcquisition {
|
||||
if let error = self.preparePrivateStateRoot(url.deletingLastPathComponent()) {
|
||||
return .failed(error)
|
||||
}
|
||||
let descriptor = Darwin.open(url.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600)
|
||||
guard descriptor >= 0 else { return .failed(String(cString: strerror(errno))) }
|
||||
var status = stat()
|
||||
guard fstat(descriptor, &status) == 0,
|
||||
status.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
|
||||
status.st_uid == geteuid()
|
||||
else {
|
||||
Darwin.close(descriptor)
|
||||
return .failed("Instance lock is not a safe file owned by the current user.")
|
||||
}
|
||||
_ = fchmod(descriptor, 0o600)
|
||||
let deadline = DispatchTime.now() + .milliseconds(max(0, waitMilliseconds))
|
||||
while flock(descriptor, LOCK_EX | LOCK_NB) != 0 {
|
||||
guard errno == EWOULDBLOCK, DispatchTime.now() < deadline else {
|
||||
let result: AppInstanceLockAcquisition = errno == EWOULDBLOCK
|
||||
? .busy
|
||||
: .failed(String(cString: strerror(errno)))
|
||||
Darwin.close(descriptor)
|
||||
return result
|
||||
}
|
||||
usleep(50000)
|
||||
}
|
||||
return .acquired(AppInstanceLock(descriptor: descriptor))
|
||||
}
|
||||
|
||||
private static func preparePrivateStateRoot(_ root: URL) -> String? {
|
||||
var status = stat()
|
||||
if lstat(root.path, &status) != 0 {
|
||||
guard errno == ENOENT else { return String(cString: strerror(errno)) }
|
||||
guard mkdir(root.path, 0o700) == 0 else { return String(cString: strerror(errno)) }
|
||||
guard lstat(root.path, &status) == 0 else { return String(cString: strerror(errno)) }
|
||||
}
|
||||
guard status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR),
|
||||
status.st_uid == geteuid(),
|
||||
status.st_mode & 0o777 == 0o700
|
||||
else {
|
||||
return "App profile state directory must be an owner-only 0700 directory."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
_ = flock(self.descriptor, LOCK_UN)
|
||||
Darwin.close(self.descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,11 +1111,14 @@ private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
||||
|
||||
@MainActor
|
||||
private func makeUpdaterController() -> UpdaterProviding {
|
||||
guard AppProfile.current.validationError == nil, !AppProfile.current.isActive else {
|
||||
return DisabledUpdaterController()
|
||||
}
|
||||
let bundleURL = Bundle.main.bundleURL
|
||||
let isBundledApp = bundleURL.pathExtension == "app"
|
||||
guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let defaults = AppDefaults.standard
|
||||
let autoUpdateKey = "autoUpdateEnabled"
|
||||
// Default to true; honor the user's last choice otherwise.
|
||||
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
|
||||
|
||||
@@ -24,9 +24,11 @@ struct MenuContent: View {
|
||||
@State private var micObserver = AudioInputDeviceObserver()
|
||||
@State private var micRefreshTask: Task<Void, Never>?
|
||||
@State private var browserControlEnabled = true
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = Logger.Level.info.rawValue
|
||||
@AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false
|
||||
@AppStorage(cameraEnabledKey, store: AppDefaults.standard) private var cameraEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey, store: AppDefaults.standard)
|
||||
private var appLogLevelRaw: String = Logger.Level.info.rawValue
|
||||
@AppStorage(debugFileLogEnabledKey, store: AppDefaults.standard)
|
||||
private var appFileLoggingEnabled: Bool = false
|
||||
|
||||
init(state: AppState, updater: UpdaterProviding?) {
|
||||
self._state = Bindable(wrappedValue: state)
|
||||
@@ -387,6 +389,11 @@ struct MenuContent: View {
|
||||
}
|
||||
|
||||
private var healthStatus: (label: String, color: Color) {
|
||||
if self.state.connectionMode == .local,
|
||||
let failure = GatewayProcessManager.shared.lastFailureReason
|
||||
{
|
||||
return (failure, .red)
|
||||
}
|
||||
if self.state.connectionMode == .remote {
|
||||
let live = GatewayConnectionPresentation(state: self.controlChannel.state)
|
||||
switch live.tone {
|
||||
|
||||
@@ -216,6 +216,13 @@ extension MenuSessionsInjector {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if let notice = self.nodesStore.persistentServiceNotice {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: notice, symbolName: "info.circle", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if case .connecting = ControlChannel.shared.state {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width),
|
||||
@@ -289,6 +296,7 @@ extension MenuSessionsInjector {
|
||||
return self.sortedNodeEntries().isEmpty
|
||||
|| self.nodesStore.lastError?.nonEmpty != nil
|
||||
|| self.nodesStore.statusMessage?.nonEmpty != nil
|
||||
|| self.nodesStore.persistentServiceNotice != nil
|
||||
}
|
||||
|
||||
private func buildContextSubmenu(
|
||||
|
||||
@@ -65,13 +65,13 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
static let shared = MacNodeModeCoordinator()
|
||||
static var nodeIdentityProfile: GatewayDeviceIdentityProfile {
|
||||
self.resolveNodeIdentityProfile(
|
||||
defaults: .standard,
|
||||
defaults: AppDefaults.standard,
|
||||
isExistingInstallation: AppStateStore.shared.onboardingSeen)
|
||||
}
|
||||
|
||||
static func prepareNodeIdentityProfile(isExistingInstallation: Bool) {
|
||||
_ = self.resolveNodeIdentityProfile(
|
||||
defaults: .standard,
|
||||
defaults: AppDefaults.standard,
|
||||
isExistingInstallation: isExistingInstallation)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
self.nodeHostWorkerRetryPolicy = nodeHostWorkerRetryPolicy
|
||||
self.refreshEvents = refreshEvents.stream
|
||||
self.refreshContinuation = refreshEvents.continuation
|
||||
self.lastObservedPaused = initialPaused ?? UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.lastObservedPaused = initialPaused ?? AppDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.lastObservedComputerControlEnabled = initialComputerControlEnabled ??
|
||||
isComputerControlEnabled()
|
||||
super.init()
|
||||
@@ -176,7 +176,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
self,
|
||||
selector: #selector(self.refreshNodeConfiguration),
|
||||
name: UserDefaults.didChangeNotification,
|
||||
object: UserDefaults.standard)
|
||||
object: AppDefaults.standard)
|
||||
self.notificationCenter.addObserver(
|
||||
self,
|
||||
selector: #selector(self.refreshNodeConfiguration),
|
||||
@@ -273,7 +273,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
|
||||
func refresh() {
|
||||
self.refresh(
|
||||
isPaused: UserDefaults.standard.bool(forKey: pauseDefaultsKey),
|
||||
isPaused: AppDefaults.standard.bool(forKey: pauseDefaultsKey),
|
||||
computerControlEnabled: isComputerControlEnabled())
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
private func run() async {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var refreshIterator = self.refreshEvents.makeAsyncIterator()
|
||||
let defaults = UserDefaults.standard
|
||||
let defaults = AppDefaults.standard
|
||||
|
||||
while !Task.isCancelled {
|
||||
// A stop/refresh immediately followed by start/unpause must not install
|
||||
@@ -815,7 +815,7 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
codexThreadCatalogEnabled: Bool,
|
||||
claudeSessionCatalogEnabled: Bool) -> [String]
|
||||
{
|
||||
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
let rawLocationMode = AppDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
let computerControlEnabled = isComputerControlEnabled()
|
||||
return Self.resolvedCaps(
|
||||
browserControlEnabled: browserControlEnabled,
|
||||
@@ -848,7 +848,8 @@ final class MacNodeModeCoordinator: NSObject {
|
||||
} else {
|
||||
switch await CLIInstaller.status() {
|
||||
case let .ready(location, _):
|
||||
launch = MacNodeHostWorkerLaunch(command: [location, "node", "worker"])
|
||||
launch = MacNodeHostWorkerLaunch(command: CommandResolver.nodeHostWorkerCommand(
|
||||
prefix: [location]))
|
||||
case let status:
|
||||
throw MacNodeHostWorker.WorkerError.unavailable(status.message)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ final class MacNodePresenceReporter {
|
||||
private let idleSecondsProvider: IdleSecondsProvider
|
||||
|
||||
init(
|
||||
reportingEnabled: Bool = UserDefaults.standard.bool(forKey: activeComputerPresenceEnabledKey),
|
||||
reportingEnabled: Bool = AppDefaults.standard.bool(forKey: activeComputerPresenceEnabledKey),
|
||||
idleSecondsProvider: @escaping IdleSecondsProvider = {
|
||||
guard AXIsProcessTrusted() else { return nil }
|
||||
return SystemPresenceInfo.lastHardwareInputSeconds()
|
||||
|
||||
@@ -980,11 +980,11 @@ extension MacNodeRuntime {
|
||||
}
|
||||
|
||||
private nonisolated static func canvasEnabled() -> Bool {
|
||||
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
AppDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
private nonisolated static func cameraEnabled() -> Bool {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
AppDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
nonisolated static func computerControlEnabledDefault() -> Bool {
|
||||
@@ -992,15 +992,15 @@ extension MacNodeRuntime {
|
||||
}
|
||||
|
||||
private nonisolated static func locationMode() -> OpenClawLocationMode {
|
||||
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
let raw = AppDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return OpenClawLocationMode(rawValue: raw) ?? .off
|
||||
}
|
||||
|
||||
private nonisolated static func locationPreciseEnabled() -> Bool {
|
||||
if UserDefaults.standard.object(forKey: locationPreciseKey) == nil {
|
||||
if AppDefaults.standard.object(forKey: locationPreciseKey) == nil {
|
||||
return true
|
||||
}
|
||||
return UserDefaults.standard.bool(forKey: locationPreciseKey)
|
||||
return AppDefaults.standard.bool(forKey: locationPreciseKey)
|
||||
}
|
||||
|
||||
private static func errorResponse(
|
||||
|
||||
@@ -8,7 +8,8 @@ enum NodeServiceManager {
|
||||
.appendingPathComponent("Library/LaunchAgents/\(nodeLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
static func start() async -> String? {
|
||||
static func start(profile: AppProfile = .current) async -> String? {
|
||||
if self.skipUnderProfile(profile, action: "start") { return nil }
|
||||
let result = await self.runServiceCommandResult(
|
||||
["start"],
|
||||
timeout: 20,
|
||||
@@ -20,7 +21,8 @@ enum NodeServiceManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func stop() async -> String? {
|
||||
static func stop(profile: AppProfile = .current) async -> String? {
|
||||
if self.skipUnderProfile(profile, action: "stop") { return nil }
|
||||
let result = await self.runServiceCommandResult(
|
||||
["stop"],
|
||||
timeout: 15,
|
||||
@@ -32,7 +34,8 @@ enum NodeServiceManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func restart() async -> String? {
|
||||
static func restart(profile: AppProfile = .current) async -> String? {
|
||||
if self.skipUnderProfile(profile, action: "restart") { return nil }
|
||||
let result = await self.runServiceCommandResult(
|
||||
["restart"],
|
||||
timeout: 20,
|
||||
@@ -46,13 +49,15 @@ enum NodeServiceManager {
|
||||
|
||||
/// Empty means no node LaunchAgent. Nil means the on-disk ownership proof
|
||||
/// exists but could not be read, so callers must not treat it as external.
|
||||
static func launchdProgramArguments() -> [String]? {
|
||||
self.launchdProgramArguments(
|
||||
static func launchdProgramArguments(profile: AppProfile = .current) -> [String]? {
|
||||
if self.skipUnderProfile(profile, action: "status") { return [] }
|
||||
return self.launchdProgramArguments(
|
||||
plistURL: self.launchdPlistURL,
|
||||
fileManager: .default)
|
||||
}
|
||||
|
||||
static func waitUntilRunning() async -> Bool {
|
||||
static func waitUntilRunning(profile: AppProfile = .current) async -> Bool {
|
||||
if self.skipUnderProfile(profile, action: "status poll") { return false }
|
||||
var consecutiveRunningChecks = 0
|
||||
for attempt in 0..<20 {
|
||||
let result = await self.runServiceCommandResult(
|
||||
@@ -77,6 +82,12 @@ enum NodeServiceManager {
|
||||
}
|
||||
|
||||
extension NodeServiceManager {
|
||||
private static func skipUnderProfile(_ profile: AppProfile, action: String) -> Bool {
|
||||
guard profile.isActive else { return false }
|
||||
self.logger.info("node service \(action, privacy: .public) skipped (unavailable under app profile)")
|
||||
return true
|
||||
}
|
||||
|
||||
private static func serviceCommand(_ args: [String]) async -> [String] {
|
||||
await CommandResolver.openclawCommand(
|
||||
subcommand: "node",
|
||||
@@ -107,6 +118,9 @@ extension NodeServiceManager {
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
#if DEBUG
|
||||
self.testingServiceCommandCalls.append(args)
|
||||
#endif
|
||||
let command = await self.serviceCommand(args)
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
@@ -187,6 +201,9 @@ extension NodeServiceManager {
|
||||
plistURL: URL,
|
||||
fileManager: FileManager) -> [String]?
|
||||
{
|
||||
#if DEBUG
|
||||
self.testingOwnershipReadCount += 1
|
||||
#endif
|
||||
guard fileManager.fileExists(atPath: plistURL.path) else { return [] }
|
||||
return LaunchAgentPlist.snapshot(url: plistURL)?.programArguments
|
||||
}
|
||||
@@ -206,6 +223,18 @@ extension NodeServiceManager {
|
||||
|
||||
#if DEBUG
|
||||
extension NodeServiceManager {
|
||||
private nonisolated(unsafe) static var testingServiceCommandCalls: [[String]] = []
|
||||
private nonisolated(unsafe) static var testingOwnershipReadCount = 0
|
||||
|
||||
static func _testResetPersistentServiceCalls() {
|
||||
self.testingServiceCommandCalls = []
|
||||
self.testingOwnershipReadCount = 0
|
||||
}
|
||||
|
||||
static func _testPersistentServiceCallSnapshot() -> (commands: [[String]], ownershipReads: Int) {
|
||||
(self.testingServiceCommandCalls, self.testingOwnershipReadCount)
|
||||
}
|
||||
|
||||
static func _testServiceCommand(_ args: [String]) async -> [String] {
|
||||
await self.serviceCommand(args)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ final class NodesStore {
|
||||
var nodes: [NodeInfo] = []
|
||||
var lastError: String?
|
||||
var statusMessage: String?
|
||||
let persistentServiceNotice: String?
|
||||
var isLoading = false
|
||||
private(set) var localNodeIdentityState: LocalNodeIdentityState = .loading
|
||||
|
||||
@@ -65,11 +66,15 @@ final class NodesStore {
|
||||
@ObservationIgnored private var localNodeIdentityPreparationTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
appProfile: AppProfile = .current,
|
||||
localNodeIdentityProfile: GatewayDeviceIdentityProfile = MacNodeModeCoordinator.nodeIdentityProfile,
|
||||
localNodeIDLoader: @escaping @Sendable (GatewayDeviceIdentityProfile) -> String? = { profile in
|
||||
DeviceIdentityStore.loadOrCreatePersisted(profile: profile)?.deviceId
|
||||
})
|
||||
{
|
||||
self.persistentServiceNotice = appProfile.isActive
|
||||
? "Persistent Mac node service unavailable under app profile; runtime node remains available."
|
||||
: nil
|
||||
self.localNodeIdentityProfile = localNodeIdentityProfile
|
||||
self.localNodeIDLoader = localNodeIDLoader
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
|
||||
static func isPending(
|
||||
for routeIdentity: String?,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date()) -> Bool
|
||||
{
|
||||
self.pendingState(for: routeIdentity, defaults: defaults, now: now) != .none
|
||||
@@ -162,7 +162,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
routeIdentity: String?,
|
||||
activationOwner: ActivationOwner? = nil,
|
||||
activationTimeoutMs: Double = OnboardingSystemAgentResumeStore.maximumActivationTimeoutMs,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date())
|
||||
-> Date?
|
||||
{
|
||||
@@ -183,7 +183,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
routeIdentity: String,
|
||||
activationOwner: ActivationOwner? = nil,
|
||||
deadline: Date,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date())
|
||||
{
|
||||
guard let routeIdentity = normalized(routeIdentity) else { return }
|
||||
@@ -199,7 +199,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
static func markVerified(
|
||||
ifOwnedBy routeIdentity: String?,
|
||||
activationOwner: ActivationOwner? = nil,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date())
|
||||
{
|
||||
guard let routeIdentity = normalized(routeIdentity) else { return }
|
||||
@@ -219,7 +219,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
static func markCompleted(
|
||||
ifOwnedBy routeIdentity: String?,
|
||||
activationOwner: ActivationOwner? = nil,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date()) -> Bool
|
||||
{
|
||||
guard let routeIdentity = normalized(routeIdentity) else { return false }
|
||||
@@ -238,7 +238,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
|
||||
static func activationOwner(
|
||||
for routeIdentity: String?,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date()) -> ActivationOwner?
|
||||
{
|
||||
guard let routeIdentity = normalized(routeIdentity) else { return nil }
|
||||
@@ -248,7 +248,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
static func isOwned(
|
||||
by activationOwner: ActivationOwner,
|
||||
for routeIdentity: String?,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date()) -> Bool
|
||||
{
|
||||
guard let routeIdentity = normalized(routeIdentity),
|
||||
@@ -259,7 +259,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
|
||||
static func pendingState(
|
||||
for routeIdentity: String?,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date()) -> PendingState
|
||||
{
|
||||
guard let routeIdentity = normalized(routeIdentity),
|
||||
@@ -282,7 +282,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
static func clear(
|
||||
ifOwnedBy routeIdentity: String,
|
||||
activationOwner: ActivationOwner? = nil,
|
||||
defaults: UserDefaults = .standard) -> Bool
|
||||
defaults: UserDefaults = AppDefaults.standard) -> Bool
|
||||
{
|
||||
guard let routeIdentity = normalized(routeIdentity) else { return false }
|
||||
var records = self.loadRecords(defaults: defaults)
|
||||
@@ -294,7 +294,7 @@ enum OnboardingSystemAgentResumeStore {
|
||||
return true
|
||||
}
|
||||
|
||||
static func clear(defaults: UserDefaults = .standard) {
|
||||
static func clear(defaults: UserDefaults = AppDefaults.standard) {
|
||||
defaults.removeObject(forKey: onboardingSystemAgentPendingKey)
|
||||
defaults.removeObject(forKey: onboardingSystemAgentPendingRetiredKey)
|
||||
}
|
||||
@@ -537,8 +537,8 @@ final class OnboardingController: NSObject, NSWindowDelegate {
|
||||
var busyReason: String?
|
||||
|
||||
static func markComplete() {
|
||||
UserDefaults.standard.set(true, forKey: onboardingSeenKey)
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
AppDefaults.standard.set(true, forKey: onboardingSeenKey)
|
||||
AppDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = true
|
||||
DashboardManager.shared.handleOnboardingCompletion()
|
||||
}
|
||||
@@ -827,7 +827,7 @@ struct OnboardingView: View {
|
||||
localDisplayName: InstanceIdentity.displayName,
|
||||
filterLocalGateways: false),
|
||||
aiSetupGateway: GatewayConnection = .shared,
|
||||
systemAgentDefaults: UserDefaults = .standard,
|
||||
systemAgentDefaults: UserDefaults = AppDefaults.standard,
|
||||
aiSetupRouteIdentityProvider: (@MainActor () -> String?)? = nil,
|
||||
configuredGatewayProbeTimeoutMs: Double = 15000,
|
||||
gatewaySelectionPersister: (@MainActor () -> Bool)? = nil,
|
||||
|
||||
@@ -110,7 +110,7 @@ final class OnboardingAISetupModel {
|
||||
|
||||
init(
|
||||
gateway: GatewayConnection = .shared,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
routeIdentityProvider: @escaping @MainActor () -> String? = {
|
||||
OnboardingSystemAgentResumeStore.selectedRouteIdentity()
|
||||
},
|
||||
|
||||
@@ -1047,8 +1047,12 @@ extension OnboardingView {
|
||||
self.openSettings(tab: .skills)
|
||||
}
|
||||
self.skillsOverview
|
||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||
.disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin)
|
||||
if AppProfile.current.isActive {
|
||||
LabeledContent("Launch at login", value: "Unavailable under profile")
|
||||
} else {
|
||||
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
|
||||
.disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await self.maybeLoadOnboardingSkills() }
|
||||
|
||||
@@ -23,8 +23,7 @@ enum OpenClawPaths {
|
||||
return URL(fileURLWithPath: override, isDirectory: true)
|
||||
}
|
||||
}
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
return home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
return AppProfile.current.stateDirectoryURL()
|
||||
}
|
||||
|
||||
private static func resolveConfigCandidate(in dir: URL) -> URL? {
|
||||
|
||||
@@ -93,8 +93,9 @@ struct PermissionsSettings: View {
|
||||
private struct LocationAccessSettings: View {
|
||||
private static let controlWidth: CGFloat = 180
|
||||
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
@AppStorage(locationModeKey, store: AppDefaults.standard)
|
||||
private var locationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey, store: AppDefaults.standard) private var locationPreciseEnabled: Bool = true
|
||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -97,6 +97,12 @@ actor PortGuardian {
|
||||
self.logger.warning(message)
|
||||
continue
|
||||
}
|
||||
if AppProfile.current.isActive {
|
||||
self.logger.error(
|
||||
"profile port \(port, privacy: .public) held by \(listener.command, privacy: .public) " +
|
||||
"(pid \(listener.pid, privacy: .public)); preserving conflict")
|
||||
continue
|
||||
}
|
||||
if await Self.terminateProcess(listener.pid) {
|
||||
let message = """
|
||||
port \(port) was held by \(listener.command)
|
||||
|
||||
@@ -61,7 +61,7 @@ enum PostAppUpdateReceiptStore {
|
||||
static func record(
|
||||
fromVersion: String,
|
||||
toVersion: String,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date())
|
||||
{
|
||||
let from = fromVersion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -73,7 +73,7 @@ enum PostAppUpdateReceiptStore {
|
||||
|
||||
static func pending(
|
||||
currentVersion: String?,
|
||||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt?
|
||||
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt?
|
||||
{
|
||||
guard let currentVersion = normalized(currentVersion),
|
||||
let data = defaults.data(forKey: postAppUpdateReceiptKey),
|
||||
@@ -87,7 +87,7 @@ enum PostAppUpdateReceiptStore {
|
||||
currentVersion: String?,
|
||||
onboardingSeen: Bool,
|
||||
allowsUpdateWorkflow: Bool = true,
|
||||
defaults: UserDefaults = .standard,
|
||||
defaults: UserDefaults = AppDefaults.standard,
|
||||
now: Date = Date()) -> PostAppUpdateReceipt?
|
||||
{
|
||||
guard let currentVersion = normalized(currentVersion) else { return nil }
|
||||
@@ -116,7 +116,7 @@ enum PostAppUpdateReceiptStore {
|
||||
return receipt
|
||||
}
|
||||
|
||||
static func clear(defaults: UserDefaults = .standard) {
|
||||
static func clear(defaults: UserDefaults = AppDefaults.standard) {
|
||||
defaults.removeObject(forKey: postAppUpdateReceiptKey)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ enum PostAppUpdateReceiptStore {
|
||||
static func setGatewayUpdateIncomplete(
|
||||
_ incomplete: Bool,
|
||||
receipt: PostAppUpdateReceipt,
|
||||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
|
||||
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt
|
||||
{
|
||||
let updated = PostAppUpdateReceipt(
|
||||
fromVersion: receipt.fromVersion,
|
||||
@@ -140,7 +140,7 @@ enum PostAppUpdateReceiptStore {
|
||||
@discardableResult
|
||||
static func recordNotificationFailure(
|
||||
receipt: PostAppUpdateReceipt,
|
||||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
|
||||
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt
|
||||
{
|
||||
// One later-launch retry handles restart races. The bound prevents
|
||||
// permanent auth/schema errors from reopening this window forever.
|
||||
@@ -159,7 +159,7 @@ enum PostAppUpdateReceiptStore {
|
||||
static func setNotificationInFlight(
|
||||
_ inFlight: Bool,
|
||||
receipt: PostAppUpdateReceipt,
|
||||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
|
||||
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt
|
||||
{
|
||||
let updated = PostAppUpdateReceipt(
|
||||
fromVersion: receipt.fromVersion,
|
||||
@@ -266,7 +266,8 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
@discardableResult
|
||||
func startIfNeeded() -> Bool {
|
||||
func startIfNeeded(profile: AppProfile = .current) -> Bool {
|
||||
guard !profile.isActive else { return false }
|
||||
guard let receipt = PostAppUpdateReceiptStore.pendingForLaunch(
|
||||
currentVersion: GatewayEnvironment.appVersionString(),
|
||||
onboardingSeen: AppStateStore.shared.onboardingSeen,
|
||||
@@ -341,6 +342,10 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
|
||||
}
|
||||
|
||||
private func finishUpdate(receipt: PostAppUpdateReceipt) async {
|
||||
guard !AppProfile.current.isActive else {
|
||||
self.finishSilently()
|
||||
return
|
||||
}
|
||||
let connectionMode = AppStateStore.shared.connectionMode
|
||||
guard CLIInstallPrompter.shouldManageCLI(connectionMode: connectionMode) else {
|
||||
self.finishSilently()
|
||||
@@ -549,6 +554,7 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
|
||||
}
|
||||
|
||||
private func verifyRuntime(connectionMode: AppState.ConnectionMode) async -> Bool {
|
||||
guard !AppProfile.current.isActive else { return true }
|
||||
guard case .ready = await CLIInstaller.managedStatus() else {
|
||||
self.fail(
|
||||
message: String(localized: "Gateway verification failed."),
|
||||
|
||||
@@ -14,14 +14,14 @@ extension ProcessInfo {
|
||||
}
|
||||
|
||||
static func resolveStableNixSuite(bundleIdentifier: String?, isAppBundle: Bool) -> UserDefaults? {
|
||||
// The app's own defaults domain is already represented by UserDefaults.standard.
|
||||
// The app's own defaults domain is already represented by AppDefaults.standard.
|
||||
// Passing that same identifier to suiteName triggers Foundation's nonsensical-suite warning.
|
||||
guard isAppBundle, bundleIdentifier != launchdLabel else { return nil }
|
||||
return UserDefaults(suiteName: launchdLabel)
|
||||
}
|
||||
|
||||
/// Nix deployments may write defaults into a stable suite (`ai.openclaw.mac`) even if the shipped
|
||||
/// app bundle identifier changes (and therefore `UserDefaults.standard` domain changes).
|
||||
/// app bundle identifier changes (and therefore `AppDefaults.standard` domain changes).
|
||||
static func resolveNixMode(
|
||||
environment: [String: String],
|
||||
standard: UserDefaults,
|
||||
@@ -45,7 +45,7 @@ extension ProcessInfo {
|
||||
isAppBundle: isAppBundle)
|
||||
return Self.resolveNixMode(
|
||||
environment: self.environment,
|
||||
standard: .standard,
|
||||
standard: AppDefaults.standard,
|
||||
stableSuite: stableSuite,
|
||||
isAppBundle: isAppBundle)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
final class ProfileGatewayPortReservation: @unchecked Sendable {
|
||||
let port: Int
|
||||
let conflict: String?
|
||||
private let lock: AppInstanceLock?
|
||||
|
||||
private init(port: Int, conflict: String?, lock: AppInstanceLock?) {
|
||||
self.port = port
|
||||
self.conflict = conflict
|
||||
self.lock = lock
|
||||
}
|
||||
|
||||
static func acquire(
|
||||
profile: AppProfile,
|
||||
port: Int,
|
||||
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||
temporaryDirectory: URL = URL(fileURLWithPath: "/tmp", isDirectory: true)) -> Self
|
||||
{
|
||||
guard let profileName = profile.name else { return Self(port: port, conflict: nil, lock: nil) }
|
||||
let lockURL = temporaryDirectory
|
||||
.appendingPathComponent("openclaw-\(geteuid())-app-profile-ports", isDirectory: true)
|
||||
.appendingPathComponent("\(port).lock")
|
||||
let lock: AppInstanceLock
|
||||
switch AppInstanceLock.acquire(url: lockURL) {
|
||||
case let .acquired(acquired): lock = acquired
|
||||
case .busy:
|
||||
return self.conflict(
|
||||
profile: profileName,
|
||||
port: port,
|
||||
reason: "another running OpenClaw profile already reserves it")
|
||||
case let .failed(reason):
|
||||
return self.conflict(
|
||||
profile: profileName,
|
||||
port: port,
|
||||
reason: "the profile reservation could not be verified (\(reason))")
|
||||
}
|
||||
|
||||
if let reason = GatewayLaunchAgentManager.conflictingProfileClaimOwner(
|
||||
port: port,
|
||||
excludingLabel: profile.gatewayLaunchAgentLabel,
|
||||
homeDirectory: homeDirectory)
|
||||
{
|
||||
return self.conflict(profile: profileName, port: port, reason: reason)
|
||||
}
|
||||
return Self(port: port, conflict: nil, lock: lock)
|
||||
}
|
||||
|
||||
private static func conflict(profile: String, port: Int, reason: String) -> Self {
|
||||
let message = "Profile \"\(profile)\" cannot use Gateway port \(port) because \(reason). " +
|
||||
"Set gateway.port to a free port for this profile, or stop/uninstall the other Gateway."
|
||||
return Self(
|
||||
port: port,
|
||||
conflict: message,
|
||||
lock: nil)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,6 @@ import KeyboardShortcuts
|
||||
extension KeyboardShortcuts.Name {
|
||||
/// KeyboardShortcuts owns UserDefaults persistence for the recorded chord.
|
||||
static let toggleQuickChat = Self(
|
||||
"toggleQuickChat",
|
||||
AppProfile.current.name.map { "toggleQuickChat-\($0)" } ?? "toggleQuickChat",
|
||||
initial: .init(.space, modifiers: [.option]))
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ struct SettingsRootView: View {
|
||||
self.detailContainer
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.defaultAppStorage(AppDefaults.standard)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in
|
||||
|
||||
@@ -154,10 +154,10 @@ final class WebChatManager {
|
||||
AppNavigationActions.openSettings(tab: .gateways)
|
||||
return
|
||||
}
|
||||
let preferredID = UserDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey)
|
||||
let preferredID = AppDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey)
|
||||
switch Self.promptForGatewayProfile(profiles: profiles, preferredID: preferredID) {
|
||||
case let .profile(profile):
|
||||
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
|
||||
AppDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
|
||||
try await self.show(profile: profile)
|
||||
case .manage:
|
||||
AppNavigationActions.openSettings(tab: .gateways)
|
||||
@@ -173,7 +173,7 @@ final class WebChatManager {
|
||||
func openGatewayWindow(profile: MacGatewayProfile) {
|
||||
Task { @MainActor [weak self] in
|
||||
do {
|
||||
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
|
||||
AppDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
|
||||
try await self?.show(profile: profile)
|
||||
} catch {
|
||||
Self.showProfileError(error, message: "Could Not Open Gateway Window")
|
||||
|
||||
@@ -17,7 +17,7 @@ private enum WebChatSwiftUILayout {
|
||||
}
|
||||
|
||||
enum WebChatTracePreferences {
|
||||
static func displayOptions(defaults: UserDefaults = .standard) -> OpenClawChatDisplayOptions {
|
||||
static func displayOptions(defaults: UserDefaults = AppDefaults.standard) -> OpenClawChatDisplayOptions {
|
||||
if let legacyValue = defaults.object(
|
||||
forKey: OpenClawChatWindowShell.assistantTraceDefaultsKey) as? Bool
|
||||
{
|
||||
@@ -841,9 +841,9 @@ private struct MacChatSurface: View {
|
||||
@State private var appState = AppStateStore.shared
|
||||
@State private var talkController = TalkModeController.shared
|
||||
@State private var audioInputCatalog = MacChatAudioInputCatalog()
|
||||
@AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey)
|
||||
@AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey, store: AppDefaults.standard)
|
||||
private var showsReasoning = WebChatTracePreferences.displayOptions().contains(.reasoning)
|
||||
@AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey)
|
||||
@AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey, store: AppDefaults.standard)
|
||||
private var showsToolActivity = WebChatTracePreferences.displayOptions().contains(.toolActivity)
|
||||
|
||||
private let userAccent: Color?
|
||||
@@ -1110,9 +1110,9 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
|
||||
},
|
||||
onThinkingPreferenceChanged: { level in
|
||||
if let level {
|
||||
UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
|
||||
AppDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: webChatThinkingLevelDefaultsKey)
|
||||
AppDefaults.standard.removeObject(forKey: webChatThinkingLevelDefaultsKey)
|
||||
}
|
||||
},
|
||||
onVerbosePreferenceChanged: { level in
|
||||
@@ -1221,7 +1221,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
|
||||
onClosed?()
|
||||
}
|
||||
|
||||
static func persistedThinkingLevel(defaults: UserDefaults = .standard) -> String? {
|
||||
static func persistedThinkingLevel(defaults: UserDefaults = AppDefaults.standard) -> String? {
|
||||
let stored = defaults.string(forKey: webChatThinkingLevelDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
@@ -1233,14 +1233,14 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
|
||||
return stored
|
||||
}
|
||||
|
||||
static func persistedVerboseLevel(defaults: UserDefaults = .standard) -> String? {
|
||||
static func persistedVerboseLevel(defaults: UserDefaults = AppDefaults.standard) -> String? {
|
||||
let stored = defaults.string(forKey: webChatVerboseLevelDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
return OpenClawChatViewModel.verboseLevelOptions.contains(stored ?? "") ? stored : nil
|
||||
}
|
||||
|
||||
static func persistVerbosePreference(_ level: String?, defaults: UserDefaults = .standard) {
|
||||
static func persistVerbosePreference(_ level: String?, defaults: UserDefaults = AppDefaults.standard) {
|
||||
if let level {
|
||||
defaults.set(level, forKey: webChatVerboseLevelDefaultsKey)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AppProfileSourceInvariantTests {
|
||||
@Test func `profile persistence has one defaults and gateway-label owner`() throws {
|
||||
let macRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceRoot = macRoot.appendingPathComponent("Sources/OpenClaw", isDirectory: true)
|
||||
let files = try #require(FileManager.default.enumerator(
|
||||
at: sourceRoot,
|
||||
includingPropertiesForKeys: nil)?.allObjects as? [URL])
|
||||
.filter { $0.pathExtension == "swift" }
|
||||
.sorted { $0.path < $1.path }
|
||||
|
||||
var directStandardOwners: [String] = []
|
||||
var hardcodedGatewayLabelOwners: [String] = []
|
||||
var unscopedAppStorageOwners: [String] = []
|
||||
for file in files {
|
||||
let source = try String(contentsOf: file, encoding: .utf8)
|
||||
if source.contains("UserDefaults.standard") {
|
||||
directStandardOwners.append(file.lastPathComponent)
|
||||
}
|
||||
if source.contains("\"ai.openclaw.gateway\"") {
|
||||
hardcodedGatewayLabelOwners.append(file.lastPathComponent)
|
||||
}
|
||||
if source.split(separator: "\n").contains(where: {
|
||||
$0.contains("@AppStorage(") && !$0.contains("store: AppDefaults.standard")
|
||||
}) {
|
||||
unscopedAppStorageOwners.append(file.lastPathComponent)
|
||||
}
|
||||
#expect(!source.contains("UserDefaults = .standard"), "Unscoped defaults in \(file.path)")
|
||||
}
|
||||
|
||||
#expect(directStandardOwners == ["AppProfile.swift"])
|
||||
#expect(hardcodedGatewayLabelOwners == ["AppProfile.swift"])
|
||||
#expect(unscopedAppStorageOwners.sorted() == ["DebugSettings.swift", "GeneralSettings.swift"])
|
||||
let settingsRoot = try String(
|
||||
contentsOf: sourceRoot.appendingPathComponent("SettingsRootView.swift"),
|
||||
encoding: .utf8)
|
||||
#expect(settingsRoot.contains(".defaultAppStorage(AppDefaults.standard)"))
|
||||
|
||||
let menuBar = try String(
|
||||
contentsOf: sourceRoot.appendingPathComponent("MenuBar.swift"),
|
||||
encoding: .utf8)
|
||||
let delegateInit = try #require(menuBar.range(of: "override init()"))
|
||||
let ownershipGate = try #require(menuBar.range(
|
||||
of: "AppInstanceLock.acquire",
|
||||
range: delegateInit.lowerBound..<menuBar.endIndex))
|
||||
let updaterConstruction = try #require(menuBar.range(
|
||||
of: "? makeUpdaterController()",
|
||||
range: ownershipGate.lowerBound..<menuBar.endIndex))
|
||||
#expect(ownershipGate.lowerBound < updaterConstruction.lowerBound)
|
||||
#expect(menuBar.contains("if let exitCode = Self.processExitCode(for: ownership)"))
|
||||
#expect(menuBar.contains("Darwin.exit(exitCode)"))
|
||||
#expect(!menuBar.contains("@State private var tailscaleService = TailscaleService.shared"))
|
||||
|
||||
let gatewayManager = try String(
|
||||
contentsOf: sourceRoot.appendingPathComponent("GatewayProcessManager.swift"),
|
||||
encoding: .utf8)
|
||||
#expect(gatewayManager.components(separatedBy: "profileOwnsGateway(").count - 1 >= 5)
|
||||
|
||||
let portGuardian = try String(
|
||||
contentsOf: sourceRoot.appendingPathComponent("PortGuardian.swift"),
|
||||
encoding: .utf8)
|
||||
let profilePreserve = try #require(portGuardian.range(of: "if AppProfile.current.isActive"))
|
||||
let firstTerminate = try #require(portGuardian.range(
|
||||
of: "terminateProcess",
|
||||
range: profilePreserve.lowerBound..<portGuardian.endIndex))
|
||||
#expect(profilePreserve.lowerBound < firstTerminate.lowerBound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
@testable import OpenClawKit
|
||||
|
||||
@Suite(.serialized)
|
||||
struct AppProfileTests {
|
||||
@Test func `default profile preserves historical identities`() {
|
||||
if AppProfile.current.name == nil, AppProfile.current.validationError == nil {
|
||||
#expect(AppDefaults.standard === UserDefaults.standard)
|
||||
}
|
||||
for raw in [nil, "", " default ", "Default"] {
|
||||
let profile = AppProfile(environment: raw.map { ["OPENCLAW_PROFILE": $0] } ?? [:])
|
||||
#expect(profile.name == nil)
|
||||
#expect(profile.validationError == nil)
|
||||
#expect(profile.gatewayLaunchAgentLabel == "ai.openclaw.gateway")
|
||||
#expect(profile.defaultsSuiteName == nil)
|
||||
#expect(profile.keychainService(base: "ai.openclaw.test") == "ai.openclaw.test")
|
||||
#expect(GatewayTLSStore.resolvedKeychainService(suffix: profile.keychainServiceSuffix) ==
|
||||
"ai.openclaw.tls-pinning")
|
||||
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")).path ==
|
||||
"/Users/test/.openclaw")
|
||||
#expect(profile.cliRootArguments.isEmpty)
|
||||
}
|
||||
#if DEBUG
|
||||
#expect(MacGatewayProfileStore.service == "ai.openclaw.gateway-profiles.debug")
|
||||
#expect(GatewayActivationBindingKeyStore.service == "ai.openclaw.onboarding-route-binding.debug")
|
||||
#else
|
||||
#expect(MacGatewayProfileStore.service == "ai.openclaw.gateway-profiles")
|
||||
#expect(GatewayActivationBindingKeyStore.service == "ai.openclaw.onboarding-route-binding")
|
||||
#endif
|
||||
}
|
||||
|
||||
@Test func `named profile owns every persistent namespace`() {
|
||||
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work_2"])
|
||||
#expect(profile.name == "work_2")
|
||||
#expect(profile.validationError == nil)
|
||||
#expect(profile.gatewayLaunchAgentLabel == "ai.openclaw.work_2")
|
||||
#expect(profile.defaultsSuiteName == "ai.openclaw.mac.profile.work_2")
|
||||
#expect(profile.keychainService(base: "ai.openclaw.test") == "ai.openclaw.test.profile.work_2")
|
||||
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")).path ==
|
||||
"/Users/test/.openclaw-work_2")
|
||||
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")) !=
|
||||
AppProfile(environment: ["OPENCLAW_PROFILE": "personal"])
|
||||
.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")))
|
||||
#expect(profile.cliRootArguments == ["--profile", "work_2"])
|
||||
#expect(GatewayTLSStore.resolvedKeychainService(suffix: profile.keychainServiceSuffix) ==
|
||||
"ai.openclaw.tls-pinning.profile.work_2")
|
||||
#expect(CommandResolver.nodeHostWorkerCommand(
|
||||
prefix: ["/usr/bin/node", "/repo/scripts/run-node.mjs"],
|
||||
profile: profile) == [
|
||||
"/usr/bin/node", "/repo/scripts/run-node.mjs", "--profile", "work_2", "node", "worker",
|
||||
])
|
||||
#expect(CommandResolver.nodeHostWorkerCommand(
|
||||
prefix: ["/opt/openclaw"],
|
||||
profile: profile) == [
|
||||
"/opt/openclaw", "--profile", "work_2", "node", "worker",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `default worker commands preserve exact argument shapes`() {
|
||||
let profile = AppProfile(environment: [:])
|
||||
#expect(CommandResolver.nodeHostWorkerCommand(
|
||||
prefix: ["/usr/bin/node", "/repo/scripts/run-node.mjs"],
|
||||
profile: profile) == ["/usr/bin/node", "/repo/scripts/run-node.mjs", "node", "worker"])
|
||||
#expect(CommandResolver.nodeHostWorkerCommand(
|
||||
prefix: ["/opt/openclaw"],
|
||||
profile: profile) == ["/opt/openclaw", "node", "worker"])
|
||||
}
|
||||
|
||||
@Test func `invalid and colliding profile names fail closed`() {
|
||||
let invalid = [
|
||||
"_work", "work space", "work/escape", String(repeating: "a", count: 65),
|
||||
"Work", "gateway", "mac", "node",
|
||||
]
|
||||
for raw in invalid {
|
||||
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": raw])
|
||||
#expect(profile.name == nil)
|
||||
#expect(profile.validationError != nil)
|
||||
}
|
||||
#expect(AppProfile(environment: ["OPENCLAW_PROFILE": String(repeating: "a", count: 64)]).name != nil)
|
||||
}
|
||||
|
||||
@Test func `colliding profile apps cannot reserve the same exact port`() throws {
|
||||
let root = try self.makeReservationRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let home = root.appendingPathComponent("home", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
|
||||
let first = ProfileGatewayPortReservation.acquire(
|
||||
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p1402"]),
|
||||
port: 55636,
|
||||
homeDirectory: home,
|
||||
temporaryDirectory: root)
|
||||
let second = ProfileGatewayPortReservation.acquire(
|
||||
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"]),
|
||||
port: 55636,
|
||||
homeDirectory: home,
|
||||
temporaryDirectory: root)
|
||||
|
||||
#expect(first.conflict == nil)
|
||||
#expect(second.conflict?.contains("p2380") == true)
|
||||
#expect(second.conflict?.contains("reservation reserves") == false)
|
||||
}
|
||||
|
||||
@Test func `persisted other profile gateway reserves its exact port`() throws {
|
||||
let root = try self.makeReservationRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let home = root.appendingPathComponent("home", isDirectory: true)
|
||||
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
|
||||
let serviceEnv = home.appendingPathComponent(".openclaw-p1402/service-env", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: serviceEnv, withIntermediateDirectories: true)
|
||||
let wrapper = serviceEnv.appendingPathComponent("ai.openclaw.p1402-env-wrapper.sh")
|
||||
let environment = serviceEnv.appendingPathComponent("ai.openclaw.p1402.env")
|
||||
try Data("#!/bin/sh\n".utf8).write(to: wrapper)
|
||||
try Data("""
|
||||
export OPENCLAW_SERVICE_MARKER='openclaw'
|
||||
export OPENCLAW_SERVICE_KIND='gateway'
|
||||
|
||||
""".utf8).write(to: environment)
|
||||
let data = try PropertyListSerialization.data(
|
||||
fromPropertyList: [
|
||||
"ProgramArguments": [
|
||||
"/bin/sh", wrapper.path, environment.path,
|
||||
"node", "openclaw.mjs", "gateway", "--port", "55636",
|
||||
],
|
||||
],
|
||||
format: .xml,
|
||||
options: 0)
|
||||
try data.write(to: agents.appendingPathComponent("ai.openclaw.p1402.plist"))
|
||||
|
||||
let reservation = ProfileGatewayPortReservation.acquire(
|
||||
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"]),
|
||||
port: 55636,
|
||||
homeDirectory: home,
|
||||
temporaryDirectory: root)
|
||||
|
||||
#expect(reservation.conflict?.contains("profile \"p1402\"") == true)
|
||||
}
|
||||
|
||||
private func makeReservationRoot() throws -> URL {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("app-profile-port-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
|
||||
return root
|
||||
}
|
||||
|
||||
@Test func `profile launch at login reason outranks bundle location`() {
|
||||
let profiled = LaunchAtLoginPresentation.resolve(
|
||||
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]),
|
||||
bundleLocationAllowsPersistentIntegration: false,
|
||||
isEnabled: false)
|
||||
#expect(profiled.subtitle == "Launch at login is unavailable while an app profile is active.")
|
||||
#expect(profiled.isDisabled)
|
||||
|
||||
let defaultUnavailable = LaunchAtLoginPresentation.resolve(
|
||||
profile: AppProfile(environment: [:]),
|
||||
bundleLocationAllowsPersistentIntegration: false,
|
||||
isEnabled: false)
|
||||
#expect(defaultUnavailable.subtitle == "Move OpenClaw to Applications before enabling launch at login.")
|
||||
#expect(defaultUnavailable.isDisabled)
|
||||
|
||||
let defaultAvailable = LaunchAtLoginPresentation.resolve(
|
||||
profile: AppProfile(environment: [:]),
|
||||
bundleLocationAllowsPersistentIntegration: true,
|
||||
isEnabled: false)
|
||||
#expect(defaultAvailable.subtitle == "Automatically start OpenClaw after you sign in.")
|
||||
#expect(!defaultAvailable.isDisabled)
|
||||
}
|
||||
|
||||
@MainActor @Test func `profile lock excludes only the same profile`() throws {
|
||||
#expect(AppDelegate.processExitCode(for: .busy) == 0)
|
||||
#expect(AppDelegate.processExitCode(for: .failed("test")) == nil)
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("app-profile-lock-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
|
||||
let defaultProfile = AppProfile(environment: [:])
|
||||
let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
let defaultURL = defaultProfile.instanceLockURL(systemTemporaryDirectory: root)
|
||||
let namedURL = workProfile.instanceLockURL(systemTemporaryDirectory: root)
|
||||
#expect(defaultURL != namedURL)
|
||||
#expect(workProfile.instanceLockURL(systemTemporaryDirectory: root) ==
|
||||
AppProfile(environment: [
|
||||
"OPENCLAW_PROFILE": "work",
|
||||
"OPENCLAW_STATE_DIR": "/different/state",
|
||||
]).instanceLockURL(systemTemporaryDirectory: root))
|
||||
var defaultLock: AppInstanceLock?
|
||||
switch AppInstanceLock.acquire(url: defaultURL) {
|
||||
case let .acquired(lock): defaultLock = lock
|
||||
case .busy, .failed: Issue.record("Expected default lock acquisition")
|
||||
}
|
||||
if case .busy = AppInstanceLock.acquire(url: defaultURL) {} else {
|
||||
Issue.record("Expected same-profile lock rejection")
|
||||
}
|
||||
var namedLock: AppInstanceLock?
|
||||
switch AppInstanceLock.acquire(url: namedURL) {
|
||||
case let .acquired(lock): namedLock = lock
|
||||
case .busy, .failed: Issue.record("Expected cross-profile lock acquisition")
|
||||
}
|
||||
#expect(defaultLock != nil)
|
||||
#expect(namedLock != nil)
|
||||
let defaultAttributes = try FileManager.default.attributesOfItem(
|
||||
atPath: defaultURL.deletingLastPathComponent().path)
|
||||
#expect((defaultAttributes[.posixPermissions] as? NSNumber)?.intValue == 0o700)
|
||||
defaultLock = nil
|
||||
namedLock = nil
|
||||
if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else {
|
||||
Issue.record("Expected released default lock to transfer")
|
||||
}
|
||||
var namedFirst: AppInstanceLock?
|
||||
if case let .acquired(lock) = AppInstanceLock.acquire(url: namedURL) {
|
||||
namedFirst = lock
|
||||
} else {
|
||||
Issue.record("Expected named-first lock acquisition")
|
||||
}
|
||||
if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else {
|
||||
Issue.record("Expected named-to-default coexistence")
|
||||
}
|
||||
#expect(namedFirst != nil)
|
||||
}
|
||||
|
||||
@Test func `profile lock reports unsafe ownership errors`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("app-profile-lock-error-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
|
||||
let target = root.appendingPathComponent("target")
|
||||
let link = root.appendingPathComponent("app-instance.lock")
|
||||
try Data().write(to: target)
|
||||
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target)
|
||||
if case .failed = AppInstanceLock.acquire(url: link) {} else {
|
||||
Issue.record("Expected symlink lock rejection")
|
||||
}
|
||||
|
||||
let unsafeURL = AppProfile(environment: ["OPENCLAW_PROFILE": "unsafe"])
|
||||
.instanceLockURL(systemTemporaryDirectory: root)
|
||||
let unsafe = unsafeURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: unsafe, withIntermediateDirectories: true)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: unsafe.path)
|
||||
if case .failed = AppInstanceLock.acquire(url: unsafeURL) {} else {
|
||||
Issue.record("Expected unsafe state directory rejection")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `long profile approvals socket uses deterministic private short path`() {
|
||||
let longState = URL(fileURLWithPath: "/Users/" + String(repeating: "very-long/", count: 16))
|
||||
let first = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true)
|
||||
let second = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true)
|
||||
#expect(first == second)
|
||||
#expect(first.hasPrefix("/tmp/openclaw-\(geteuid())/exec-approvals-"))
|
||||
#expect(first.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path))
|
||||
#expect(ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: false)
|
||||
.hasPrefix(longState.path))
|
||||
let oldCanonical = longState.appendingPathComponent("exec-approvals.sock").path
|
||||
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
|
||||
existing: nil,
|
||||
stateDirectoryURL: longState,
|
||||
computed: first) == first)
|
||||
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
|
||||
existing: oldCanonical,
|
||||
stateDirectoryURL: longState,
|
||||
computed: first) == first)
|
||||
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
|
||||
existing: "/custom/approvals.sock",
|
||||
stateDirectoryURL: longState,
|
||||
computed: first) == "/custom/approvals.sock")
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,23 @@ struct CLIInstallerTests {
|
||||
"--yes",
|
||||
"--no-restart",
|
||||
])
|
||||
|
||||
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
#expect(CLIInstaller.managedUpdateCommand(
|
||||
executable: "/opt/openclaw",
|
||||
targetVersion: "2026.7.4",
|
||||
profile: profile) == [
|
||||
"/opt/openclaw", "--profile", "work", "update", "--tag", "2026.7.4",
|
||||
"--json", "--timeout", "900",
|
||||
])
|
||||
#expect(CLIInstaller.managedUpdateCommand(
|
||||
executable: "/opt/openclaw",
|
||||
targetVersion: "2026.7.4",
|
||||
repair: true,
|
||||
profile: profile) == [
|
||||
"/opt/openclaw", "--profile", "work", "update", "repair", "--json",
|
||||
"--timeout", "900", "--yes",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `managed update parses structured updater diagnostics`() throws {
|
||||
|
||||
@@ -152,6 +152,35 @@ private actor GatewayEndpointRemoteEnsureGate {
|
||||
}
|
||||
|
||||
struct GatewayEndpointStoreTests {
|
||||
@MainActor
|
||||
@Test func `live local source uses canonical default and named profile ports`() async throws {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
try Data(#"{"gateway":{"mode":"local"}}"#.utf8)
|
||||
.write(to: URL(fileURLWithPath: configPath))
|
||||
defer { try? FileManager.default.removeItem(atPath: configPath) }
|
||||
|
||||
try await TestIsolation.withIsolatedState(
|
||||
env: [
|
||||
"OPENCLAW_CONFIG_PATH": configPath,
|
||||
"OPENCLAW_GATEWAY_PORT": nil,
|
||||
],
|
||||
defaults: ["gatewayPort": nil])
|
||||
{
|
||||
let state = AppState(preview: true)
|
||||
let base = await GatewayEndpointStore._testLiveSourceSnapshot(
|
||||
state: state,
|
||||
profile: AppProfile(environment: [:]),
|
||||
beforeConfigRead: {})
|
||||
let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
let work = await GatewayEndpointStore._testLiveSourceSnapshot(
|
||||
state: state,
|
||||
profile: workProfile,
|
||||
beforeConfigRead: {})
|
||||
#expect(base.localPort == 18789)
|
||||
#expect(work.localPort == workProfile.defaultGatewayPort)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeLaunchAgentSnapshot(
|
||||
env: [String: String] = [:],
|
||||
token: String? = nil,
|
||||
@@ -238,6 +267,7 @@ struct GatewayEndpointStoreTests {
|
||||
token: token,
|
||||
password: { nil },
|
||||
localPort: { 18789 },
|
||||
localUnavailableReason: { nil },
|
||||
remoteRouteIfRunning: remoteRouteIfRunning,
|
||||
remoteRouteIsCurrent: remoteRouteIsCurrent,
|
||||
canStartRemoteTunnel: canStartRemoteTunnel,
|
||||
@@ -264,6 +294,27 @@ struct GatewayEndpointStoreTests {
|
||||
return ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
}
|
||||
|
||||
@Test func `local conflict remains unavailable across refresh until cleared`() async throws {
|
||||
let source = self.source(mode: .local)
|
||||
let store = self.makeStore(sourceSnapshot: { source })
|
||||
|
||||
await store.setLocalUnavailableReason("Profile port conflict")
|
||||
await store.refresh()
|
||||
#expect(await store.currentState() == .unavailable(
|
||||
mode: .local,
|
||||
reason: "Profile port conflict"))
|
||||
await #expect(throws: Error.self) {
|
||||
_ = try await store.requireEndpoint()
|
||||
}
|
||||
|
||||
await store.setLocalUnavailableReason(nil)
|
||||
await store.refresh()
|
||||
guard case .ready = await store.currentState() else {
|
||||
Issue.record("Expected local endpoint to recover after conflict clears")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func dashboardURL(
|
||||
_ endpoint: String,
|
||||
mode: AppState.ConnectionMode,
|
||||
@@ -554,8 +605,7 @@ extension GatewayEndpointStoreTests {
|
||||
let sourceGate = GatewayEndpointSourceGate(source)
|
||||
let store = self.makeStore(
|
||||
sourceSnapshot: { await sourceGate.snapshot() },
|
||||
liveSourceIsCurrent: { $0.routingGeneration == 7 },
|
||||
)
|
||||
liveSourceIsCurrent: { $0.routingGeneration == 7 })
|
||||
|
||||
_ = try await store.requireEndpoint()
|
||||
#expect(await sourceGate.reads() == 1)
|
||||
@@ -651,8 +701,7 @@ extension GatewayEndpointStoreTests {
|
||||
let routeGate = GatewayEndpointRouteLookupGate()
|
||||
let store = self.makeStore(
|
||||
sourceSnapshot: { await sourceGate.snapshot() },
|
||||
remoteRouteIfRunning: { await routeGate.lookup() },
|
||||
)
|
||||
remoteRouteIfRunning: { await routeGate.lookup() })
|
||||
|
||||
let staleRequest = Task { try await store.requireEndpoint() }
|
||||
await routeGate.waitUntilStarted()
|
||||
@@ -741,8 +790,7 @@ extension GatewayEndpointStoreTests {
|
||||
let sourceGate = GatewayEndpointSourceGate(fallbackSource)
|
||||
let store = self.makeStore(
|
||||
sourceSnapshot: { await sourceGate.snapshot() },
|
||||
token: { "local-token" },
|
||||
)
|
||||
token: { "local-token" })
|
||||
let initialURL = try #require(URL(string: "ws://127.0.0.1:18789"))
|
||||
|
||||
await sourceGate.suspendNextRead()
|
||||
@@ -813,8 +861,7 @@ extension GatewayEndpointStoreTests {
|
||||
sourceSnapshot: { source },
|
||||
remoteRouteIfRunning: { await remoteGate.routeIfRunning() },
|
||||
remoteRouteIsCurrent: { await remoteGate.isCurrent($0) },
|
||||
ensureRemoteTunnel: { await remoteGate.ensure() },
|
||||
)
|
||||
ensureRemoteTunnel: { await remoteGate.ensure() })
|
||||
|
||||
let cancelledWaiter = Task { try await store.requireEndpoint() }
|
||||
await remoteGate.waitUntilEnsureStarts()
|
||||
@@ -849,8 +896,7 @@ extension GatewayEndpointStoreTests {
|
||||
sourceSnapshot: { source },
|
||||
remoteRouteIfRunning: { await remoteGate.routeIfRunning() },
|
||||
remoteRouteIsCurrent: { await remoteGate.isCurrent($0) },
|
||||
ensureRemoteTunnel: { await remoteGate.ensure() },
|
||||
)
|
||||
ensureRemoteTunnel: { await remoteGate.ensure() })
|
||||
|
||||
let first = Task { try await store.requireEndpoint() }
|
||||
await remoteGate.waitUntilEnsureStarts()
|
||||
|
||||
@@ -151,6 +151,43 @@ struct GatewayEnvironmentTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `named profiles derive stable distinct gateway ports after explicit precedence`() {
|
||||
let work = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
let personal = AppProfile(environment: ["OPENCLAW_PROFILE": "personal"])
|
||||
let workPort = GatewayEnvironment.resolvedGatewayPort(
|
||||
environment: [:],
|
||||
configPort: nil,
|
||||
storedPort: 0,
|
||||
profile: work)
|
||||
#expect((20000..<60000).contains(workPort))
|
||||
#expect(workPort == work.defaultGatewayPort)
|
||||
#expect(workPort != personal.defaultGatewayPort)
|
||||
#expect(GatewayEnvironment.resolvedGatewayPort(
|
||||
environment: ["OPENCLAW_GATEWAY_PORT": "21001"],
|
||||
configPort: 22001,
|
||||
storedPort: 23001,
|
||||
profile: work) == 21001)
|
||||
#expect(GatewayEnvironment.resolvedGatewayPort(
|
||||
environment: [:],
|
||||
configPort: 22001,
|
||||
storedPort: 23001,
|
||||
profile: work) == 22001)
|
||||
#expect(GatewayEnvironment.resolvedGatewayPort(
|
||||
environment: [:],
|
||||
configPort: nil,
|
||||
storedPort: 23001,
|
||||
profile: work) == 23001)
|
||||
#expect(AppProfile(environment: [:]).defaultGatewayPort == 18789)
|
||||
#expect(GatewayEnvironment.gatewayCommand(
|
||||
prefix: ["/opt/openclaw"],
|
||||
port: workPort,
|
||||
bind: "loopback",
|
||||
profile: work) == [
|
||||
"/opt/openclaw", "--profile", "work", "gateway", "--port", "\(workPort)",
|
||||
"--bind", "loopback",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `expected gateway version from string uses parser`() {
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
|
||||
|
||||
@@ -4,10 +4,116 @@ import Testing
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayLaunchAgentManagerTests {
|
||||
@Test func `gateway launchd artifacts follow default and named profile labels`() {
|
||||
let home = URL(fileURLWithPath: "/Users/test", isDirectory: true)
|
||||
let directory = URL(fileURLWithPath: "/state/service-env", isDirectory: true)
|
||||
let base = AppProfile(environment: [:])
|
||||
let work = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
|
||||
#expect(GatewayLaunchAgentManager.plistURL(homeDirectory: home, profile: base).path ==
|
||||
"/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist")
|
||||
#expect(GatewayLaunchAgentManager.plistURL(homeDirectory: home, profile: work).path ==
|
||||
"/Users/test/Library/LaunchAgents/ai.openclaw.work.plist")
|
||||
let baseArtifacts = GatewayLaunchAgentManager.generatedEnvironmentArtifacts(
|
||||
directory: directory,
|
||||
profile: base)
|
||||
let workArtifacts = GatewayLaunchAgentManager.generatedEnvironmentArtifacts(
|
||||
directory: directory,
|
||||
profile: work)
|
||||
#expect(baseArtifacts.environment.path == "/state/service-env/ai.openclaw.gateway.env")
|
||||
#expect(baseArtifacts.wrapper.path == "/state/service-env/ai.openclaw.gateway-env-wrapper.sh")
|
||||
#expect(workArtifacts.environment.path == "/state/service-env/ai.openclaw.work.env")
|
||||
#expect(workArtifacts.wrapper.path == "/state/service-env/ai.openclaw.work-env-wrapper.sh")
|
||||
}
|
||||
|
||||
@Test func `gateway daemon command selects named profile at the root`() async throws {
|
||||
let root = try makeTempDirForTests()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let executable = root.appendingPathComponent("node_modules/.bin/openclaw")
|
||||
try makeExecutableForTests(at: executable)
|
||||
let command = await CommandResolver.openclawCommand(
|
||||
subcommand: "gateway",
|
||||
extraArgs: ["status", "--json"],
|
||||
projectRoot: root,
|
||||
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]))
|
||||
|
||||
#expect(command == [
|
||||
executable.path, "--profile", "work", "gateway", "status", "--json",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `daemon commands tolerate first run state migrations`() {
|
||||
#expect(GatewayLaunchAgentManager.startupMigrationTolerance >= 120)
|
||||
}
|
||||
|
||||
@Test func `malformed canonical profile claims fail closed`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
|
||||
let data = try PropertyListSerialization.data(
|
||||
fromPropertyList: [
|
||||
"ProgramArguments": ["node", "openclaw.mjs", "gateway"],
|
||||
"EnvironmentVariables": [
|
||||
"OPENCLAW_SERVICE_MARKER": "openclaw",
|
||||
"OPENCLAW_SERVICE_KIND": "gateway",
|
||||
],
|
||||
],
|
||||
format: .xml,
|
||||
options: 0)
|
||||
try data.write(to: agents.appendingPathComponent("ai.openclaw.p1402.plist"))
|
||||
|
||||
let claim = GatewayLaunchAgentManager.conflictingProfileClaimOwner(
|
||||
port: 55636,
|
||||
excludingLabel: "ai.openclaw.p2380",
|
||||
homeDirectory: home)
|
||||
|
||||
#expect(claim?.contains("p1402") == true)
|
||||
}
|
||||
|
||||
@Test func `same prefix ssh tunnel is not a profile Gateway claim`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
|
||||
let data = try PropertyListSerialization.data(
|
||||
fromPropertyList: [
|
||||
"ProgramArguments": ["/usr/bin/ssh", "-N", "-L", "55636:127.0.0.1:18789"],
|
||||
],
|
||||
format: .xml,
|
||||
options: 0)
|
||||
try data.write(to: agents.appendingPathComponent("ai.openclaw.gateway-tunnel.plist"))
|
||||
try Data("not a plist".utf8).write(to: agents.appendingPathComponent("ai.openclaw.unrelated.plist"))
|
||||
|
||||
#expect(GatewayLaunchAgentManager.conflictingProfileClaimOwner(
|
||||
port: 55636,
|
||||
excludingLabel: "ai.openclaw.qa",
|
||||
homeDirectory: home) == nil)
|
||||
}
|
||||
|
||||
@Test func `default generated Gateway claim is recognized`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
|
||||
let data = try PropertyListSerialization.data(
|
||||
fromPropertyList: [
|
||||
"ProgramArguments": ["node", "openclaw.mjs", "gateway", "--port", "18789"],
|
||||
"EnvironmentVariables": [
|
||||
"OPENCLAW_SERVICE_MARKER": "openclaw",
|
||||
"OPENCLAW_SERVICE_KIND": "gateway",
|
||||
],
|
||||
],
|
||||
format: .xml,
|
||||
options: 0)
|
||||
try data.write(to: agents.appendingPathComponent("ai.openclaw.gateway.plist"))
|
||||
|
||||
#expect(GatewayLaunchAgentManager.conflictingProfileClaimOwner(
|
||||
port: 18789,
|
||||
excludingLabel: "ai.openclaw.qa",
|
||||
homeDirectory: home)?.contains("default") == true)
|
||||
}
|
||||
|
||||
@Test func `reads Gateway service ownership command directly from launchd`() throws {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("openclaw-gateway-\(UUID().uuidString).plist")
|
||||
|
||||
@@ -7,6 +7,29 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct GatewayProcessManagerTests {
|
||||
@Test func `colliding profile ports cannot attach another profile gateway`() {
|
||||
let first = AppProfile(environment: ["OPENCLAW_PROFILE": "p1402"])
|
||||
let second = AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"])
|
||||
#expect(first.defaultGatewayPort == 55636)
|
||||
#expect(second.defaultGatewayPort == 55636)
|
||||
#expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
||||
profile: first,
|
||||
listenerPID: 1402,
|
||||
managedServicePID: 1402))
|
||||
#expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
||||
profile: second,
|
||||
listenerPID: 1402,
|
||||
managedServicePID: 2380))
|
||||
#expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
||||
profile: second,
|
||||
listenerPID: 1402,
|
||||
managedServicePID: nil))
|
||||
#expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment(
|
||||
profile: AppProfile(environment: [:]),
|
||||
listenerPID: 1402,
|
||||
managedServicePID: nil))
|
||||
}
|
||||
|
||||
private func availableGatewayPort() throws -> Int {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else {
|
||||
@@ -114,12 +137,16 @@ struct GatewayProcessManagerTests {
|
||||
|
||||
private nonisolated func gatewayTask(
|
||||
healthSucceedsAfter unavailableResponses: Int?,
|
||||
stallsFirstHealthResponse: Bool = false) -> GatewayTestWebSocketTask
|
||||
stallsFirstHealthResponse: Bool = false,
|
||||
healthResponseGates: [AsyncTestGate] = []) -> GatewayTestWebSocketTask
|
||||
{
|
||||
GatewayTestWebSocketTask(
|
||||
sendHook: { task, message, sendIndex in
|
||||
guard sendIndex > 0 else { return }
|
||||
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
|
||||
if healthResponseGates.indices.contains(sendIndex - 1) {
|
||||
await healthResponseGates[sendIndex - 1].wait()
|
||||
}
|
||||
if stallsFirstHealthResponse, sendIndex == 1 { return }
|
||||
if unavailableResponses.map({ sendIndex <= $0 }) ?? true {
|
||||
let response = Data(
|
||||
@@ -1074,8 +1101,11 @@ struct GatewayProcessManagerTests {
|
||||
@Test func `new launchd gateway can cross multiple readiness deadlines`() async throws {
|
||||
let port = 19116
|
||||
let url = try #require(URL(string: "ws://example.invalid"))
|
||||
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
||||
self.gatewayTask(healthSucceedsAfter: 2)
|
||||
let responseGates = [AsyncTestGate(), AsyncTestGate()]
|
||||
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
|
||||
self.gatewayTask(
|
||||
healthSucceedsAfter: 2,
|
||||
healthResponseGates: responseGates)
|
||||
}
|
||||
defer { manager.setTestingConnection(nil) }
|
||||
|
||||
@@ -1097,8 +1127,18 @@ struct GatewayProcessManagerTests {
|
||||
manager._testStartLaunchdGatewayReadiness(
|
||||
port: port,
|
||||
pid: 4242,
|
||||
readinessWindow: 0.05,
|
||||
firstInstallReadinessBudget: 0.15)
|
||||
readinessWindow: 0.2,
|
||||
firstInstallReadinessBudget: 5)
|
||||
// Release each response only after its 200 ms window so the test owns
|
||||
// both deadline crossings instead of depending on runner scheduling.
|
||||
await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 2 }
|
||||
#expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 2)
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
responseGates[0].open()
|
||||
await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 3 }
|
||||
#expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 3)
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
responseGates[1].open()
|
||||
await manager.waitForStartupAttempt()
|
||||
|
||||
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
|
||||
|
||||
@@ -3,6 +3,19 @@ import Testing
|
||||
|
||||
@MainActor
|
||||
struct GeneralSettingsStatusTests {
|
||||
@Test func `local profile conflict outranks connected state`() {
|
||||
let status = GeneralStatusPresentation.resolve(
|
||||
mode: .local,
|
||||
isPaused: true,
|
||||
controlState: .connected,
|
||||
localFailure: "Profile p2380 cannot use Gateway port 55636")
|
||||
|
||||
#expect(status.title == "OpenClaw needs attention")
|
||||
#expect(status.subtitle.contains("55636"))
|
||||
#expect(status.tone == .attention)
|
||||
#expect(status.showsConnectionAction)
|
||||
}
|
||||
|
||||
@Test func `connected remote gateway is healthy`() {
|
||||
let status = GeneralStatusPresentation.resolve(
|
||||
mode: .remote,
|
||||
|
||||
@@ -3,6 +3,26 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct LaunchAgentManagerTests {
|
||||
@Test func `active profile performs no login agent reads or writes`() async {
|
||||
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
var writes: [String] = []
|
||||
LaunchAgentManager._testResetLaunchctlCalls()
|
||||
|
||||
#expect(await !(LaunchAgentManager.status(profile: profile)))
|
||||
#expect(await !(LaunchAgentManager.set(
|
||||
enabled: true,
|
||||
bundlePath: "/Applications/OpenClaw.app",
|
||||
profile: profile,
|
||||
writePlist: { writes.append($0) })))
|
||||
#expect(await !(LaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: "/Applications/OpenClaw.app",
|
||||
profile: profile,
|
||||
writePlist: { writes.append($0) })))
|
||||
#expect(writes.isEmpty)
|
||||
#expect(LaunchAgentManager._testLaunchctlCallSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `enabling an already loaded login job only refreshes its plist`() async {
|
||||
var persistedBundlePaths: [String] = []
|
||||
let reloaded = await LaunchAgentManager.set(
|
||||
|
||||
@@ -293,8 +293,10 @@ struct MacNodeHostWorkerTests {
|
||||
|
||||
@Test func `ready worker exit notifies its route owner`() async throws {
|
||||
try await confirmation("unexpected worker exit") { confirmed in
|
||||
let exitGate = AsyncTestGate()
|
||||
let worker = MacNodeHostWorker(session: GatewayNodeSession()) {
|
||||
confirmed()
|
||||
exitGate.open()
|
||||
}
|
||||
let script = """
|
||||
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
|
||||
@@ -304,7 +306,7 @@ struct MacNodeHostWorkerTests {
|
||||
|
||||
_ = try await worker.start(launch: MacNodeHostWorkerLaunch(
|
||||
command: ["/bin/sh", "-c", script]))
|
||||
try? await Task.sleep(for: .milliseconds(200))
|
||||
await exitGate.wait()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,20 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct NodeServiceManagerTests {
|
||||
@Test func `active profile performs no persistent node service work`() async {
|
||||
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
NodeServiceManager._testResetPersistentServiceCalls()
|
||||
|
||||
#expect(await NodeServiceManager.start(profile: profile) == nil)
|
||||
#expect(await NodeServiceManager.stop(profile: profile) == nil)
|
||||
#expect(await NodeServiceManager.restart(profile: profile) == nil)
|
||||
#expect(NodeServiceManager.launchdProgramArguments(profile: profile) == [])
|
||||
#expect(await !(NodeServiceManager.waitUntilRunning(profile: profile)))
|
||||
let snapshot = NodeServiceManager._testPersistentServiceCallSnapshot()
|
||||
#expect(snapshot.commands.isEmpty)
|
||||
#expect(snapshot.ownershipReads == 0)
|
||||
}
|
||||
|
||||
@Test func `builds node service commands with current CLI shape`() async throws {
|
||||
try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) {
|
||||
let tmp = try makeTempDirForTests()
|
||||
|
||||
@@ -6,6 +6,20 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct NodesStoreTests {
|
||||
@Test func `named profile keeps durable node service unavailability informational`() {
|
||||
let store = NodesStore(
|
||||
appProfile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]),
|
||||
localNodeIdentityProfile: .node,
|
||||
localNodeIDLoader: { _ in nil })
|
||||
|
||||
#expect(store.persistentServiceNotice ==
|
||||
"Persistent Mac node service unavailable under app profile; runtime node remains available.")
|
||||
#expect(store.lastError == nil)
|
||||
store.statusMessage = "Refreshing devices…"
|
||||
#expect(store.persistentServiceNotice != nil)
|
||||
#expect(store.lastError == nil)
|
||||
}
|
||||
|
||||
@Test func `local node identity is prepared once`() async {
|
||||
let loader = LocalNodeIdentityLoader(results: ["node-id"])
|
||||
let store = NodesStore(
|
||||
|
||||
@@ -68,7 +68,7 @@ enum TestIsolation {
|
||||
}
|
||||
}
|
||||
|
||||
let userDefaults = UserDefaults.standard
|
||||
let userDefaults = AppDefaults.standard
|
||||
var previousDefaults: [String: Any?] = [:]
|
||||
for (key, value) in defaults {
|
||||
previousDefaults[key] = userDefaults.object(forKey: key)
|
||||
|
||||
@@ -7,6 +7,19 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct UpdateOrchestrationTests {
|
||||
@MainActor
|
||||
@Test func `post update performs no service work under active profile`() {
|
||||
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
||||
NodeServiceManager._testResetPersistentServiceCalls()
|
||||
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
|
||||
|
||||
#expect(!PostUpdateController.shared.startIfNeeded(profile: profile))
|
||||
let snapshot = NodeServiceManager._testPersistentServiceCallSnapshot()
|
||||
#expect(snapshot.commands.isEmpty)
|
||||
#expect(snapshot.ownershipReads == 0)
|
||||
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
|
||||
}
|
||||
|
||||
@Test func `Sparkle receipt appears only after the target app launches`() throws {
|
||||
let suite = "UpdateOrchestrationTests.post-update.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
@@ -340,7 +353,7 @@ struct UpdateOrchestrationTests {
|
||||
onStart: { starts += 1 })
|
||||
|
||||
updater.startAfterResolvingGatewayUpdateChannel()
|
||||
for _ in 0 ..< 10 where !updater.isAvailable {
|
||||
for _ in 0..<10 where !updater.isAvailable {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
@@ -356,7 +369,7 @@ struct UpdateOrchestrationTests {
|
||||
onStart: { starts += 1 })
|
||||
|
||||
updater.startAfterResolvingGatewayUpdateChannel()
|
||||
for _ in 0 ..< 10 where !updater.isAvailable {
|
||||
for _ in 0..<10 where !updater.isAvailable {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,29 @@ public struct DeviceIdentity: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceIdentityStateRootState {
|
||||
private(set) var url: URL?
|
||||
private(set) var used = false
|
||||
|
||||
mutating func configure(_ url: URL) -> Bool {
|
||||
let normalized = url.standardizedFileURL
|
||||
if let configured = self.url { return configured == normalized }
|
||||
guard !self.used else { return false }
|
||||
self.url = normalized
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func resolve() -> URL? {
|
||||
self.used = true
|
||||
return self.url
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceIdentityPaths {
|
||||
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
|
||||
@TaskLocal static var scopedStateDirURL: URL?
|
||||
private static let configuredStateLock = NSLock()
|
||||
private nonisolated(unsafe) static var configuredState = DeviceIdentityStateRootState()
|
||||
|
||||
/// Entitlements are baked into the code signature, so resolve the gate once per process.
|
||||
/// Every identity load and DeviceAuthStore read/write resolves the state dir through here;
|
||||
@@ -65,6 +85,10 @@ enum DeviceIdentityPaths {
|
||||
temporaryDirectory: FileManager.default.temporaryDirectory)
|
||||
}
|
||||
|
||||
static func configureStateDirURL(_ url: URL) -> Bool {
|
||||
self.configuredStateLock.withLock { self.configuredState.configure(url) }
|
||||
}
|
||||
|
||||
static func stateDirURL(
|
||||
overrideURL: URL?,
|
||||
legacyStateDirURL: URL?,
|
||||
@@ -90,6 +114,9 @@ enum DeviceIdentityPaths {
|
||||
if let scopedStateDirURL {
|
||||
return scopedStateDirURL
|
||||
}
|
||||
if let configured = self.configuredStateLock.withLock({ self.configuredState.resolve() }) {
|
||||
return configured
|
||||
}
|
||||
for key in self.stateDirEnv {
|
||||
if let raw = getenv(key) {
|
||||
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -204,6 +231,11 @@ public enum DeviceIdentityStore {
|
||||
self.loadOrCreate(profile: .primary)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func configureStateDirectory(_ url: URL) -> Bool {
|
||||
DeviceIdentityPaths.configureStateDirURL(url)
|
||||
}
|
||||
|
||||
#if compiler(>=6.4)
|
||||
nonisolated(nonsending) static func withStateDirectory<T>(
|
||||
_ url: URL,
|
||||
|
||||
@@ -264,6 +264,25 @@ struct GatewayTLSKeychainOperations: @unchecked Sendable {
|
||||
delete: { SecItemDelete($0) })
|
||||
}
|
||||
|
||||
struct GatewayTLSKeychainNamespaceState {
|
||||
private(set) var suffix: String?
|
||||
private(set) var used = false
|
||||
|
||||
mutating func configure(suffix: String) -> Bool {
|
||||
if let configured = self.suffix {
|
||||
return configured == suffix
|
||||
}
|
||||
guard !self.used || suffix.isEmpty else { return false }
|
||||
self.suffix = suffix
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func service(base: String) -> String {
|
||||
self.used = true
|
||||
return base + (self.suffix ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayTLSStore {
|
||||
@TaskLocal static var keychainOperations = GatewayTLSKeychainOperations.live
|
||||
|
||||
@@ -273,7 +292,19 @@ public enum GatewayTLSStore {
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private static let keychainService = "ai.openclaw.tls-pinning"
|
||||
private static let baseKeychainService = "ai.openclaw.tls-pinning"
|
||||
private static let keychainServiceLock = NSLock()
|
||||
private nonisolated(unsafe) static var keychainNamespace = GatewayTLSKeychainNamespaceState()
|
||||
private static var keychainService: String {
|
||||
self.keychainServiceLock.withLock {
|
||||
self.keychainNamespace.service(base: self.baseKeychainService)
|
||||
}
|
||||
}
|
||||
|
||||
private static var usesDefaultKeychainService: Bool {
|
||||
self.keychainServiceLock.withLock { (self.keychainNamespace.suffix ?? "").isEmpty }
|
||||
}
|
||||
|
||||
private static let keychainAccountPrefix = "fingerprint.v3."
|
||||
private static let legacyCanonicalAccountPrefix = "fingerprint.v2."
|
||||
|
||||
@@ -282,6 +313,19 @@ public enum GatewayTLSStore {
|
||||
private static let legacyKeyPrefix = "gateway.tls."
|
||||
private static let firstUseClaims = GatewayTLSFirstUseClaims()
|
||||
|
||||
/// The macOS app profile is immutable for the process lifetime. Configure its
|
||||
/// Keychain namespace before constructing any Gateway connection.
|
||||
@discardableResult
|
||||
public static func configureKeychainServiceSuffix(_ suffix: String) -> Bool {
|
||||
self.keychainServiceLock.withLock {
|
||||
self.keychainNamespace.configure(suffix: suffix)
|
||||
}
|
||||
}
|
||||
|
||||
static func resolvedKeychainService(suffix: String) -> String {
|
||||
self.baseKeychainService + suffix
|
||||
}
|
||||
|
||||
public static func loadFingerprint(stableID: String) -> String? {
|
||||
guard case let .value(fingerprint) = self.loadFingerprintResult(stableID: stableID) else {
|
||||
return nil
|
||||
@@ -512,6 +556,7 @@ public enum GatewayTLSStore {
|
||||
}
|
||||
|
||||
private static func readLegacyDefaultsFingerprint(stableID: String) -> FingerprintRead {
|
||||
guard self.usesDefaultKeychainService else { return .missing }
|
||||
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return .unavailable }
|
||||
let key = self.legacyKeyPrefix + stableID
|
||||
guard let value = defaults.object(forKey: key) else { return .missing }
|
||||
@@ -608,8 +653,10 @@ public enum GatewayTLSStore {
|
||||
} ?? true
|
||||
guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return removedV2 }
|
||||
let removedRaw = self.deleteFingerprint(account: stableID)
|
||||
UserDefaults(suiteName: self.legacySuiteName)?
|
||||
.removeObject(forKey: self.legacyKeyPrefix + stableID)
|
||||
if self.usesDefaultKeychainService {
|
||||
UserDefaults(suiteName: self.legacySuiteName)?
|
||||
.removeObject(forKey: self.legacyKeyPrefix + stableID)
|
||||
}
|
||||
return removedRaw && removedV2
|
||||
}
|
||||
|
||||
@@ -624,6 +671,7 @@ public enum GatewayTLSStore {
|
||||
}
|
||||
|
||||
private static func clearAllLegacyFingerprints() {
|
||||
guard self.usesDefaultKeychainService else { return }
|
||||
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
|
||||
for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(self.legacyKeyPrefix) {
|
||||
defaults.removeObject(forKey: key)
|
||||
|
||||
@@ -74,6 +74,30 @@ private func deviceAuthEntry(
|
||||
|
||||
@Suite(.serialized)
|
||||
struct DeviceIdentityStoreTests {
|
||||
@Test func `process state root configures once before identity use`() {
|
||||
var state = DeviceIdentityStateRootState()
|
||||
let work = URL(fileURLWithPath: "/Users/test/.openclaw-work", isDirectory: true)
|
||||
let other = URL(fileURLWithPath: "/Users/test/.openclaw-other", isDirectory: true)
|
||||
let configuredWork = state.configure(work)
|
||||
let reconfiguredWork = state.configure(work)
|
||||
let configuredOther = state.configure(other)
|
||||
let resolvedWork = state.resolve()
|
||||
let configuredWorkAfterUse = state.configure(work)
|
||||
let configuredOtherAfterUse = state.configure(other)
|
||||
#expect(configuredWork)
|
||||
#expect(reconfiguredWork)
|
||||
#expect(!configuredOther)
|
||||
#expect(resolvedWork == work)
|
||||
#expect(configuredWorkAfterUse)
|
||||
#expect(!configuredOtherAfterUse)
|
||||
|
||||
var usedDefault = DeviceIdentityStateRootState()
|
||||
let resolvedDefault = usedDefault.resolve()
|
||||
let configuredDefaultAfterUse = usedDefault.configure(work)
|
||||
#expect(resolvedDefault == nil)
|
||||
#expect(!configuredDefaultAfterUse)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `task scoped state directories isolate concurrent identity stores`() async throws {
|
||||
let fixture = DeviceIdentityMigrationFixture()
|
||||
|
||||
@@ -105,6 +105,30 @@ private func gatewayTLSTestTrust(systemTrusted: Bool) throws -> SecTrust {
|
||||
}
|
||||
|
||||
struct GatewayTLSPinningTests {
|
||||
@Test func `keychain namespace configures once and fails closed after use`() {
|
||||
var state = GatewayTLSKeychainNamespaceState()
|
||||
let configuredWork = state.configure(suffix: ".profile.work")
|
||||
let reconfiguredWork = state.configure(suffix: ".profile.work")
|
||||
let configuredOther = state.configure(suffix: ".profile.other")
|
||||
let workService = state.service(base: "ai.openclaw.tls-pinning")
|
||||
let configuredWorkAfterUse = state.configure(suffix: ".profile.work")
|
||||
let configuredDefaultAfterUse = state.configure(suffix: "")
|
||||
#expect(configuredWork)
|
||||
#expect(reconfiguredWork)
|
||||
#expect(!configuredOther)
|
||||
#expect(workService == "ai.openclaw.tls-pinning.profile.work")
|
||||
#expect(configuredWorkAfterUse)
|
||||
#expect(!configuredDefaultAfterUse)
|
||||
|
||||
var usedDefault = GatewayTLSKeychainNamespaceState()
|
||||
let defaultService = usedDefault.service(base: "ai.openclaw.tls-pinning")
|
||||
let configuredDefault = usedDefault.configure(suffix: "")
|
||||
let configuredProfileAfterDefaultUse = usedDefault.configure(suffix: ".profile.work")
|
||||
#expect(defaultService == "ai.openclaw.tls-pinning")
|
||||
#expect(configuredDefault)
|
||||
#expect(!configuredProfileAfterDefaultUse)
|
||||
}
|
||||
|
||||
private func withFakeKeychain<T>(_ operation: (GatewayTLSFakeKeychain) throws -> T) rethrows -> T {
|
||||
let keychain = GatewayTLSFakeKeychain()
|
||||
return try GatewayTLSStore.$keychainOperations.withValue(keychain.operations) {
|
||||
|
||||
+15
-8
@@ -153,6 +153,12 @@ fi
|
||||
if [[ "$TARGET_ONLY" -eq 1 && -n "$APP_BUNDLE" ]]; then
|
||||
fail "--target-only does not accept OPENCLAW_APP_BUNDLE"
|
||||
fi
|
||||
if [[ -n "${OPENCLAW_PROFILE:-}" ]]; then
|
||||
normalized_profile="$(printf '%s' "${OPENCLAW_PROFILE}" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "${normalized_profile}" != "default" ]]; then
|
||||
fail "restart-mac.sh cannot safely target one app profile; launch that profile directly instead"
|
||||
fi
|
||||
fi
|
||||
canonicalize_app_bundle
|
||||
|
||||
mkdir -p "$(dirname "$LOG_PATH")"
|
||||
@@ -505,14 +511,15 @@ fi
|
||||
# 4) Launch the installed app in the foreground so the menu bar extra appears.
|
||||
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
|
||||
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
|
||||
run_step "launch app" env -i \
|
||||
HOME="${HOME}" \
|
||||
USER="${USER:-$(id -un)}" \
|
||||
LOGNAME="${LOGNAME:-$(id -un)}" \
|
||||
TMPDIR="${TMPDIR:-/tmp}" \
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
LANG="${LANG:-en_US.UTF-8}" \
|
||||
/usr/bin/open "${OPEN_ARGS[@]}"
|
||||
LAUNCH_ENV=(
|
||||
"HOME=${HOME}"
|
||||
"USER=${USER:-$(id -un)}"
|
||||
"LOGNAME=${LOGNAME:-$(id -un)}"
|
||||
"TMPDIR=${TMPDIR:-/tmp}"
|
||||
"PATH=/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
"LANG=${LANG:-en_US.UTF-8}"
|
||||
)
|
||||
run_step "launch app" env -i "${LAUNCH_ENV[@]}" /usr/bin/open "${OPEN_ARGS[@]}"
|
||||
|
||||
# 5) Verify the app is alive.
|
||||
sleep 1.5
|
||||
|
||||
+107
-35
@@ -263,9 +263,11 @@ async function runRestartLaunchAgentWithFakeTimers(args: Parameters<typeof resta
|
||||
}
|
||||
}
|
||||
|
||||
function expectLaunchctlEnableBootstrapOrder(env: Record<string, string | undefined>) {
|
||||
function expectLaunchctlEnableBootstrapOrder(
|
||||
env: Record<string, string | undefined>,
|
||||
label = "ai.openclaw.gateway",
|
||||
) {
|
||||
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
|
||||
const label = "ai.openclaw.gateway";
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
const serviceId = `${domain}/${label}`;
|
||||
const enableIndex = state.launchctlCalls.findIndex(
|
||||
@@ -1643,11 +1645,9 @@ describe("launchd install", () => {
|
||||
"print",
|
||||
"bootout",
|
||||
"unload",
|
||||
"bootout",
|
||||
"unload",
|
||||
"enable",
|
||||
"bootstrap",
|
||||
"bootout",
|
||||
"print",
|
||||
"enable",
|
||||
"bootstrap",
|
||||
]);
|
||||
@@ -1742,14 +1742,7 @@ describe("launchd install", () => {
|
||||
expect(state.files.get(plistPath)).toBe(previous);
|
||||
expect(state.serviceLoaded).toBe(false);
|
||||
expect(state.serviceRunning).toBe(false);
|
||||
expect(launchctlCommandNames()).toEqual([
|
||||
"print",
|
||||
"bootout",
|
||||
"unload",
|
||||
"enable",
|
||||
"bootstrap",
|
||||
"bootout",
|
||||
]);
|
||||
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]);
|
||||
});
|
||||
|
||||
it("removes generated artifacts after a failed fresh install", async () => {
|
||||
@@ -1761,19 +1754,49 @@ describe("launchd install", () => {
|
||||
state.serviceRunning = false;
|
||||
state.bootstrapError = "Operation not permitted";
|
||||
state.bootstrapTransient = true;
|
||||
state.bootoutError = "Boot-out failed: 5: Input/output error";
|
||||
state.bootoutCode = 5;
|
||||
|
||||
await expect(
|
||||
installLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
programArguments: defaultProgramArguments,
|
||||
environment: { OPENCLAW_GATEWAY_PORT: "19000" },
|
||||
}),
|
||||
).rejects.toThrow("launchctl bootstrap failed: Operation not permitted");
|
||||
const error = await installLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
programArguments: defaultProgramArguments,
|
||||
environment: { OPENCLAW_GATEWAY_PORT: "19000" },
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("launchctl bootstrap failed: Operation not permitted");
|
||||
expect(state.files.has(plistPath)).toBe(false);
|
||||
expect(state.files.has(envFilePath)).toBe(false);
|
||||
expect(state.files.has(wrapperPath)).toBe(false);
|
||||
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]);
|
||||
});
|
||||
|
||||
it("fails closed when rollback cannot determine the replacement state", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
state.printNotLoadedRemaining = 1;
|
||||
state.printError = "launchctl print permission denied";
|
||||
state.printFailuresRemaining = 1;
|
||||
state.bootstrapError = "Operation not permitted";
|
||||
|
||||
const error = await installLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
programArguments: defaultProgramArguments,
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(
|
||||
"launchctl bootstrap failed: Operation not permitted",
|
||||
);
|
||||
expect((error as Error).message).toContain(
|
||||
"The previous LaunchAgent supervision could not be restored.",
|
||||
);
|
||||
expect((error as Error).cause).toBeInstanceOf(Error);
|
||||
expect(((error as Error).cause as Error).message).toContain("could not determine whether");
|
||||
expect(state.files.has(plistPath)).toBe(true);
|
||||
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]);
|
||||
});
|
||||
|
||||
it("restores the exact prior plist and supervision after external bootstrap failure", async () => {
|
||||
@@ -1826,7 +1849,7 @@ describe("launchd install", () => {
|
||||
"unload",
|
||||
"enable",
|
||||
"bootstrap",
|
||||
"bootout",
|
||||
"print",
|
||||
"enable",
|
||||
"bootstrap",
|
||||
]);
|
||||
@@ -1892,22 +1915,71 @@ describe("launchd install", () => {
|
||||
expect(state.launchctlCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("enables service before bootstrap without self-restarting the fresh agent", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
await installLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
it.each([
|
||||
{
|
||||
name: "default gateway",
|
||||
env: createDefaultLaunchdEnv(),
|
||||
label: "ai.openclaw.gateway",
|
||||
programArguments: defaultProgramArguments,
|
||||
});
|
||||
},
|
||||
{
|
||||
name: "profiled gateway",
|
||||
env: { HOME: "/Users/test", OPENCLAW_PROFILE: "qa" },
|
||||
label: "ai.openclaw.qa",
|
||||
programArguments: defaultProgramArguments,
|
||||
},
|
||||
{
|
||||
name: "node service",
|
||||
env: { HOME: "/Users/test", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node" },
|
||||
label: "ai.openclaw.node",
|
||||
programArguments: ["node", "node-host.js"],
|
||||
},
|
||||
])(
|
||||
"installs a fresh $name without booting out the absent job",
|
||||
async ({ env, label, programArguments }) => {
|
||||
state.bootoutError = "Boot-out failed: 5: Input/output error";
|
||||
state.bootoutCode = 5;
|
||||
await installLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
programArguments,
|
||||
});
|
||||
|
||||
const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? "";
|
||||
expect(plist).toContain("<key>Comment</key>\n <string>OpenClaw Gateway</string>");
|
||||
expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION");
|
||||
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env);
|
||||
const installKickstartIndex = state.launchctlCalls.findIndex(
|
||||
(c) => c[0] === "kickstart" && c[2] === serviceId,
|
||||
const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? "";
|
||||
expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION");
|
||||
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env, label);
|
||||
const installKickstartIndex = state.launchctlCalls.findIndex(
|
||||
(c) => c[0] === "kickstart" && c[2] === serviceId,
|
||||
);
|
||||
expect(installKickstartIndex).toBe(-1);
|
||||
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps loaded reinstall deactivation failures fatal", async () => {
|
||||
const env = createDefaultLaunchdEnv();
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
state.files.set(
|
||||
plistPath,
|
||||
createTestLaunchAgentPlist({
|
||||
label: "ai.openclaw.gateway",
|
||||
programArguments: defaultProgramArguments,
|
||||
}),
|
||||
);
|
||||
expect(installKickstartIndex).toBe(-1);
|
||||
state.bootoutError = "Boot-out failed: 5: Input/output error";
|
||||
state.bootoutCode = 5;
|
||||
|
||||
await expect(
|
||||
installLaunchAgent({
|
||||
env,
|
||||
stdout: new PassThrough(),
|
||||
programArguments: defaultProgramArguments,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"launchctl bootout failed during LaunchAgent install: Boot-out failed: 5: Input/output error",
|
||||
);
|
||||
|
||||
expect(launchctlCommandNames()).toEqual(["print", "bootout", "print", "bootout"]);
|
||||
});
|
||||
|
||||
it("writes a version-free node service description", async () => {
|
||||
|
||||
+21
-5
@@ -1487,9 +1487,19 @@ async function restoreLaunchAgentInstall(params: {
|
||||
snapshot: LaunchAgentInstallSnapshot;
|
||||
}): Promise<void> {
|
||||
const serviceTarget = `${params.domain}/${params.label}`;
|
||||
const bootout = await execLaunchctl(["bootout", serviceTarget]);
|
||||
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
|
||||
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
|
||||
// A failed bootstrap may leave no registered job. Restore files directly in
|
||||
// that state; only a loaded replacement must be removed before rollback.
|
||||
const currentState = await probeLaunchAgentState(serviceTarget);
|
||||
if (currentState.state === "unknown") {
|
||||
throw new Error(
|
||||
`launchctl print could not determine whether ${serviceTarget} is loaded during LaunchAgent rollback: ${currentState.detail ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
if (currentState.state !== "not-loaded") {
|
||||
const bootout = await execLaunchctl(["bootout", serviceTarget]);
|
||||
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
|
||||
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
|
||||
}
|
||||
}
|
||||
await restoreLaunchAgentInstallArtifacts({
|
||||
env: params.env,
|
||||
@@ -1546,9 +1556,15 @@ async function activateLaunchAgent(params: {
|
||||
// the plist write cannot race us into two KeepAlive managers.
|
||||
await assertNoSystemLaunchDaemonOwnership(label);
|
||||
for (const legacy of params.snapshot.legacy) {
|
||||
await deactivateLaunchAgentDefinition(domain, legacy.plistPath);
|
||||
if (legacy.loaded) {
|
||||
await deactivateLaunchAgentDefinition(domain, legacy.plistPath);
|
||||
}
|
||||
}
|
||||
// Plist-form bootout reports EIO for a valid definition that was never loaded.
|
||||
// The pre-publication snapshot is the authoritative cutover fact.
|
||||
if (params.snapshot.loaded) {
|
||||
await deactivateLaunchAgentDefinition(domain, params.plistPath);
|
||||
}
|
||||
await deactivateLaunchAgentDefinition(domain, params.plistPath);
|
||||
// launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap.
|
||||
await bootstrapLaunchAgentOrThrow({
|
||||
domain,
|
||||
|
||||
@@ -217,6 +217,30 @@ function runRestartArgParser(...args: string[]) {
|
||||
return spawnSync("bash", [harnessPath, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function runProfileGuard(profile: string) {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-profile-test-"));
|
||||
tempRoots.push(root);
|
||||
const script = readFileSync(restartScriptPath, "utf8");
|
||||
const start = script.indexOf('if [[ -n "${OPENCLAW_PROFILE:-}" ]]');
|
||||
const guardBlock = script.slice(start, script.indexOf("canonicalize_app_bundle", start));
|
||||
const harnessPath = join(root, "profile-guard.sh");
|
||||
writeFileSync(
|
||||
harnessPath,
|
||||
[
|
||||
"#!/bin/bash",
|
||||
"set -euo pipefail",
|
||||
'fail() { printf "ERROR: %s\\n" "$*" >&2; exit 1; }',
|
||||
guardBlock,
|
||||
'printf "safe\\n"',
|
||||
].join("\n"),
|
||||
);
|
||||
chmodSync(harnessPath, 0o755);
|
||||
return spawnSync("/bin/bash", [harnessPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, OPENCLAW_PROFILE: profile },
|
||||
});
|
||||
}
|
||||
|
||||
function runLaunchArgBuilder(...args: string[]) {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
|
||||
tempRoots.push(root);
|
||||
@@ -404,6 +428,19 @@ describe("scripts/restart-mac.sh", () => {
|
||||
expect(script).not.toContain('LOG_PATH="${OPENCLAW_RESTART_LOG:-/tmp/openclaw-restart.log}"');
|
||||
});
|
||||
|
||||
it("rejects named app profiles before global process or launchd cleanup", () => {
|
||||
const script = readFileSync(restartScriptPath, "utf8");
|
||||
|
||||
expect(script).toContain('if [[ -n "${OPENCLAW_PROFILE:-}"');
|
||||
expect(script).toContain("restart-mac.sh cannot safely target one app profile");
|
||||
expect(script.indexOf("cannot safely target one app profile")).toBeLessThan(
|
||||
script.indexOf("\nacquire_lock\n"),
|
||||
);
|
||||
expect(runProfileGuard("work").status).toBe(1);
|
||||
expect(runProfileGuard("default").stdout.trim()).toBe("safe");
|
||||
expect(runProfileGuard("Default").stdout.trim()).toBe("safe");
|
||||
});
|
||||
|
||||
it("does not remove a live restart lock it did not acquire", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
|
||||
tempRoots.push(root);
|
||||
|
||||
Reference in New Issue
Block a user