mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(macos): support background-only launches (#112168)
* fix(macos): support background-only launches * fix(macos): refresh native i18n inventory * fix(i18n): refresh native inventory after main sync
This commit is contained in:
+171
-171
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,14 @@ Options:
|
||||
```bash
|
||||
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
|
||||
scripts/restart-mac.sh --sign # force code signing (requires cert)
|
||||
scripts/restart-mac.sh --background-only # keep services running without automatic windows
|
||||
```
|
||||
|
||||
`--background-only` suppresses first-run onboarding, update and CLI prompts, and
|
||||
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.
|
||||
|
||||
## Packaging flow
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
struct AppLaunchPresentationPolicy: Equatable {
|
||||
let backgroundOnly: Bool
|
||||
|
||||
init(arguments: [String]) {
|
||||
self.backgroundOnly = arguments.contains("--background-only")
|
||||
}
|
||||
|
||||
static var current: Self {
|
||||
Self(arguments: CommandLine.arguments)
|
||||
}
|
||||
|
||||
var allowsAutomaticPresentation: Bool {
|
||||
!self.backgroundOnly
|
||||
}
|
||||
|
||||
func shouldAutoOpenChat(arguments: [String]) -> Bool {
|
||||
self.allowsAutomaticPresentation &&
|
||||
(arguments.contains("--chat") || arguments.contains("--webchat"))
|
||||
}
|
||||
|
||||
func shouldAutoOpenDashboard(arguments: [String]) -> Bool {
|
||||
self.allowsAutomaticPresentation && arguments.contains("--dashboard")
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,9 @@ struct OpenClawApp: App {
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, mode in
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
||||
if AppLaunchPresentationPolicy.current.allowsAutomaticPresentation {
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
||||
}
|
||||
BrowserProfileImportModel.shared.handleConnectionModeChange()
|
||||
}
|
||||
|
||||
@@ -510,6 +512,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
@MainActor
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let launchPolicy = AppLaunchPresentationPolicy.current
|
||||
let hasReplacementHandoff = ApplicationRelocator.hasReplacementHandoffMetadata(
|
||||
environment: environment)
|
||||
let isReplacementHandoff = ApplicationRelocator.acceptReplacementHandoff(
|
||||
@@ -521,7 +524,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// Only a child whose signed parent and inherited readiness pipe authenticate
|
||||
// may overlap the old process during replacement handoff.
|
||||
if !isReplacementHandoff, self.isDuplicateInstance() {
|
||||
NSWorkspace.shared.open(Self.dashboardURL)
|
||||
if launchPolicy.allowsAutomaticPresentation {
|
||||
NSWorkspace.shared.open(Self.dashboardURL)
|
||||
}
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
@@ -545,7 +550,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
AppActivationPolicy.apply(showDockIcon: state?.showDockIcon ?? false)
|
||||
if let state {
|
||||
let shouldWaitForConnection = state.connectionMode != .unconfigured
|
||||
if !shouldWaitForConnection {
|
||||
if !shouldWaitForConnection, launchPolicy.allowsAutomaticPresentation {
|
||||
Task { @MainActor in
|
||||
await self.scheduleFirstRunOnboardingIfNeeded(gatewayConnected: false)
|
||||
}
|
||||
@@ -559,7 +564,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
await ConnectionModeCoordinator.shared.apply(
|
||||
mode: state.connectionMode,
|
||||
paused: state.isPaused)
|
||||
guard shouldWaitForConnection else { return }
|
||||
guard shouldWaitForConnection, launchPolicy.allowsAutomaticPresentation else { return }
|
||||
await self.scheduleFirstRunOnboardingIfNeeded(
|
||||
gatewayConnected: ControlChannel.shared.state == .connected)
|
||||
}
|
||||
@@ -576,9 +581,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||
AppStateStore.shared.applyPeekabooBridgeHostState()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
if !PostUpdateController.shared.startIfNeeded() {
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
|
||||
if launchPolicy.allowsAutomaticPresentation {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
if !PostUpdateController.shared.startIfNeeded() {
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
|
||||
}
|
||||
}
|
||||
}
|
||||
Task {
|
||||
@@ -588,21 +595,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
#if DEBUG
|
||||
// Screenshot/demo helper: show the pairing panel with sample requests.
|
||||
if ProcessInfo.processInfo.environment["OPENCLAW_DEBUG_PAIRING_DEMO"] == "1" {
|
||||
if launchPolicy.allowsAutomaticPresentation,
|
||||
ProcessInfo.processInfo.environment["OPENCLAW_DEBUG_PAIRING_DEMO"] == "1"
|
||||
{
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
DebugActions.showPairingPanelDemo()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
|
||||
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
||||
if launchPolicy.shouldAutoOpenChat(arguments: CommandLine.arguments) {
|
||||
self.webChatAutoLogger.debug("Auto-opening chat via CLI flag")
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
if CommandLine.arguments.contains("--dashboard") {
|
||||
if launchPolicy.shouldAutoOpenDashboard(arguments: CommandLine.arguments) {
|
||||
self.webChatAutoLogger.info("Auto-opening dashboard via CLI flag")
|
||||
Task { @MainActor in
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct AppLaunchPresentationPolicyTests {
|
||||
@Test func `normal launches allow automatic presentation`() {
|
||||
let policy = AppLaunchPresentationPolicy(arguments: ["OpenClaw"])
|
||||
|
||||
#expect(policy.allowsAutomaticPresentation)
|
||||
#expect(policy.shouldAutoOpenChat(arguments: ["OpenClaw", "--chat"]))
|
||||
#expect(policy.shouldAutoOpenDashboard(arguments: ["OpenClaw", "--dashboard"]))
|
||||
}
|
||||
|
||||
@Test func `background-only wins over automatic presentation flags`() {
|
||||
let arguments = ["OpenClaw", "--background-only", "--chat", "--dashboard"]
|
||||
let policy = AppLaunchPresentationPolicy(arguments: arguments)
|
||||
|
||||
#expect(!policy.allowsAutomaticPresentation)
|
||||
#expect(!policy.shouldAutoOpenChat(arguments: arguments))
|
||||
#expect(!policy.shouldAutoOpenDashboard(arguments: arguments))
|
||||
}
|
||||
|
||||
@Test func `attach-only does not change presentation behavior`() {
|
||||
let arguments = ["OpenClaw", "--attach-only", "--dashboard"]
|
||||
let policy = AppLaunchPresentationPolicy(arguments: arguments)
|
||||
|
||||
#expect(policy.allowsAutomaticPresentation)
|
||||
#expect(policy.shouldAutoOpenDashboard(arguments: arguments))
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -23,6 +23,7 @@ AUTO_DETECT_SIGNING=1
|
||||
GATEWAY_WAIT_SECONDS="${OPENCLAW_GATEWAY_WAIT_SECONDS:-0}"
|
||||
LAUNCHAGENT_DISABLE_MARKER="${HOME}/.openclaw/disable-launchagent"
|
||||
ATTACH_ONLY=1
|
||||
BACKGROUND_ONLY=0
|
||||
TARGET_ONLY=0
|
||||
TARGET_APP_BUNDLE="${ROOT_DIR}/dist/OpenClaw.app"
|
||||
TARGET_EXECUTABLE="${TARGET_APP_BUNDLE}/${APP_EXECUTABLE_RELATIVE_PATH}"
|
||||
@@ -113,14 +114,16 @@ for arg in "$@"; do
|
||||
--sign) SIGN=1; AUTO_DETECT_SIGNING=0 ;;
|
||||
--attach-only) ATTACH_ONLY=1 ;;
|
||||
--no-attach-only) ATTACH_ONLY=0 ;;
|
||||
--background-only) BACKGROUND_ONLY=1 ;;
|
||||
--target-only) TARGET_ONLY=1 ;;
|
||||
--help|-h)
|
||||
log "Usage: $(basename "$0") [--wait] [--no-sign] [--sign] [--attach-only|--no-attach-only] [--target-only]"
|
||||
log "Usage: $(basename "$0") [--wait] [--no-sign] [--sign] [--attach-only|--no-attach-only] [--background-only] [--target-only]"
|
||||
log " --wait Wait for other restart to complete instead of exiting"
|
||||
log " --no-sign Force no code signing (fastest for development)"
|
||||
log " --sign Force code signing (will fail if no signing key available)"
|
||||
log " --attach-only Launch app with --attach-only (skip launchd install)"
|
||||
log " --no-attach-only Launch app without attach-only override"
|
||||
log " --background-only Launch app without automatic windows or prompts"
|
||||
log " --target-only Restart only this checkout's dist app; fail if another OpenClaw app is active"
|
||||
log ""
|
||||
log "Env:"
|
||||
@@ -162,6 +165,9 @@ fi
|
||||
if [[ "$ATTACH_ONLY" -eq 1 ]]; then
|
||||
log "==> Using --attach-only (skip launchd install)"
|
||||
fi
|
||||
if [[ "$BACKGROUND_ONLY" -eq 1 ]]; then
|
||||
log "==> Using --background-only (suppress automatic presentation)"
|
||||
fi
|
||||
|
||||
acquire_lock
|
||||
|
||||
@@ -467,9 +473,12 @@ if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then
|
||||
run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" verify_gateway_port_listening "${GATEWAY_PORT}"
|
||||
fi
|
||||
|
||||
ATTACH_ONLY_ARGS=()
|
||||
APP_LAUNCH_ARGS=()
|
||||
if [[ "$ATTACH_ONLY" -eq 1 ]]; then
|
||||
ATTACH_ONLY_ARGS+=(--args --attach-only)
|
||||
APP_LAUNCH_ARGS+=(--attach-only)
|
||||
fi
|
||||
if [[ "$BACKGROUND_ONLY" -eq 1 ]]; then
|
||||
APP_LAUNCH_ARGS+=(--background-only)
|
||||
fi
|
||||
|
||||
if [[ "$TARGET_ONLY" -eq 1 ]]; then
|
||||
@@ -484,6 +493,10 @@ fi
|
||||
|
||||
run_step "install packaged app" install_staged_app
|
||||
choose_app_bundle
|
||||
OPEN_ARGS=(-n "${APP_BUNDLE}")
|
||||
if [[ "$ATTACH_ONLY" -eq 1 || "$BACKGROUND_ONLY" -eq 1 ]]; then
|
||||
OPEN_ARGS+=(--args "${APP_LAUNCH_ARGS[@]}")
|
||||
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.).
|
||||
@@ -495,7 +508,7 @@ run_step "launch app" env -i \
|
||||
TMPDIR="${TMPDIR:-/tmp}" \
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
LANG="${LANG:-en_US.UTF-8}" \
|
||||
/usr/bin/open -n "${APP_BUNDLE}" ${ATTACH_ONLY_ARGS[@]:+"${ATTACH_ONLY_ARGS[@]}"}
|
||||
/usr/bin/open "${OPEN_ARGS[@]}"
|
||||
|
||||
# 5) Verify the app is alive.
|
||||
sleep 1.5
|
||||
|
||||
@@ -204,11 +204,12 @@ function runRestartArgParser(...args: string[]) {
|
||||
"SIGN=0",
|
||||
"AUTO_DETECT_SIGNING=1",
|
||||
"ATTACH_ONLY=1",
|
||||
"BACKGROUND_ONLY=0",
|
||||
"TARGET_ONLY=0",
|
||||
'log() { printf "%s\\n" "$*"; }',
|
||||
'fail() { printf "ERROR: %s\\n" "$*" >&2; exit 1; }',
|
||||
parserBlock,
|
||||
'printf "wait=%s no_sign=%s sign=%s attach_only=%s target_only=%s\\n" "$WAIT_FOR_LOCK" "$NO_SIGN" "$SIGN" "$ATTACH_ONLY" "$TARGET_ONLY"',
|
||||
'printf "wait=%s no_sign=%s sign=%s attach_only=%s background_only=%s target_only=%s\\n" "$WAIT_FOR_LOCK" "$NO_SIGN" "$SIGN" "$ATTACH_ONLY" "$BACKGROUND_ONLY" "$TARGET_ONLY"',
|
||||
].join("\n"),
|
||||
);
|
||||
chmodSync(harnessPath, 0o755);
|
||||
@@ -216,6 +217,50 @@ function runRestartArgParser(...args: string[]) {
|
||||
return spawnSync("bash", [harnessPath, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function runLaunchArgBuilder(...args: string[]) {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
|
||||
tempRoots.push(root);
|
||||
|
||||
const script = readFileSync(restartScriptPath, "utf8");
|
||||
const parserBlock = script.slice(
|
||||
script.indexOf('for arg in "$@"; do'),
|
||||
script.indexOf('if [[ "$NO_SIGN" -eq 1 && "$SIGN" -eq 1 ]]'),
|
||||
);
|
||||
const appLaunchArgBlock = script.slice(
|
||||
script.indexOf("APP_LAUNCH_ARGS=()"),
|
||||
script.indexOf('if [[ "$TARGET_ONLY" -eq 1 ]]; then', script.indexOf("APP_LAUNCH_ARGS=()")),
|
||||
);
|
||||
const openArgBlock = script.slice(
|
||||
script.indexOf('OPEN_ARGS=(-n "${APP_BUNDLE}")'),
|
||||
script.indexOf("# 4) Launch"),
|
||||
);
|
||||
const harnessPath = join(root, "launch-arg-harness.sh");
|
||||
writeFileSync(
|
||||
harnessPath,
|
||||
[
|
||||
"#!/bin/bash",
|
||||
"set -euo pipefail",
|
||||
"WAIT_FOR_LOCK=0",
|
||||
"NO_SIGN=0",
|
||||
"SIGN=0",
|
||||
"AUTO_DETECT_SIGNING=1",
|
||||
"ATTACH_ONLY=1",
|
||||
"BACKGROUND_ONLY=0",
|
||||
"TARGET_ONLY=0",
|
||||
'APP_BUNDLE="/tmp/OpenClaw.app"',
|
||||
'log() { printf "%s\\n" "$*"; }',
|
||||
'fail() { printf "ERROR: %s\\n" "$*" >&2; exit 1; }',
|
||||
parserBlock,
|
||||
appLaunchArgBlock,
|
||||
openArgBlock,
|
||||
'printf "<%s>\\n" "${OPEN_ARGS[@]}"',
|
||||
].join("\n"),
|
||||
);
|
||||
chmodSync(harnessPath, 0o755);
|
||||
|
||||
return spawnSync("/bin/bash", [harnessPath, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function runRestartLockHarness(lockDir: string) {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
|
||||
tempRoots.push(root);
|
||||
@@ -295,10 +340,12 @@ describe("scripts/restart-mac.sh", () => {
|
||||
});
|
||||
|
||||
it("parses restart mode flags before side effects", () => {
|
||||
const result = runRestartArgParser("--wait", "--no-sign", "--target-only");
|
||||
const result = runRestartArgParser("--wait", "--no-sign", "--background-only", "--target-only");
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("wait=1 no_sign=1 sign=0 attach_only=1 target_only=1");
|
||||
expect(result.stdout.trim()).toBe(
|
||||
"wait=1 no_sign=1 sign=0 attach_only=1 background_only=1 target_only=1",
|
||||
);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
@@ -443,7 +490,7 @@ describe("scripts/restart-mac.sh", () => {
|
||||
script.indexOf("else", script.indexOf("# 1)")),
|
||||
);
|
||||
const switchTargetBlock = script.slice(
|
||||
script.indexOf('if [[ "$TARGET_ONLY" -eq 1 ]]; then', script.indexOf("ATTACH_ONLY_ARGS")),
|
||||
script.indexOf('if [[ "$TARGET_ONLY" -eq 1 ]]; then', script.indexOf("APP_LAUNCH_ARGS")),
|
||||
script.indexOf("# 4) Launch"),
|
||||
);
|
||||
|
||||
@@ -458,6 +505,22 @@ describe("scripts/restart-mac.sh", () => {
|
||||
expect(script).toContain("target-only restart deferred");
|
||||
});
|
||||
|
||||
it("passes background-only through to the launched app", () => {
|
||||
const script = readFileSync(restartScriptPath, "utf8");
|
||||
|
||||
expect(script).toContain("APP_LAUNCH_ARGS+=(--background-only)");
|
||||
expect(script).toContain('OPEN_ARGS+=(--args "${APP_LAUNCH_ARGS[@]}")');
|
||||
expect(script).toContain('/usr/bin/open "${OPEN_ARGS[@]}"');
|
||||
});
|
||||
|
||||
it("keeps no-attach-only launches nounset-safe on the macOS system Bash", () => {
|
||||
const result = runLaunchArgBuilder("--no-attach-only");
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout.trim()).toBe("<-n>\n</tmp/OpenClaw.app>");
|
||||
});
|
||||
|
||||
it("finds persistent launchd supervisors across explicit domains", () => {
|
||||
const result = runManagedSupervisorClassifier([
|
||||
{
|
||||
@@ -589,8 +652,11 @@ describe("scripts/restart-mac.sh", () => {
|
||||
it("forces LaunchServices to start the selected app bundle", () => {
|
||||
const script = readFileSync(restartScriptPath, "utf8");
|
||||
|
||||
expect(script).toContain('/usr/bin/open -n "${APP_BUNDLE}"');
|
||||
expect(script).not.toContain('/usr/bin/open "${APP_BUNDLE}"');
|
||||
expect(script).toContain('OPEN_ARGS=(-n "${APP_BUNDLE}")');
|
||||
expect(script).toContain('/usr/bin/open "${OPEN_ARGS[@]}"');
|
||||
expect(script.indexOf("\nchoose_app_bundle\n")).toBeLessThan(
|
||||
script.indexOf('OPEN_ARGS=(-n "${APP_BUNDLE}")'),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes custom app bundle paths before process matching", () => {
|
||||
|
||||
Reference in New Issue
Block a user