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:
Vincent Koc
2026-07-21 15:41:49 +08:00
committed by GitHub
parent 69d821569c
commit 9e0c5f94b5
7 changed files with 340 additions and 191 deletions
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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")
}
}
+19 -10
View File
@@ -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
View File
@@ -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
+72 -6
View File
@@ -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", () => {