Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 4 additions & 31 deletions apps/macos/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/macos/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let package = Package(
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../../Swabble"),
.package(path: "vendor/swiftui-math"),
],
targets: [
.target(
Expand Down
23 changes: 23 additions & 0 deletions apps/macos/Sources/OpenClaw/BundledAppDetector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

/// Detects whether the app bundle contains all resources needed to run fully
/// without any external CLI install, global Node, or pnpm.
enum BundledAppDetector {
static func hasNodeRuntime() -> Bool {
guard let dir = CommandResolver.bundledNodeBinPath() else { return false }
return FileManager().isExecutableFile(atPath: dir + "/node")
}

static func hasGateway() -> Bool {
CommandResolver.bundledGatewayEntrypoint() != nil
}

static func hasWebApp() -> Bool {
guard let r = Bundle.main.resourceURL else { return false }
return FileManager().fileExists(atPath: r.appendingPathComponent("webapp/server.js").path)
}

static func isFullyBundled() -> Bool {
hasNodeRuntime() && hasGateway() && hasWebApp()
}
}
2 changes: 2 additions & 0 deletions apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ final class CLIInstallPrompter {
}

private func shouldPrompt() -> Bool {
// Never prompt when the app bundle already contains all needed resources.
guard !BundledAppDetector.isFullyBundled() else { return false }
guard !self.isPrompting else { return false }
guard AppStateStore.shared.onboardingSeen else { return false }
guard AppStateStore.shared.connectionMode == .local else { return false }
Expand Down
21 changes: 21 additions & 0 deletions apps/macos/Sources/OpenClaw/CommandResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ enum CommandResolver {
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
private static let helperName = "openclaw"

// MARK: - Bundled runtime helpers

static func bundledNodeBinPath() -> String? {
Bundle.main.resourceURL?
.appendingPathComponent("runtimes/node/bin")
.path
}

static func bundledGatewayEntrypoint() -> String? {
guard let resources = Bundle.main.resourceURL else { return nil }
let candidates = [
resources.appendingPathComponent("gateway/dist/index.js").path,
resources.appendingPathComponent("gateway/openclaw.mjs").path,
]
return candidates.first { FileManager().isReadableFile(atPath: $0) }
}

static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager().isReadableFile(atPath: distEntry) { return distEntry }
Expand Down Expand Up @@ -89,6 +106,10 @@ enum CommandResolver {
// Dev-only convenience. Avoid project-local PATH hijacking in release builds.
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
#endif
// Bundled Node.js takes highest priority so bundled apps work without any system Node.
if let bundled = Self.bundledNodeBinPath() {
extras.insert(bundled, at: 0)
}
let openclawPaths = self.openclawManagedPaths(home: home)
if !openclawPaths.isEmpty {
extras.insert(contentsOf: openclawPaths, at: 1)
Expand Down
169 changes: 169 additions & 0 deletions apps/macos/Sources/OpenClaw/DashboardWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import AppKit
import Foundation
import OSLog
import WebKit

/// Full-window WKWebView dashboard that loads the bundled Next.js app.
/// Only shown when `BundledAppDetector.hasWebApp()` returns true.
@MainActor
final class DashboardWindowController: NSWindowController {
static let shared = DashboardWindowController()

private let logger = Logger(subsystem: "ai.openclaw", category: "dashboard")
private var webView: WKWebView?
private var spinner: NSProgressIndicator?
private var statusObservationTask: Task<Void, Never>?

// MARK: - Init

private init() {
let window = Self.makeWindow()
super.init(window: window)
self.setupContent(in: window)
self.observeWebAppStatus()
}

@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }

// MARK: - Public

func bringToFront() {
self.showWindow(nil)
self.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}

// MARK: - Setup

private static func makeWindow() -> NSWindow {
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
win.title = "OpenClaw"
win.titlebarAppearsTransparent = true
win.isReleasedWhenClosed = false
win.center()
win.setFrameAutosaveName("DashboardWindow")
win.minSize = NSSize(width: 800, height: 600)
return win
}

private func setupContent(in window: NSWindow) {
let config = WKWebViewConfiguration()
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
// Allow local resources
config.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")

let wv = WKWebView(frame: window.contentView?.bounds ?? .zero, configuration: config)
wv.autoresizingMask = [.width, .height]
wv.navigationDelegate = self
wv.allowsMagnification = true

// Inject a flag so the web app knows it's running inside the native shell.
let script = WKUserScript(
source: "window.__openclawNative = true;",
injectionTime: .atDocumentStart,
forMainFrameOnly: false)
wv.configuration.userContentController.addUserScript(script)

window.contentView?.addSubview(wv)
self.webView = wv

// Loading spinner shown until server is ready.
let spin = NSProgressIndicator()
spin.style = .spinning
spin.controlSize = .large
spin.isIndeterminate = true
spin.startAnimation(nil)
spin.translatesAutoresizingMaskIntoConstraints = false
window.contentView?.addSubview(spin)
if let contentView = window.contentView {
NSLayoutConstraint.activate([
spin.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
spin.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
])
}
self.spinner = spin
}

// MARK: - Status observation

private func observeWebAppStatus() {
self.statusObservationTask?.cancel()
self.statusObservationTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self else { return }
if case .running = WebAppProcessManager.shared.status {
self.loadDashboard()
return
} else if case .failed = WebAppProcessManager.shared.status {
self.showError()
return
}
try? await Task.sleep(nanoseconds: 300_000_000)
}
}
}

private func loadDashboard() {
guard let wv = self.webView else { return }
self.logger.info("dashboard: loading http://127.0.0.1:3100/")
let url = URL(string: "http://127.0.0.1:3100/")!
wv.load(URLRequest(url: url))
}

private func showError() {
guard let spin = self.spinner else { return }
spin.stopAnimation(nil)
spin.removeFromSuperview()
self.spinner = nil

let label = NSTextField(labelWithString: "Failed to start the web app.\nCheck Console.app for details.")
label.alignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
self.window?.contentView?.addSubview(label)
if let contentView = self.window?.contentView {
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
])
}
}
}

// MARK: - WKNavigationDelegate

extension DashboardWindowController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.spinner?.stopAnimation(nil)
self.spinner?.removeFromSuperview()
self.spinner = nil
}

func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
let host = url.host ?? ""
// Allow navigation within the local app server.
if host == "127.0.0.1" || host == "localhost" {
decisionHandler(.allow)
return
}
// Open external URLs in the default browser.
if url.scheme == "http" || url.scheme == "https" {
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
}
6 changes: 3 additions & 3 deletions apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ConcurrencyExtras
import Foundation
import os
import OSLog

enum GatewayEndpointState: Sendable, Equatable {
Expand Down Expand Up @@ -29,7 +29,7 @@ actor GatewayEndpointStore {
case password
}

private static let envOverrideWarnings = LockIsolated((token: false, password: false))
private static let envOverrideWarnings = OSAllocatedUnfairLock(initialState: (token: false, password: false))

struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
Expand Down Expand Up @@ -211,7 +211,7 @@ actor GatewayEndpointStore {
envVar: String,
configKey: String)
{
let shouldWarn = Self.envOverrideWarnings.withValue { state in
let shouldWarn = Self.envOverrideWarnings.withLock { state in
switch kind {
case .token:
guard !state.token else { return false }
Expand Down
Loading