diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 0281713738b14..ab2b6bd759230 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c", + "originHash" : "d47aa99d2c77b69f3c7b93dc30f5d78bd4692173374eac05478d849ae754f28b", "pins" : [ { "identity" : "axorcist", @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/ElevenLabsKit", "state" : { - "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", + "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", "version" : "0.1.0" } }, @@ -64,22 +64,13 @@ "version" : "1.2.1" } }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { @@ -108,24 +99,6 @@ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", "version" : "1.6.4" } - }, - { - "identity" : "swiftui-math", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swiftui-math", - "state" : { - "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", - "version" : "0.1.0" - } - }, - { - "identity" : "textual", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/textual", - "state" : { - "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38", - "version" : "0.3.1" - } } ], "version" : 3 diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 10ab47b851406..27029ecf2b950 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -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( diff --git a/apps/macos/Sources/OpenClaw/BundledAppDetector.swift b/apps/macos/Sources/OpenClaw/BundledAppDetector.swift new file mode 100644 index 0000000000000..c3a9391daf96c --- /dev/null +++ b/apps/macos/Sources/OpenClaw/BundledAppDetector.swift @@ -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() + } +} diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index 482f36fd6d0c7..986525991c9f7 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -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 } diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index c17f64e30e734..0f65f9425fd79 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -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 } @@ -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) diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift new file mode 100644 index 0000000000000..d8ac2bc8792ea --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -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? + + // 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) + } +} diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 0edb2e6512254..519f01486ed8c 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -1,5 +1,5 @@ -import ConcurrencyExtras import Foundation +import os import OSLog enum GatewayEndpointState: Sendable, Equatable { @@ -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 @@ -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 } diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift index 059eb4da6e0cc..5683942ba7caf 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -100,6 +100,17 @@ enum GatewayEnvironment { Semver.parse(versionString) } + static func readBundledGatewayVersion() -> Semver? { + guard let resources = Bundle.main.resourceURL else { return nil } + let pkg = resources.appendingPathComponent("gateway/package.json") + guard let data = try? Data(contentsOf: pkg) else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = json["version"] as? String + else { return nil } + return Semver.parse(version) + } + static func check() -> GatewayEnvironmentStatus { let start = Date() defer { @@ -115,6 +126,9 @@ enum GatewayEnvironment { let projectRoot = CommandResolver.projectRoot() let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) + // Bundled gateway takes priority over project-local entrypoint. + let bundledEntrypoint = CommandResolver.bundledGatewayEntrypoint() + let effectiveEntrypoint = bundledEntrypoint ?? projectEntrypoint switch RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) { case let .failure(err): @@ -127,7 +141,7 @@ enum GatewayEnvironment { case let .success(runtime): let gatewayBin = CommandResolver.openclawExecutable() - if gatewayBin == nil, projectEntrypoint == nil { + if gatewayBin == nil, effectiveEntrypoint == nil { return GatewayEnvironmentStatus( kind: .missingGateway, nodeVersion: runtime.version.description, @@ -137,6 +151,7 @@ enum GatewayEnvironment { } let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) } + ?? self.readBundledGatewayVersion() ?? self.readLocalGatewayVersion(projectRoot: projectRoot) if let expected, let installed, !installed.compatible(with: expected) { @@ -152,13 +167,16 @@ enum GatewayEnvironment { """) } - let gatewayLabel = gatewayBin != nil ? "global" : "local" + let gatewayLabel: String + if gatewayBin != nil { gatewayLabel = "global" } + else if bundledEntrypoint != nil { gatewayLabel = "bundled" } + else { gatewayLabel = "local" } let gatewayVersionText = installed?.description ?? "unknown" // Avoid repeating "(local)" twice; if using the local entrypoint, show the path once. - let localPathHint = gatewayBin == nil && projectEntrypoint != nil + let localPathHint = gatewayBin == nil && bundledEntrypoint == nil && projectEntrypoint != nil ? " (local: \(projectEntrypoint ?? "unknown"))" : "" - let gatewayLabelText = gatewayBin != nil + let gatewayLabelText = gatewayBin != nil || bundledEntrypoint != nil ? "(\(gatewayLabel))" : localPathHint.isEmpty ? "(\(gatewayLabel))" : localPathHint return GatewayEnvironmentStatus( @@ -182,6 +200,9 @@ enum GatewayEnvironment { } let projectRoot = CommandResolver.projectRoot() let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) + // Bundled gateway takes priority over project-local entrypoint. + let entry = CommandResolver.bundledGatewayEntrypoint() + ?? projectEntrypoint let status = self.check() let gatewayBin = CommandResolver.openclawExecutable() let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths()) @@ -197,7 +218,7 @@ enum GatewayEnvironment { return GatewayCommandResolution(status: status, command: cmd) } - if let entry = projectEntrypoint, + if let entry, case let .success(resolvedRuntime) = runtime { let bind = self.preferredGatewayBind() ?? "loopback" diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 00e2a9be0a635..9039649a1c3a2 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -291,6 +291,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") } + // Start bundled web app and open dashboard window when fully bundled. + if BundledAppDetector.hasWebApp() { + Task { await WebAppProcessManager.shared.start() } + DashboardWindowController.shared.bringToFront() + } + // 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") @@ -302,6 +308,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + WebAppProcessManager.shared.stop() PresenceReporter.shared.stop() NodePairingApprovalPrompter.shared.stop() DevicePairingApprovalPrompter.shared.stop() diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift index 3416d23f81211..7f9e8f2af2109 100644 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -106,6 +106,13 @@ struct MenuContent: View { self.voiceWakeMicMenu } Divider() + if BundledAppDetector.hasWebApp() { + Button { + DashboardWindowController.shared.bringToFront() + } label: { + Label("Show Dashboard", systemImage: "macwindow") + } + } Button { Task { @MainActor in await self.openDashboard() diff --git a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift index 51646e0a36a33..09acb7bf22b2a 100644 --- a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift +++ b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift @@ -1,7 +1,14 @@ import SwiftUI +private struct MenuItemHighlightedKey: EnvironmentKey { + static let defaultValue: Bool = false +} + extension EnvironmentValues { - @Entry var menuItemHighlighted: Bool = false + var menuItemHighlighted: Bool { + get { self[MenuItemHighlightedKey.self] } + set { self[MenuItemHighlightedKey.self] = newValue } + } } struct SessionMenuLabelView: View { diff --git a/apps/macos/Sources/OpenClaw/WebAppProcessManager.swift b/apps/macos/Sources/OpenClaw/WebAppProcessManager.swift new file mode 100644 index 0000000000000..1e3f29782a026 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/WebAppProcessManager.swift @@ -0,0 +1,158 @@ +import Foundation +import Observation +import OSLog + +/// Manages the bundled Next.js standalone server as a direct subprocess. +/// Only active when `BundledAppDetector.hasWebApp()` returns true. +@MainActor +@Observable +final class WebAppProcessManager { + static let shared = WebAppProcessManager() + + private let logger = Logger(subsystem: "ai.openclaw", category: "webapp") + + enum Status: Equatable { + case stopped + case starting + case running + case failed(String) + } + + private(set) var status: Status = .stopped + private var process: Process? + + private static let port = 3100 + + // MARK: - Public API + + func start() async { + guard BundledAppDetector.hasWebApp() else { + self.logger.debug("webapp: no bundled web app; skipping") + return + } + guard case .stopped = self.status else { + self.logger.debug("webapp: already started or starting; skipping") + return + } + + // If something is already listening on 3100, attach to it. + if await Self.isPortResponsive() { + self.logger.info("webapp: port \(Self.port) already responsive; attaching") + self.status = .running + return + } + + guard let nodePath = CommandResolver.bundledNodeBinPath().map({ $0 + "/node" }), + FileManager().isExecutableFile(atPath: nodePath) + else { + let msg = "bundled node binary not found or not executable" + self.logger.error("webapp: \(msg, privacy: .public)") + self.status = .failed(msg) + return + } + + guard let resources = Bundle.main.resourceURL else { + let msg = "bundle resource URL unavailable" + self.logger.error("webapp: \(msg, privacy: .public)") + self.status = .failed(msg) + return + } + + let serverScript = resources.appendingPathComponent("webapp/server.js").path + guard FileManager().fileExists(atPath: serverScript) else { + let msg = "webapp/server.js not found at \(serverScript)" + self.logger.error("webapp: \(msg, privacy: .public)") + self.status = .failed(msg) + return + } + + self.status = .starting + self.logger.info("webapp: starting Next.js server on port \(Self.port)") + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: nodePath) + proc.arguments = [serverScript] + proc.environment = [ + "PORT": "\(Self.port)", + "HOSTNAME": "127.0.0.1", + "NODE_ENV": "production", + // Pass through minimal PATH so the process can find itself + "PATH": CommandResolver.preferredPaths().joined(separator: ":"), + ] + // Run server from the webapp directory so relative asset paths resolve. + proc.currentDirectoryURL = resources.appendingPathComponent("webapp") + + // Discard stdout/stderr to avoid filling pipe buffers. + proc.standardOutput = FileHandle.nullDevice + proc.standardError = FileHandle.nullDevice + + proc.terminationHandler = { [weak self] p in + Task { @MainActor [weak self] in + guard let self else { return } + if case .running = self.status { return } + let code = p.terminationStatus + let msg = "webapp process exited with code \(code)" + self.logger.error("webapp: \(msg, privacy: .public)") + if case .starting = self.status { + self.status = .failed(msg) + } + self.process = nil + } + } + + do { + try proc.run() + } catch { + let msg = "failed to launch webapp: \(error.localizedDescription)" + self.logger.error("webapp: \(msg, privacy: .public)") + self.status = .failed(msg) + return + } + + self.process = proc + + // Poll for readiness. + if await self.waitForReady(timeout: 10) { + self.logger.info("webapp: server ready on port \(Self.port)") + self.status = .running + } else { + let msg = "webapp did not become ready within 10s" + self.logger.error("webapp: \(msg, privacy: .public)") + self.status = .failed(msg) + proc.terminate() + self.process = nil + } + } + + func stop() { + guard let proc = self.process else { return } + self.logger.info("webapp: stopping server") + proc.terminate() + self.process = nil + self.status = .stopped + } + + // MARK: - Private helpers + + private func waitForReady(timeout: TimeInterval) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await Self.isPortResponsive() { return true } + try? await Task.sleep(for: .milliseconds(300)) + } + return false + } + + private static func isPortResponsive() async -> Bool { + let url = URL(string: "http://127.0.0.1:\(port)/")! + var req = URLRequest(url: url, timeoutInterval: 2) + req.httpMethod = "HEAD" + do { + let (_, resp) = try await URLSession.shared.data(for: req) + if let http = resp as? HTTPURLResponse, http.statusCode < 500 { + return true + } + } catch {} + return false + } +} diff --git a/apps/shared/OpenClawKit/Package.swift b/apps/shared/OpenClawKit/Package.swift index 5c8132d2c9bf5..6cb80bd520867 100644 --- a/apps/shared/OpenClawKit/Package.swift +++ b/apps/shared/OpenClawKit/Package.swift @@ -15,7 +15,6 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"), - .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"), ], targets: [ .target( @@ -41,10 +40,6 @@ let package = Package( name: "OpenClawChatUI", dependencies: [ "OpenClawKit", - .product( - name: "Textual", - package: "textual", - condition: .when(platforms: [.macOS, .iOS])), ], path: "Sources/OpenClawChatUI", swiftSettings: [ diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift index e68c8591bcf03..007357d8e8203 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift @@ -1,5 +1,4 @@ import SwiftUI -import Textual public enum ChatMarkdownVariant: String, CaseIterable, Sendable { case standard @@ -22,12 +21,10 @@ struct ChatMarkdownRenderer: View { var body: some View { let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text) VStack(alignment: .leading, spacing: 10) { - StructuredText(markdown: processed.cleaned) - .modifier(ChatMarkdownStyle( - variant: self.variant, - context: self.context, - font: self.font, - textColor: self.textColor)) + Text(LocalizedStringKey(processed.cleaned)) + .font(self.font) + .foregroundStyle(self.textColor) + .textSelection(.enabled) if !processed.images.isEmpty { InlineImageList(images: processed.images) @@ -36,35 +33,6 @@ struct ChatMarkdownRenderer: View { } } -private struct ChatMarkdownStyle: ViewModifier { - let variant: ChatMarkdownVariant - let context: ChatMarkdownRenderer.Context - let font: Font - let textColor: Color - - func body(content: Content) -> some View { - Group { - if self.variant == .compact { - content.textual.structuredTextStyle(.default) - } else { - content.textual.structuredTextStyle(.gitHub) - } - } - .font(self.font) - .foregroundStyle(self.textColor) - .textual.inlineStyle(self.inlineStyle) - .textual.textSelection(.enabled) - } - - private var inlineStyle: InlineStyle { - let linkColor: Color = self.context == .user ? self.textColor : .accentColor - let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9 - return InlineStyle() - .code(.monospaced, .fontScale(codeScale)) - .link(.foregroundColor(linkColor)) - } -} - @MainActor private struct InlineImageList: View { let images: [ChatMarkdownPreprocessor.InlineImage] diff --git a/package.json b/package.json index 05c25f361c641..f7b4bb84188b8 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", + "mac:dmg": "bash scripts/package-mac-app.sh && bash scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw.dmg", "mac:open": "open dist/OpenClaw.app", "mac:package": "bash scripts/package-mac-app.sh", "mac:restart": "bash scripts/restart-mac.sh", diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index d43987c7a2883..815a82bf276e8 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -2,6 +2,7 @@ set -euo pipefail APP_BUNDLE="${1:-dist/OpenClaw.app}" +ORIG_APP_BUNDLE="$APP_BUNDLE" IDENTITY="${SIGN_IDENTITY:-}" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" @@ -188,17 +189,39 @@ fi APP_ENTITLEMENTS="$ENT_TMP_APP_BASE" -# clear extended attributes to avoid stale signatures -xattr -cr "$APP_BUNDLE" 2>/dev/null || true +# Copy to /tmp to avoid iCloud Drive File Provider unremovable xattr detritus +TMP_APP_BUNDLE="/tmp/OpenClaw.app.$$" +rm -rf "$TMP_APP_BUNDLE" +# ditto ignores xattrs but preserves permissions with --norsrc +ditto --norsrc "$ORIG_APP_BUNDLE" "$TMP_APP_BUNDLE" +APP_BUNDLE="$TMP_APP_BUNDLE" + +# clear extended attributes and resource forks to avoid stale signatures +chmod -R u+rw "$APP_BUNDLE" 2>/dev/null || true +dot_clean -m "$APP_BUNDLE" 2>/dev/null || true +find "$APP_BUNDLE" -exec xattr -c {} \; 2>/dev/null || true + +strip_provenance() { + local target="$1" + if [ -f "$target" ] && ! [ -L "$target" ]; then + local tmp_target="${target}.strip" + cat "$target" > "$tmp_target" + chmod +x "$tmp_target" + rm -f "$target" + mv "$tmp_target" "$target" + fi +} sign_item() { local target="$1" local entitlements="$2" + strip_provenance "$target" codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --entitlements "$entitlements" --sign "$IDENTITY" "$target" } sign_plain_item() { local target="$1" + strip_provenance "$target" codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target" } @@ -280,10 +303,35 @@ if [ -d "$APP_BUNDLE/Contents/Frameworks" ]; then done fi +# Sign bundled Node.js binary (requires allow-jit + allow-unsigned-executable-memory) +NODE_BIN="$APP_BUNDLE/Contents/Resources/runtimes/node/bin/node" +if [ -f "$NODE_BIN" ]; then + echo "Signing bundled Node.js binary" + sign_item "$NODE_BIN" "$ENT_TMP_RUNTIME" +fi + +# Sign any native addons (.node files) in webapp and gateway resource directories +for resource_dir in \ + "$APP_BUNDLE/Contents/Resources/webapp" \ + "$APP_BUNDLE/Contents/Resources/gateway" +do + if [ -d "$resource_dir" ]; then + find "$resource_dir" -name "*.node" -print0 | while IFS= read -r -d '' f; do + echo "Signing native addon: $f"; sign_plain_item "$f" + done + fi +done + # Finally sign the bundle sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS" verify_team_ids rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_RUNTIME" -echo "Codesign complete for $APP_BUNDLE" + +# Copy back from /tmp and clean up +rm -rf "$ORIG_APP_BUNDLE" +ditto "$TMP_APP_BUNDLE" "$ORIG_APP_BUNDLE" +rm -rf "$TMP_APP_BUNDLE" + +echo "Codesign complete for $ORIG_APP_BUNDLE" diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh index a9f71eb6ca548..13f032c2f264a 100755 --- a/scripts/create-dmg.sh +++ b/scripts/create-dmg.sh @@ -15,7 +15,8 @@ set -euo pipefail # DMG_APP_POS default: "125 160" # DMG_APPS_POS default: "375 160" # SKIP_DMG_STYLE=1 skip Finder styling -# DMG_EXTRA_SECTORS extra sectors to keep when shrinking RW image (default: 2048) +# DMG_EXTRA_SECTORS extra sectors to keep when shrinking RW image (default: 409600 ≈ 200 MB) +# Use a smaller value (e.g. 2048) for non-bundled builds without Node/webapp. APP_PATH="${1:-}" OUT_PATH="${2:-}" @@ -45,7 +46,7 @@ DMG_WINDOW_BOUNDS="${DMG_WINDOW_BOUNDS:-400 100 900 420}" DMG_ICON_SIZE="${DMG_ICON_SIZE:-128}" DMG_APP_POS="${DMG_APP_POS:-125 160}" DMG_APPS_POS="${DMG_APPS_POS:-375 160}" -DMG_EXTRA_SECTORS="${DMG_EXTRA_SECTORS:-2048}" +DMG_EXTRA_SECTORS="${DMG_EXTRA_SECTORS:-409600}" to_applescript_list4() { local raw="$1" diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 455c118f9b569..9989b5d05faf7 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -123,6 +123,14 @@ else echo "🖥 Skipping Control UI build (SKIP_UI_BUILD=1)" fi +# Build Next.js web app (standalone output required for bundling) +if [[ "${SKIP_WEBAPP_BUILD:-0}" != "1" ]]; then + echo "🌐 Building web app (Next.js standalone)" + (cd "$ROOT_DIR" && pnpm --filter ironclaw-web build) +else + echo "🌐 Skipping web app build (SKIP_WEBAPP_BUILD=1)" +fi + cd "$ROOT_DIR/apps/macos" echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [${BUILD_ARCHS[*]}]" @@ -172,7 +180,7 @@ if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then fi chmod +x "$APP_ROOT/Contents/MacOS/OpenClaw" # SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings. -/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/OpenClaw" 2>/dev/null || true +# /usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/OpenClaw" 2>/dev/null || true SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")" if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then @@ -252,6 +260,77 @@ else fi fi +echo "🌐 Bundling web app (Next.js standalone)" +WEBAPP_DEST="$APP_ROOT/Contents/Resources/webapp" +WEBAPP_STANDALONE="$ROOT_DIR/apps/web/.next/standalone" +if [[ -d "$WEBAPP_STANDALONE" ]]; then + rm -rf "$WEBAPP_DEST" + cp -R "$WEBAPP_STANDALONE/" "$WEBAPP_DEST/" + mkdir -p "$WEBAPP_DEST/.next" + if [[ -d "$ROOT_DIR/apps/web/.next/static" ]]; then + cp -R "$ROOT_DIR/apps/web/.next/static/" "$WEBAPP_DEST/.next/static/" + fi + if [[ -d "$ROOT_DIR/apps/web/public" ]]; then + cp -R "$ROOT_DIR/apps/web/public/" "$WEBAPP_DEST/public/" + fi + echo " ✓ web app bundled at $WEBAPP_DEST" +else + echo "WARN: Next.js standalone output not found at $WEBAPP_STANDALONE; skipping web app bundle" >&2 +fi + +echo "🔌 Bundling gateway" +GATEWAY_DEST="$APP_ROOT/Contents/Resources/gateway" +rm -rf "$GATEWAY_DEST" +mkdir -p "$GATEWAY_DEST" +if [[ -d "$ROOT_DIR/dist" ]]; then + rsync -av --exclude="*.app" "$ROOT_DIR/dist/" "$GATEWAY_DEST/dist/" >/dev/null +fi +if [[ -f "$ROOT_DIR/openclaw.mjs" ]]; then + cp "$ROOT_DIR/openclaw.mjs" "$GATEWAY_DEST/openclaw.mjs" +fi +if [[ -f "$ROOT_DIR/package.json" ]]; then + cp "$ROOT_DIR/package.json" "$GATEWAY_DEST/package.json" +fi +echo " ✓ gateway bundled at $GATEWAY_DEST" + +# Bundle a universal Node.js binary (arm64 + x86_64) +NODE_VERSION="${NODE_VERSION:-22.14.0}" +if [[ "${SKIP_NODE_BUNDLE:-0}" != "1" ]]; then + echo "📦 Bundling Node.js ${NODE_VERSION} (universal)" + NODE_DEST="$APP_ROOT/Contents/Resources/runtimes/node/bin" + mkdir -p "$NODE_DEST" + TMP_NODE=$(mktemp -d) + cleanup_node_tmp() { rm -rf "$TMP_NODE"; } + trap cleanup_node_tmp EXIT + + ARM64_TGZ="$TMP_NODE/node-arm64.tar.gz" + X64_TGZ="$TMP_NODE/node-x64.tar.gz" + ARM64_NODE="$TMP_NODE/node-arm64" + X64_NODE="$TMP_NODE/node-x64" + + echo " Downloading arm64..." + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-arm64.tar.gz" -o "$ARM64_TGZ" + echo " Downloading x64..." + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-darwin-x64.tar.gz" -o "$X64_TGZ" + + echo " Extracting..." + tar -xzf "$ARM64_TGZ" -C "$TMP_NODE" "node-v${NODE_VERSION}-darwin-arm64/bin/node" + tar -xzf "$X64_TGZ" -C "$TMP_NODE" "node-v${NODE_VERSION}-darwin-x64/bin/node" + mv "$TMP_NODE/node-v${NODE_VERSION}-darwin-arm64/bin/node" "$ARM64_NODE" + mv "$TMP_NODE/node-v${NODE_VERSION}-darwin-x64/bin/node" "$X64_NODE" + + echo " Creating universal binary..." + /usr/bin/lipo -create "$ARM64_NODE" "$X64_NODE" -output "$NODE_DEST/node" + chmod +x "$NODE_DEST/node" + /usr/bin/codesign --remove-signature "$NODE_DEST/node" 2>/dev/null || true + + echo " ✓ Node.js $(/usr/bin/file "$NODE_DEST/node")" + trap - EXIT + rm -rf "$TMP_NODE" +else + echo "📦 Skipping Node.js bundle (SKIP_NODE_BUNDLE=1)" +fi + echo "⏹ Stopping any running OpenClaw" killall -q OpenClaw 2>/dev/null || true