mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 19:40:40 +00:00
* macOS: honor Nix defaults suite; auto launch in Nix mode Fixes repeated onboarding in Nix deployments by detecting nixMode from the stable defaults suite (ai.openclaw.mac) and bridging key settings into the current defaults domain. Also enables LaunchAgent autostart by default in Nix mode (escape hatch: openclaw.nixAutoLaunchAtLogin=false). * macOS: keep Nix mode fix focused Drop the automatic launch-at-login behavior from the Nix defaults patch; keep this PR scoped to reliable nixMode detection + defaults bridging. * macOS: simplify nixMode fix Remove the defaults-bridging helper and rely on a single, stable defaults suite (ai.openclaw.mac) for nixMode detection when running as an app bundle. This keeps the fix focused on onboarding suppression and rename churn resilience. * macOS: fix nixMode defaults suite churn (#12205)
473 lines
18 KiB
Swift
473 lines
18 KiB
Swift
import AppKit
|
|
import Darwin
|
|
import Foundation
|
|
import MenuBarExtraAccess
|
|
import Observation
|
|
import OSLog
|
|
import Security
|
|
import SwiftUI
|
|
|
|
@main
|
|
struct OpenClawApp: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
|
@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 let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
|
@State private var statusItem: NSStatusItem?
|
|
@State private var isMenuPresented = false
|
|
@State private var isPanelVisible = false
|
|
@State private var tailscaleService = TailscaleService.shared
|
|
|
|
@MainActor
|
|
private func updateStatusHighlight() {
|
|
self.statusItem?.button?.highlight(self.isPanelVisible)
|
|
}
|
|
|
|
@MainActor
|
|
private func updateHoverHUDSuppression() {
|
|
HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible)
|
|
}
|
|
|
|
init() {
|
|
OpenClawLogging.bootstrapIfNeeded()
|
|
|
|
Self.applyAttachOnlyOverrideIfNeeded()
|
|
_state = State(initialValue: AppStateStore.shared)
|
|
}
|
|
|
|
var body: some Scene {
|
|
MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: {
|
|
CritterStatusLabel(
|
|
isPaused: self.state.isPaused,
|
|
isSleeping: self.isGatewaySleeping,
|
|
isWorking: self.state.isWorking,
|
|
earBoostActive: self.state.earBoostActive,
|
|
blinkTick: self.state.blinkTick,
|
|
sendCelebrationTick: self.state.sendCelebrationTick,
|
|
gatewayStatus: self.gatewayManager.status,
|
|
animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
|
|
iconState: self.effectiveIconState)
|
|
}
|
|
.menuBarExtraStyle(.menu)
|
|
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
|
self.statusItem = item
|
|
MenuSessionsInjector.shared.install(into: item)
|
|
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
|
self.installStatusItemMouseHandler(for: item)
|
|
self.updateHoverHUDSuppression()
|
|
}
|
|
.onChange(of: self.state.isPaused) { _, paused in
|
|
self.applyStatusItemAppearance(paused: paused, sleeping: self.isGatewaySleeping)
|
|
if self.state.connectionMode == .local {
|
|
self.gatewayManager.setActive(!paused)
|
|
} else {
|
|
self.gatewayManager.stop()
|
|
}
|
|
}
|
|
.onChange(of: self.controlChannel.state) { _, _ in
|
|
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
|
}
|
|
.onChange(of: self.gatewayManager.status) { _, _ in
|
|
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
|
}
|
|
.onChange(of: self.state.connectionMode) { _, mode in
|
|
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
|
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
|
}
|
|
|
|
Settings {
|
|
SettingsRootView(state: self.state, updater: self.delegate.updaterController)
|
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
|
.environment(self.tailscaleService)
|
|
}
|
|
.defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
|
.windowResizability(.contentSize)
|
|
.onChange(of: self.isMenuPresented) { _, _ in
|
|
self.updateStatusHighlight()
|
|
self.updateHoverHUDSuppression()
|
|
}
|
|
}
|
|
|
|
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
|
|
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
|
}
|
|
|
|
private static func applyAttachOnlyOverrideIfNeeded() {
|
|
let args = CommandLine.arguments
|
|
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
|
|
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
|
|
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
|
|
return
|
|
}
|
|
Task {
|
|
_ = await GatewayLaunchAgentManager.set(
|
|
enabled: false,
|
|
bundlePath: Bundle.main.bundlePath,
|
|
port: GatewayEnvironment.gatewayPort())
|
|
}
|
|
Self.logger.info("attach-only flag enabled")
|
|
}
|
|
|
|
private var isGatewaySleeping: Bool {
|
|
if self.state.isPaused { return false }
|
|
switch self.state.connectionMode {
|
|
case .unconfigured:
|
|
return true
|
|
case .remote:
|
|
if case .connected = self.controlChannel.state { return false }
|
|
return true
|
|
case .local:
|
|
switch self.gatewayManager.status {
|
|
case .running, .starting, .attachedExisting:
|
|
if case .connected = self.controlChannel.state { return false }
|
|
return true
|
|
case .failed, .stopped:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func installStatusItemMouseHandler(for item: NSStatusItem) {
|
|
guard let button = item.button else { return }
|
|
if button.subviews.contains(where: { $0 is StatusItemMouseHandlerView }) { return }
|
|
|
|
WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in
|
|
self.isPanelVisible = visible
|
|
self.updateStatusHighlight()
|
|
self.updateHoverHUDSuppression()
|
|
}
|
|
CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in
|
|
self.state.canvasPanelVisible = visible
|
|
}
|
|
CanvasManager.shared.defaultAnchorProvider = { [self] in self.statusButtonScreenFrame() }
|
|
|
|
let handler = StatusItemMouseHandlerView()
|
|
handler.translatesAutoresizingMaskIntoConstraints = false
|
|
handler.onLeftClick = { [self] in
|
|
HoverHUDController.shared.dismiss(reason: "statusItemClick")
|
|
self.toggleWebChatPanel()
|
|
}
|
|
handler.onRightClick = { [self] in
|
|
HoverHUDController.shared.dismiss(reason: "statusItemRightClick")
|
|
WebChatManager.shared.closePanel()
|
|
self.isMenuPresented = true
|
|
self.updateStatusHighlight()
|
|
}
|
|
handler.onHoverChanged = { [self] inside in
|
|
HoverHUDController.shared.statusItemHoverChanged(
|
|
inside: inside,
|
|
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
|
}
|
|
|
|
button.addSubview(handler)
|
|
NSLayoutConstraint.activate([
|
|
handler.leadingAnchor.constraint(equalTo: button.leadingAnchor),
|
|
handler.trailingAnchor.constraint(equalTo: button.trailingAnchor),
|
|
handler.topAnchor.constraint(equalTo: button.topAnchor),
|
|
handler.bottomAnchor.constraint(equalTo: button.bottomAnchor),
|
|
])
|
|
}
|
|
|
|
@MainActor
|
|
private func toggleWebChatPanel() {
|
|
HoverHUDController.shared.setSuppressed(true)
|
|
self.isMenuPresented = false
|
|
Task { @MainActor in
|
|
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
|
WebChatManager.shared.togglePanel(
|
|
sessionKey: sessionKey,
|
|
anchorProvider: { [self] in self.statusButtonScreenFrame() })
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func statusButtonScreenFrame() -> NSRect? {
|
|
guard let button = self.statusItem?.button, let window = button.window else { return nil }
|
|
let inWindow = button.convert(button.bounds, to: nil)
|
|
return window.convertToScreen(inWindow)
|
|
}
|
|
|
|
private var effectiveIconState: IconState {
|
|
let selection = self.state.iconOverride
|
|
if selection == .system {
|
|
return self.activityStore.iconState
|
|
}
|
|
let overrideState = selection.toIconState()
|
|
switch overrideState {
|
|
case let .workingMain(kind): return .overridden(kind)
|
|
case let .workingOther(kind): return .overridden(kind)
|
|
case .idle: return .idle
|
|
case let .overridden(kind): return .overridden(kind)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Transparent overlay that intercepts clicks without stealing MenuBarExtra ownership.
|
|
private final class StatusItemMouseHandlerView: NSView {
|
|
var onLeftClick: (() -> Void)?
|
|
var onRightClick: (() -> Void)?
|
|
var onHoverChanged: ((Bool) -> Void)?
|
|
private var tracking: NSTrackingArea?
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
if let onLeftClick {
|
|
onLeftClick()
|
|
} else {
|
|
super.mouseDown(with: event)
|
|
}
|
|
}
|
|
|
|
override func rightMouseDown(with event: NSEvent) {
|
|
self.onRightClick?()
|
|
// Do not call super; menu will be driven by isMenuPresented binding.
|
|
}
|
|
|
|
override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
if let tracking {
|
|
self.removeTrackingArea(tracking)
|
|
}
|
|
let options: NSTrackingArea.Options = [
|
|
.mouseEnteredAndExited,
|
|
.activeAlways,
|
|
.inVisibleRect,
|
|
]
|
|
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
|
self.addTrackingArea(area)
|
|
self.tracking = area
|
|
}
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
self.onHoverChanged?(true)
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
self.onHoverChanged?(false)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
private var state: AppState?
|
|
private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat")
|
|
let updaterController: UpdaterProviding = makeUpdaterController()
|
|
|
|
func application(_: NSApplication, open urls: [URL]) {
|
|
Task { @MainActor in
|
|
for url in urls {
|
|
await DeepLinkHandler.shared.handle(url: url)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
if self.isDuplicateInstance() {
|
|
NSApp.terminate(nil)
|
|
return
|
|
}
|
|
self.state = AppStateStore.shared
|
|
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
|
if let state {
|
|
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
|
}
|
|
TerminationSignalWatcher.shared.start()
|
|
NodePairingApprovalPrompter.shared.start()
|
|
DevicePairingApprovalPrompter.shared.start()
|
|
ExecApprovalsPromptServer.shared.start()
|
|
ExecApprovalsGatewayPrompter.shared.start()
|
|
MacNodeModeCoordinator.shared.start()
|
|
VoiceWakeGlobalSettingsSync.shared.start()
|
|
Task { PresenceReporter.shared.start() }
|
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
|
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
|
self.scheduleFirstRunOnboardingIfNeeded()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
|
|
}
|
|
|
|
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
|
|
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
|
self.webChatAutoLogger.debug("Auto-opening chat via CLI flag")
|
|
Task { @MainActor in
|
|
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
|
WebChatManager.shared.show(sessionKey: sessionKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
func applicationWillTerminate(_ notification: Notification) {
|
|
PresenceReporter.shared.stop()
|
|
NodePairingApprovalPrompter.shared.stop()
|
|
DevicePairingApprovalPrompter.shared.stop()
|
|
ExecApprovalsPromptServer.shared.stop()
|
|
ExecApprovalsGatewayPrompter.shared.stop()
|
|
MacNodeModeCoordinator.shared.stop()
|
|
TerminationSignalWatcher.shared.stop()
|
|
VoiceWakeGlobalSettingsSync.shared.stop()
|
|
WebChatManager.shared.close()
|
|
WebChatManager.shared.resetTunnels()
|
|
Task { await RemoteTunnelManager.shared.stopAll() }
|
|
Task { await GatewayConnection.shared.shutdown() }
|
|
Task { await PeekabooBridgeHostCoordinator.shared.stop() }
|
|
}
|
|
|
|
@MainActor
|
|
private func scheduleFirstRunOnboardingIfNeeded() {
|
|
let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey)
|
|
let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen
|
|
guard shouldShow else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
|
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
|
|
}
|
|
}
|
|
|
|
// MARK: - Sparkle updater (disabled for unsigned/dev builds)
|
|
|
|
@MainActor
|
|
protocol UpdaterProviding: AnyObject {
|
|
var automaticallyChecksForUpdates: Bool { get set }
|
|
var automaticallyDownloadsUpdates: Bool { get set }
|
|
var isAvailable: Bool { get }
|
|
var updateStatus: UpdateStatus { get }
|
|
func checkForUpdates(_ sender: Any?)
|
|
}
|
|
|
|
// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
|
final class DisabledUpdaterController: UpdaterProviding {
|
|
var automaticallyChecksForUpdates: Bool = false
|
|
var automaticallyDownloadsUpdates: Bool = false
|
|
let isAvailable: Bool = false
|
|
let updateStatus = UpdateStatus()
|
|
func checkForUpdates(_: Any?) {}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class UpdateStatus {
|
|
static let disabled = UpdateStatus()
|
|
var isUpdateReady: Bool
|
|
|
|
init(isUpdateReady: Bool = false) {
|
|
self.isUpdateReady = isUpdateReady
|
|
}
|
|
}
|
|
|
|
#if canImport(Sparkle)
|
|
import Sparkle
|
|
|
|
@MainActor
|
|
final class SparkleUpdaterController: NSObject, UpdaterProviding {
|
|
private lazy var controller = SPUStandardUpdaterController(
|
|
startingUpdater: false,
|
|
updaterDelegate: self,
|
|
userDriverDelegate: nil)
|
|
let updateStatus = UpdateStatus()
|
|
|
|
init(savedAutoUpdate: Bool) {
|
|
super.init()
|
|
let updater = self.controller.updater
|
|
updater.automaticallyChecksForUpdates = savedAutoUpdate
|
|
updater.automaticallyDownloadsUpdates = savedAutoUpdate
|
|
self.controller.startUpdater()
|
|
}
|
|
|
|
var automaticallyChecksForUpdates: Bool {
|
|
get { self.controller.updater.automaticallyChecksForUpdates }
|
|
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
|
|
}
|
|
|
|
var automaticallyDownloadsUpdates: Bool {
|
|
get { self.controller.updater.automaticallyDownloadsUpdates }
|
|
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
|
|
}
|
|
|
|
var isAvailable: Bool { true }
|
|
|
|
func checkForUpdates(_ sender: Any?) {
|
|
self.controller.checkForUpdates(sender)
|
|
}
|
|
|
|
func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
|
self.updateStatus.isUpdateReady = true
|
|
}
|
|
|
|
func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) {
|
|
self.updateStatus.isUpdateReady = false
|
|
}
|
|
|
|
func userDidCancelDownload(_ updater: SPUUpdater) {
|
|
self.updateStatus.isUpdateReady = false
|
|
}
|
|
|
|
func updater(
|
|
_ updater: SPUUpdater,
|
|
userDidMakeChoice choice: SPUUserUpdateChoice,
|
|
forUpdate updateItem: SUAppcastItem,
|
|
state: SPUUserUpdateState)
|
|
{
|
|
switch choice {
|
|
case .install, .skip:
|
|
self.updateStatus.isUpdateReady = false
|
|
case .dismiss:
|
|
self.updateStatus.isUpdateReady = (state.stage == .downloaded)
|
|
@unknown default:
|
|
self.updateStatus.isUpdateReady = false
|
|
}
|
|
}
|
|
}
|
|
|
|
extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {}
|
|
|
|
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
|
var staticCode: SecStaticCode?
|
|
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
|
|
let code = staticCode
|
|
else { return false }
|
|
|
|
var infoCF: CFDictionary?
|
|
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
|
|
let info = infoCF as? [String: Any],
|
|
let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
|
|
let leaf = certs.first
|
|
else {
|
|
return false
|
|
}
|
|
|
|
if let summary = SecCertificateCopySubjectSummary(leaf) as String? {
|
|
return summary.hasPrefix("Developer ID Application:")
|
|
}
|
|
return false
|
|
}
|
|
|
|
@MainActor
|
|
private func makeUpdaterController() -> UpdaterProviding {
|
|
let bundleURL = Bundle.main.bundleURL
|
|
let isBundledApp = bundleURL.pathExtension == "app"
|
|
guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() }
|
|
|
|
let defaults = UserDefaults.standard
|
|
let autoUpdateKey = "autoUpdateEnabled"
|
|
// Default to true; honor the user's last choice otherwise.
|
|
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
|
|
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
|
|
}
|
|
#else
|
|
@MainActor
|
|
private func makeUpdaterController() -> UpdaterProviding {
|
|
DisabledUpdaterController()
|
|
}
|
|
#endif
|