feat(macos): prompt for CLI install
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.11 (Unreleased)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode, and use external launchd/CLI instead of the embedded gateway runtime.
|
||||||
|
|
||||||
## 2026.1.10
|
## 2026.1.10
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
71
apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift
Normal file
71
apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CLIInstallPrompter {
|
||||||
|
static let shared = CLIInstallPrompter()
|
||||||
|
private let logger = Logger(subsystem: "com.clawdbot", category: "cli.prompt")
|
||||||
|
private var isPrompting = false
|
||||||
|
|
||||||
|
func checkAndPromptIfNeeded(reason: String) {
|
||||||
|
guard self.shouldPrompt() else { return }
|
||||||
|
guard let version = Self.appVersion() else { return }
|
||||||
|
self.isPrompting = true
|
||||||
|
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Install Clawdbot CLI?"
|
||||||
|
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
|
||||||
|
alert.addButton(withTitle: "Install CLI")
|
||||||
|
alert.addButton(withTitle: "Not now")
|
||||||
|
alert.addButton(withTitle: "Open Settings")
|
||||||
|
let response = alert.runModal()
|
||||||
|
|
||||||
|
switch response {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
Task { await self.installCLI() }
|
||||||
|
case .alertThirdButtonReturn:
|
||||||
|
self.openSettings(tab: .general)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
|
||||||
|
self.isPrompting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldPrompt() -> Bool {
|
||||||
|
guard !self.isPrompting else { return false }
|
||||||
|
guard AppStateStore.shared.onboardingSeen else { return false }
|
||||||
|
guard AppStateStore.shared.connectionMode == .local else { return false }
|
||||||
|
guard !AppStateStore.shared.attachExistingGatewayOnly else { return false }
|
||||||
|
guard CLIInstaller.installedLocation() == nil else { return false }
|
||||||
|
guard let version = Self.appVersion() else { return false }
|
||||||
|
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||||
|
return lastPrompt != version
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installCLI() async {
|
||||||
|
var lastStatus: String?
|
||||||
|
await CLIInstaller.install { message in
|
||||||
|
lastStatus = message
|
||||||
|
}
|
||||||
|
if let message = lastStatus {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "CLI install finished"
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSettings(tab: SettingsTab) {
|
||||||
|
SettingsTabRouter.request(tab)
|
||||||
|
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||||
|
NSApp.sendAction(#selector(NSApplication.showSettingsWindow), to: nil, from: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appVersion() -> String? {
|
||||||
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,24 +2,16 @@ import Foundation
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
enum CLIInstaller {
|
enum CLIInstaller {
|
||||||
private static func embeddedHelperURL() -> URL {
|
|
||||||
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdbot")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func installedLocation() -> String? {
|
static func installedLocation() -> String? {
|
||||||
self.installedLocation(
|
self.installedLocation(
|
||||||
searchPaths: cliHelperSearchPaths,
|
searchPaths: CommandResolver.preferredPaths(),
|
||||||
embeddedHelper: self.embeddedHelperURL(),
|
|
||||||
fileManager: .default)
|
fileManager: .default)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func installedLocation(
|
static func installedLocation(
|
||||||
searchPaths: [String],
|
searchPaths: [String],
|
||||||
embeddedHelper: URL,
|
|
||||||
fileManager: FileManager) -> String?
|
fileManager: FileManager) -> String?
|
||||||
{
|
{
|
||||||
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
|
||||||
|
|
||||||
for basePath in searchPaths {
|
for basePath in searchPaths {
|
||||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path
|
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path
|
||||||
var isDirectory: ObjCBool = false
|
var isDirectory: ObjCBool = false
|
||||||
@@ -32,10 +24,7 @@ enum CLIInstaller {
|
|||||||
|
|
||||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||||
|
|
||||||
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
return candidate
|
||||||
if resolved == embedded {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -46,57 +35,9 @@ enum CLIInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||||
let helper = self.embeddedHelperURL()
|
let expected = GatewayEnvironment.expectedGatewayVersion()
|
||||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
await GatewayEnvironment.installGlobal(version: expected) { message in
|
||||||
await statusHandler(
|
Task { @MainActor in await statusHandler(message) }
|
||||||
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
|
||||||
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdbot" }
|
|
||||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
|
||||||
await statusHandler(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
|
||||||
let escapedSource = self.shellEscape(source)
|
|
||||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
|
||||||
let cmds = [
|
|
||||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
|
||||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
|
||||||
].joined(separator: "; ")
|
|
||||||
|
|
||||||
let script = """
|
|
||||||
do shell script "\(cmds)" with administrator privileges
|
|
||||||
"""
|
|
||||||
|
|
||||||
let proc = Process()
|
|
||||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
||||||
proc.arguments = ["-e", script]
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
|
||||||
proc.standardOutput = pipe
|
|
||||||
proc.standardError = pipe
|
|
||||||
|
|
||||||
do {
|
|
||||||
try proc.run()
|
|
||||||
proc.waitUntilExit()
|
|
||||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
|
||||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if proc.terminationStatus == 0 {
|
|
||||||
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
|
||||||
}
|
|
||||||
if output.lowercased().contains("user canceled") {
|
|
||||||
return "Install canceled"
|
|
||||||
}
|
|
||||||
return "Failed to install CLI helper: \(output)"
|
|
||||||
} catch {
|
|
||||||
return "Failed to run installer: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func shellEscape(_ path: String) -> String {
|
|
||||||
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ let deepLinkKeyKey = "clawdbot.deepLinkKey"
|
|||||||
let modelCatalogPathKey = "clawdbot.modelCatalogPath"
|
let modelCatalogPathKey = "clawdbot.modelCatalogPath"
|
||||||
let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
|
let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
|
||||||
let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
|
let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
|
||||||
|
let cliInstallPromptedVersionKey = "clawdbot.cliInstallPromptedVersion"
|
||||||
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
|
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
|
||||||
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"
|
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"
|
||||||
let appLogLevelKey = "clawdbot.debug.appLogLevel"
|
let appLogLevelKey = "clawdbot.debug.appLogLevel"
|
||||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ struct DebugSettings: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("CLI helper")
|
self.gridLabel("CLI")
|
||||||
let loc = CLIInstaller.installedLocation()
|
let loc = CLIInstaller.installedLocation()
|
||||||
Text(loc ?? "missing")
|
Text(loc ?? "missing")
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
|
|||||||
@@ -64,19 +64,6 @@ struct GatewayCommandResolution {
|
|||||||
enum GatewayEnvironment {
|
enum GatewayEnvironment {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
|
||||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
private static let bundledGatewayLabel = "Bundled gateway"
|
|
||||||
|
|
||||||
static func bundledGatewayExecutable() -> String? {
|
|
||||||
guard let res = Bundle.main.resourceURL else { return nil }
|
|
||||||
let path = res.appendingPathComponent("Relay/clawdbot").path
|
|
||||||
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func bundledNodeExecutable() -> String? {
|
|
||||||
guard let res = Bundle.main.resourceURL else { return nil }
|
|
||||||
let path = res.appendingPathComponent("Relay/node").path
|
|
||||||
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func gatewayPort() -> Int {
|
static func gatewayPort() -> Int {
|
||||||
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
|
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
|
||||||
@@ -112,32 +99,6 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
let expected = self.expectedGatewayVersion()
|
let expected = self.expectedGatewayVersion()
|
||||||
|
|
||||||
if let bundled = self.bundledGatewayExecutable() {
|
|
||||||
let installed = self.readGatewayVersion(binary: bundled)
|
|
||||||
let bundledNode = self.bundledNodeExecutable()
|
|
||||||
let bundledNodeVersion = bundledNode.flatMap { self.readNodeVersion(binary: $0) }
|
|
||||||
if let expected, let installed, !installed.compatible(with: expected) {
|
|
||||||
let message = self.bundledGatewayIncompatibleMessage(
|
|
||||||
installed: installed,
|
|
||||||
expected: expected)
|
|
||||||
return GatewayEnvironmentStatus(
|
|
||||||
kind: .incompatible(found: installed.description, required: expected.description),
|
|
||||||
nodeVersion: bundledNodeVersion,
|
|
||||||
gatewayVersion: installed.description,
|
|
||||||
requiredGateway: expected.description,
|
|
||||||
message: message)
|
|
||||||
}
|
|
||||||
let gatewayVersionText = installed?.description ?? "unknown"
|
|
||||||
return GatewayEnvironmentStatus(
|
|
||||||
kind: .ok,
|
|
||||||
nodeVersion: bundledNodeVersion,
|
|
||||||
gatewayVersion: gatewayVersionText,
|
|
||||||
requiredGateway: expected?.description,
|
|
||||||
message: self.bundledGatewayStatusMessage(
|
|
||||||
gatewayVersion: gatewayVersionText,
|
|
||||||
nodeVersion: bundledNodeVersion))
|
|
||||||
}
|
|
||||||
|
|
||||||
let projectRoot = CommandResolver.projectRoot()
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
|
|
||||||
@@ -208,7 +169,6 @@ enum GatewayEnvironment {
|
|||||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
let status = self.check()
|
let status = self.check()
|
||||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||||
let bundled = self.bundledGatewayExecutable()
|
|
||||||
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
||||||
|
|
||||||
guard case .ok = status.kind else {
|
guard case .ok = status.kind else {
|
||||||
@@ -216,20 +176,17 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = self.gatewayPort()
|
let port = self.gatewayPort()
|
||||||
if let bundled {
|
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
|
||||||
let cmd = [bundled, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
|
||||||
}
|
|
||||||
if let gatewayBin {
|
if let gatewayBin {
|
||||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
|
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let entry = projectEntrypoint,
|
if let entry = projectEntrypoint,
|
||||||
case let .success(resolvedRuntime) = runtime
|
case let .success(resolvedRuntime) = runtime
|
||||||
{
|
{
|
||||||
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
|
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,40 +320,4 @@ enum GatewayEnvironment {
|
|||||||
return Semver.parse(version)
|
return Semver.parse(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readNodeVersion(binary: String) -> String? {
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = URL(fileURLWithPath: binary)
|
|
||||||
process.arguments = ["--version"]
|
|
||||||
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
|
||||||
process.standardOutput = pipe
|
|
||||||
process.standardError = pipe
|
|
||||||
do {
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
|
||||||
let raw = String(data: data, encoding: .utf8)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
|
|
||||||
return raw?.isEmpty == false ? raw : nil
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func bundledGatewayStatusMessage(
|
|
||||||
gatewayVersion: String,
|
|
||||||
nodeVersion: String?) -> String
|
|
||||||
{
|
|
||||||
"\(self.bundledGatewayLabel) \(gatewayVersion) (node \(nodeVersion ?? "unknown"))"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func bundledGatewayIncompatibleMessage(
|
|
||||||
installed: Semver,
|
|
||||||
expected: Semver) -> String
|
|
||||||
{
|
|
||||||
"\(self.bundledGatewayLabel) \(installed.description) is incompatible with app " +
|
|
||||||
"\(expected.description); rebuild the app bundle."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,32 +16,41 @@ enum GatewayLaunchAgentManager {
|
|||||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func gatewayExecutablePath(bundlePath: String) -> String {
|
private static func gatewayProgramArguments(port: Int, bind: String) -> Result<[String], String> {
|
||||||
"\(bundlePath)/Contents/Resources/Relay/clawdbot"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func relayDir(bundlePath: String) -> String {
|
|
||||||
"\(bundlePath)/Contents/Resources/Relay"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let projectRoot = CommandResolver.projectRoot()
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||||
}
|
}
|
||||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||||
case let .success(runtime) = CommandResolver.runtimeResolution()
|
case let .success(runtime) = CommandResolver.runtimeResolution()
|
||||||
{
|
{
|
||||||
return CommandResolver.makeRuntimeCommand(
|
let cmd = CommandResolver.makeRuntimeCommand(
|
||||||
runtime: runtime,
|
runtime: runtime,
|
||||||
entrypoint: entry,
|
entrypoint: entry,
|
||||||
subcommand: "gateway",
|
subcommand: "gateway-daemon",
|
||||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
|
return .success(cmd)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
let searchPaths = CommandResolver.preferredPaths()
|
||||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
|
||||||
|
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
|
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||||
|
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
||||||
|
{
|
||||||
|
let cmd = CommandResolver.makeRuntimeCommand(
|
||||||
|
runtime: runtime,
|
||||||
|
entrypoint: entry,
|
||||||
|
subcommand: "gateway-daemon",
|
||||||
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
|
return .success(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .failure("clawdbot CLI not found in PATH; install the global package.")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isLoaded() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
@@ -51,6 +60,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
|
_ = bundlePath
|
||||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||||
self.logger.info("launchd enable skipped (attach-only or disable marker set)")
|
self.logger.info("launchd enable skipped (attach-only or disable marker set)")
|
||||||
return nil
|
return nil
|
||||||
@@ -58,11 +68,6 @@ enum GatewayLaunchAgentManager {
|
|||||||
if enabled {
|
if enabled {
|
||||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
|
||||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
|
||||||
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
|
||||||
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
|
||||||
}
|
|
||||||
|
|
||||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let desiredToken = self.preferredGatewayToken()
|
let desiredToken = self.preferredGatewayToken()
|
||||||
@@ -72,6 +77,14 @@ enum GatewayLaunchAgentManager {
|
|||||||
bind: desiredBind,
|
bind: desiredBind,
|
||||||
token: desiredToken,
|
token: desiredToken,
|
||||||
password: desiredPassword)
|
password: desiredPassword)
|
||||||
|
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
||||||
|
guard case let .success(programArguments) = programArgumentsResult else {
|
||||||
|
if case let .failure(message) = programArgumentsResult {
|
||||||
|
self.logger.error("launchd enable failed: \(message)")
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return "Failed to resolve gateway command."
|
||||||
|
}
|
||||||
|
|
||||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||||
@@ -87,7 +100,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||||
self.writePlist(bundlePath: bundlePath, port: port)
|
self.writePlist(programArguments: programArguments)
|
||||||
|
|
||||||
await self.ensureEnabled()
|
await self.ensureEnabled()
|
||||||
if loaded {
|
if loaded {
|
||||||
@@ -117,18 +130,13 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func writePlist(bundlePath: String, port: Int) {
|
private static func writePlist(bundlePath: String, port: Int) {
|
||||||
let relayDir = self.relayDir(bundlePath: bundlePath)
|
private static func writePlist(programArguments: [String]) {
|
||||||
let preferredPath = ([relayDir] + CommandResolver.preferredPaths())
|
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
.joined(separator: ":")
|
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
|
||||||
let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind)
|
|
||||||
let token = self.preferredGatewayToken()
|
let token = self.preferredGatewayToken()
|
||||||
let password = self.preferredGatewayPassword()
|
let password = self.preferredGatewayPassword()
|
||||||
var envEntries = """
|
var envEntries = """
|
||||||
<key>PATH</key>
|
<key>PATH</key>
|
||||||
<string>\(preferredPath)</string>
|
<string>\(preferredPath)</string>
|
||||||
<key>CLAWDBOT_IMAGE_BACKEND</key>
|
|
||||||
<string>sips</string>
|
|
||||||
"""
|
"""
|
||||||
if let token {
|
if let token {
|
||||||
let escapedToken = self.escapePlistValue(token)
|
let escapedToken = self.escapePlistValue(token)
|
||||||
@@ -319,14 +327,6 @@ extension GatewayLaunchAgentManager {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension GatewayLaunchAgentManager {
|
extension GatewayLaunchAgentManager {
|
||||||
static func _testGatewayExecutablePath(bundlePath: String) -> String {
|
|
||||||
self.gatewayExecutablePath(bundlePath: bundlePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func _testRelayDir(bundlePath: String) -> String {
|
|
||||||
self.relayDir(bundlePath: bundlePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func _testPreferredGatewayBind() -> String? {
|
static func _testPreferredGatewayBind() -> String? {
|
||||||
self.preferredGatewayBind()
|
self.preferredGatewayBind()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ struct GeneralSettings: View {
|
|||||||
Button {
|
Button {
|
||||||
Task { await self.installCLI() }
|
Task { await self.installCLI() }
|
||||||
} label: {
|
} label: {
|
||||||
let title = self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper"
|
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||||
ZStack {
|
ZStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
.opacity(self.isInstallingCLI ? 0 : 1)
|
||||||
@@ -393,7 +393,7 @@ struct GeneralSettings: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
} else {
|
} else {
|
||||||
Text("Symlink \"clawdbot\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
|
Text("Installs via npm/pnpm/bun; requires Node 22+.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ enum LogLocator {
|
|||||||
stdoutLog.path
|
stdoutLog.path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path to use for the embedded Gateway launchd job stdout/err.
|
/// Path to use for the Gateway launchd job stdout/err.
|
||||||
static var launchdGatewayLogPath: String {
|
static var launchdGatewayLogPath: String {
|
||||||
gatewayLog.path
|
gatewayLog.path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ struct ClawdbotApp: App {
|
|||||||
}
|
}
|
||||||
.onChange(of: self.state.connectionMode) { _, mode in
|
.onChange(of: self.state.connectionMode) { _, mode in
|
||||||
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||||
|
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings {
|
Settings {
|
||||||
@@ -262,6 +263,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||||
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
||||||
self.scheduleFirstRunOnboardingIfNeeded()
|
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).
|
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
|
||||||
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
var canAdvance: Bool { !self.isWizardBlocking }
|
var canAdvance: Bool { !self.isWizardBlocking }
|
||||||
var devLinkCommand: String {
|
var devLinkCommand: String {
|
||||||
let bundlePath = Bundle.main.bundlePath
|
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||||
return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdbot' /usr/local/bin/clawdbot"
|
return "npm install -g clawdbot@\(version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LocalGatewayProbe: Equatable {
|
struct LocalGatewayProbe: Equatable {
|
||||||
|
|||||||
@@ -494,9 +494,9 @@ extension OnboardingView {
|
|||||||
|
|
||||||
func cliPage() -> some View {
|
func cliPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Install the helper CLI")
|
Text("Install the CLI")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("Optional, but recommended: link `clawdbot` so scripts can reach the local gateway.")
|
Text("Required for local mode: installs `clawdbot` so launchd can run the gateway.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -522,7 +522,7 @@ extension OnboardingView {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(self.installingCLI)
|
.disabled(self.installingCLI)
|
||||||
|
|
||||||
Button(self.copied ? "Copied" : "Copy dev link") {
|
Button(self.copied ? "Copied" : "Copy install command") {
|
||||||
self.copyToPasteboard(self.devLinkCommand)
|
self.copyToPasteboard(self.devLinkCommand)
|
||||||
}
|
}
|
||||||
.disabled(self.installingCLI)
|
.disabled(self.installingCLI)
|
||||||
@@ -541,8 +541,8 @@ extension OnboardingView {
|
|||||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||||
Text(
|
Text(
|
||||||
"""
|
"""
|
||||||
We install into /usr/local/bin and /opt/homebrew/bin.
|
Uses npm/pnpm/bun. Requires Node 22+ on this Mac.
|
||||||
Rerun anytime if you move the build output.
|
Rerun anytime to reinstall or update.
|
||||||
""")
|
""")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -5,39 +5,30 @@ import Testing
|
|||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct CLIInstallerTests {
|
struct CLIInstallerTests {
|
||||||
@Test func installedLocationOnlyAcceptsEmbeddedHelper() throws {
|
@Test func installedLocationFindsExecutable() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let root = fm.temporaryDirectory.appendingPathComponent(
|
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||||
"clawdbot-cli-installer-\(UUID().uuidString)")
|
"clawdbot-cli-installer-\(UUID().uuidString)")
|
||||||
defer { try? fm.removeItem(at: root) }
|
defer { try? fm.removeItem(at: root) }
|
||||||
|
|
||||||
let embedded = root.appendingPathComponent("Relay/clawdbot")
|
|
||||||
try fm.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
||||||
fm.createFile(atPath: embedded.path, contents: Data())
|
|
||||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path)
|
|
||||||
|
|
||||||
let binDir = root.appendingPathComponent("bin")
|
let binDir = root.appendingPathComponent("bin")
|
||||||
try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
|
try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
|
||||||
let link = binDir.appendingPathComponent("clawdbot")
|
let cli = binDir.appendingPathComponent("clawdbot")
|
||||||
try fm.createSymbolicLink(at: link, withDestinationURL: embedded)
|
fm.createFile(atPath: cli.path, contents: Data())
|
||||||
|
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli.path)
|
||||||
|
|
||||||
let found = CLIInstaller.installedLocation(
|
let found = CLIInstaller.installedLocation(
|
||||||
searchPaths: [binDir.path],
|
searchPaths: [binDir.path],
|
||||||
embeddedHelper: embedded,
|
|
||||||
fileManager: fm)
|
fileManager: fm)
|
||||||
#expect(found == link.path)
|
#expect(found == cli.path)
|
||||||
|
|
||||||
try fm.removeItem(at: link)
|
try fm.removeItem(at: cli)
|
||||||
let other = root.appendingPathComponent("Other/clawdbot")
|
fm.createFile(atPath: cli.path, contents: Data())
|
||||||
try fm.createDirectory(at: other.deletingLastPathComponent(), withIntermediateDirectories: true)
|
try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: cli.path)
|
||||||
fm.createFile(atPath: other.path, contents: Data())
|
|
||||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: other.path)
|
|
||||||
try fm.createSymbolicLink(at: link, withDestinationURL: other)
|
|
||||||
|
|
||||||
let rejected = CLIInstaller.installedLocation(
|
let missing = CLIInstaller.installedLocation(
|
||||||
searchPaths: [binDir.path],
|
searchPaths: [binDir.path],
|
||||||
embeddedHelper: embedded,
|
|
||||||
fileManager: fm)
|
fileManager: fm)
|
||||||
#expect(rejected == nil)
|
#expect(missing == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,6 @@ struct LowCoverageHelperTests {
|
|||||||
#expect(
|
#expect(
|
||||||
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
GatewayLaunchAgentManager._testEscapePlistValue("a&b<c>\"'") ==
|
||||||
"a&b<c>"'")
|
"a&b<c>"'")
|
||||||
|
|
||||||
#expect(GatewayLaunchAgentManager
|
|
||||||
._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
|
|
||||||
#expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,8 +339,12 @@ sleep 1
|
|||||||
kill -9 <PID> # last resort
|
kill -9 <PID> # last resort
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fix 3: Check embedded gateway**
|
**Fix 3: Check the CLI install**
|
||||||
Ensure the gateway relay was properly bundled. Run [`./scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) and ensure Node is available (the script downloads a bundled runtime by default).
|
Ensure the global `clawdbot` CLI is installed and matches the app version:
|
||||||
|
```bash
|
||||||
|
clawdbot --version
|
||||||
|
npm install -g clawdbot@<version>
|
||||||
|
```
|
||||||
|
|
||||||
## Debug Mode
|
## Debug Mode
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +1,63 @@
|
|||||||
---
|
---
|
||||||
summary: "Bundled gateway runtime: packaging, launchd, signing, and bundling"
|
summary: "Gateway runtime on macOS (external launchd service)"
|
||||||
read_when:
|
read_when:
|
||||||
- Packaging Clawdbot.app
|
- Packaging Clawdbot.app
|
||||||
- Debugging the bundled gateway binary
|
- Debugging the macOS gateway launchd service
|
||||||
- Changing relay bundling flags or codesigning
|
- Installing the gateway CLI for macOS
|
||||||
---
|
---
|
||||||
|
|
||||||
# Bundled Gateway (macOS)
|
# Gateway on macOS (external launchd)
|
||||||
|
|
||||||
Goal: ship **Clawdbot.app** with a self-contained relay that can run the CLI and
|
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
||||||
Gateway daemon. No global `npm install -g clawdbot`, no system Node requirement.
|
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
|
||||||
|
service to keep the Gateway running.
|
||||||
|
|
||||||
## What gets bundled
|
## Install the CLI (required for local mode)
|
||||||
|
|
||||||
App bundle layout:
|
You need Node 22+ on the Mac, then install `clawdbot` globally:
|
||||||
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/node`
|
```bash
|
||||||
- Node runtime binary (downloaded during packaging, stripped for size)
|
npm install -g clawdbot@<version>
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/dist/`
|
```
|
||||||
- Compiled CLI/gateway payload from `pnpm exec tsc`
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/node_modules/`
|
|
||||||
- Production dependencies staged via `pnpm deploy --prod --legacy` (includes optional native addons)
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/clawdbot`
|
|
||||||
- Wrapper script that execs the bundled Node + dist entrypoint
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/package.json`
|
|
||||||
- tiny “Pi runtime compatibility” file (see below, includes `"type": "module"`)
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/skills/`
|
|
||||||
- Bundled skills payload (required for Pi tools)
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/theme/`
|
|
||||||
- Pi TUI theme payload (optional, but strongly recommended)
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/a2ui/`
|
|
||||||
- A2UI host assets (served by the gateway)
|
|
||||||
- `Clawdbot.app/Contents/Resources/Relay/control-ui/`
|
|
||||||
- Control UI build output (served by the gateway)
|
|
||||||
|
|
||||||
Why the sidecar files matter:
|
The macOS app’s **Install CLI** button runs the same flow via npm/pnpm/bun.
|
||||||
- The embedded Pi runtime detects “bundled relay mode” and then looks for
|
|
||||||
`package.json` + `theme/` **next to `process.execPath`** (i.e. next to
|
|
||||||
`node`). Keep the sidecar files.
|
|
||||||
|
|
||||||
## Build pipeline
|
|
||||||
|
|
||||||
Packaging script:
|
|
||||||
- [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh)
|
|
||||||
|
|
||||||
It builds:
|
|
||||||
- TS: `pnpm exec tsc`
|
|
||||||
- Swift app + helper: `swift build …`
|
|
||||||
- Relay payload: `pnpm deploy --prod --legacy` + copy `dist/`
|
|
||||||
- Node runtime: downloads the latest Node release (override via `NODE_VERSION`)
|
|
||||||
|
|
||||||
Important knobs:
|
|
||||||
- `NODE_VERSION=22.12.0` → pin a specific Node version
|
|
||||||
- `NODE_DIST_MIRROR=…` → mirror for downloads (default: nodejs.org)
|
|
||||||
- `STRIP_NODE=0` → keep symbols (default strips to reduce size)
|
|
||||||
- `BUNDLED_RUNTIME=bun` → switch the relay build back to Bun (`bun --compile`)
|
|
||||||
|
|
||||||
Version injection:
|
|
||||||
- The relay wrapper exports `CLAWDBOT_BUNDLED_VERSION` so `--version` works
|
|
||||||
without reading `package.json` at runtime.
|
|
||||||
|
|
||||||
## Launchd (Gateway as LaunchAgent)
|
## Launchd (Gateway as LaunchAgent)
|
||||||
|
|
||||||
Label:
|
Label:
|
||||||
- `com.clawdbot.gateway` (or `com.clawdbot.<profile>`)
|
- `com.clawdbot.gateway` (or `com.clawdbot.<profile>`)
|
||||||
|
|
||||||
Plist location (per-user):
|
Plist location (per‑user):
|
||||||
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist` (or `.../com.clawdbot.<profile>.plist`)
|
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
|
||||||
|
|
||||||
Manager:
|
Manager:
|
||||||
- The macOS app owns LaunchAgent install/update for the bundled gateway.
|
- The macOS app owns LaunchAgent install/update in Local mode.
|
||||||
|
- The CLI can also install it: `clawdbot daemon install`.
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||||
- App quit does **not** stop the gateway (launchd keeps it alive).
|
- App quit does **not** stop the gateway (launchd keeps it alive).
|
||||||
- CLI install (`clawdbot daemon install`) writes the same LaunchAgent; `--force` rewrites it.
|
|
||||||
|
|
||||||
Logging:
|
Logging:
|
||||||
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
||||||
|
|
||||||
Default LaunchAgent env:
|
## Version compatibility
|
||||||
- `CLAWDBOT_IMAGE_BACKEND=sips` (avoid sharp native addon inside the bundle)
|
|
||||||
|
|
||||||
## Codesigning (hardened runtime + Node)
|
The macOS app checks the gateway version against its own version. If they’re
|
||||||
|
incompatible, update the global CLI to match the app version.
|
||||||
|
|
||||||
Node uses JIT. The bundled runtime is signed with:
|
## Smoke check
|
||||||
- `com.apple.security.cs.allow-jit`
|
|
||||||
- `com.apple.security.cs.allow-unsigned-executable-memory`
|
|
||||||
|
|
||||||
This is applied by `scripts/codesign-mac-app.sh`.
|
|
||||||
|
|
||||||
Note: because the relay runs under hardened runtime, any bundled `*.node` native
|
|
||||||
addons must be signed with the same Team ID as the relay `node` binary.
|
|
||||||
`scripts/codesign-mac-app.sh` re-signs `Contents/Resources/Relay/**/*.node` for this.
|
|
||||||
|
|
||||||
## Image processing
|
|
||||||
|
|
||||||
To avoid shipping native `sharp` addons inside the bundle, the gateway defaults
|
|
||||||
to `/usr/bin/sips` for image ops when run from the app (via launchd env + wrapper).
|
|
||||||
|
|
||||||
## Tests / smoke checks
|
|
||||||
|
|
||||||
From a packaged app (local build):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dist/Clawdbot.app/Contents/Resources/Relay/clawdbot --version
|
clawdbot --version
|
||||||
|
|
||||||
CLAWDBOT_SKIP_PROVIDERS=1 \
|
CLAWDBOT_SKIP_PROVIDERS=1 \
|
||||||
CLAWDBOT_SKIP_CANVAS_HOST=1 \
|
CLAWDBOT_SKIP_CANVAS_HOST=1 \
|
||||||
dist/Clawdbot.app/Contents/Resources/Relay/clawdbot gateway --port 18999 --bind loopback
|
clawdbot gateway --port 18999 --bind loopback
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, in another shell:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm -s clawdbot gateway call health --url ws://127.0.0.1:18999 --timeout 3000
|
clawdbot gateway call health --url ws://127.0.0.1:18999 --timeout 3000
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Gateway lifecycle on macOS
|
# Gateway lifecycle on macOS
|
||||||
|
|
||||||
The macOS app **manages the Gateway via launchd** by default. This gives you
|
The macOS app **manages the Gateway via launchd** by default. The launchd job
|
||||||
reliable auto‑start at login and restart on crashes.
|
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
|
||||||
|
auto‑start at login and restart on crashes.
|
||||||
|
|
||||||
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
||||||
If you need tighter coupling to the UI, use **Attach‑only** and run the Gateway
|
If you need tighter coupling to the UI, use **Attach‑only** and run the Gateway
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ This guide covers the necessary steps to build and run the Clawdbot macOS applic
|
|||||||
Before building the app, ensure you have the following installed:
|
Before building the app, ensure you have the following installed:
|
||||||
|
|
||||||
1. **Xcode 26.2+**: Required for Swift development.
|
1. **Xcode 26.2+**: Required for Swift development.
|
||||||
2. **Node.js & pnpm**: Required for the gateway and CLI components.
|
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||||
3. **Node**: Required to package the embedded gateway relay (the script can download a bundled runtime).
|
|
||||||
|
|
||||||
## 1. Initialize Submodules
|
## 1. Initialize Submodules
|
||||||
|
|
||||||
@@ -39,24 +38,22 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
|||||||
./scripts/package-mac-app.sh
|
./scripts/package-mac-app.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `BUNDLED_RUNTIME=node|bun` to switch the embedded gateway runtime (default: node).
|
|
||||||
|
|
||||||
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
||||||
|
|
||||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||||
|
|
||||||
## 4. Install the CLI Helper
|
## 4. Install the CLI
|
||||||
|
|
||||||
The macOS app requires a symlink named `clawdbot` in `/usr/local/bin` or `/opt/homebrew/bin` to manage background tasks.
|
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||||
|
|
||||||
**To install it:**
|
**To install it (recommended):**
|
||||||
1. Open the Clawdbot app.
|
1. Open the Clawdbot app.
|
||||||
2. Go to the **General** settings tab.
|
2. Go to the **General** settings tab.
|
||||||
3. Click **"Install CLI helper"** (requires administrator privileges).
|
3. Click **"Install CLI"**.
|
||||||
|
|
||||||
Alternatively, you can manually link it from your Admin account:
|
Alternatively, install it manually:
|
||||||
```bash
|
```bash
|
||||||
sudo ln -sf "/Users/$(whoami)/Projects/clawdbot/dist/Clawdbot.app/Contents/Resources/Relay/clawdbot" /usr/local/bin/clawdbot
|
npm install -g clawdbot@<version>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
|||||||
|
|
||||||
- sets a stable debug bundle identifier: `com.clawdbot.mac.debug`
|
- sets a stable debug bundle identifier: `com.clawdbot.mac.debug`
|
||||||
- writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`)
|
- writes the Info.plist with that bundle id (override via `BUNDLE_ID=...`)
|
||||||
- calls [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) to sign the main binary, bundled CLI, and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
|
- calls [`scripts/codesign-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)).
|
||||||
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
|
- uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds).
|
||||||
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||||
- **Packaging requires Node**: The embedded gateway relay is bundled with Node. Ensure Node is available for the packaging script (or set `NODE_VERSION` to pin the download).
|
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
||||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ node.
|
|||||||
- Runs or connects to the Gateway (local or remote).
|
- Runs or connects to the Gateway (local or remote).
|
||||||
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
|
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
|
||||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||||
- Installs a helper CLI (`clawdbot`) into `/usr/local/bin` and
|
- Installs the global CLI (`clawdbot`) via npm/pnpm/bun on request.
|
||||||
`/opt/homebrew/bin` on request.
|
|
||||||
|
|
||||||
## Local vs remote mode
|
## Local vs remote mode
|
||||||
|
|
||||||
@@ -84,14 +83,13 @@ Safety:
|
|||||||
1) Install and launch **Clawdbot.app**.
|
1) Install and launch **Clawdbot.app**.
|
||||||
2) Complete the permissions checklist (TCC prompts).
|
2) Complete the permissions checklist (TCC prompts).
|
||||||
3) Ensure **Local** mode is active and the Gateway is running.
|
3) Ensure **Local** mode is active and the Gateway is running.
|
||||||
4) Install the CLI helper if you want terminal access.
|
4) Install the CLI if you want terminal access.
|
||||||
|
|
||||||
## Build & dev workflow (native)
|
## Build & dev workflow (native)
|
||||||
|
|
||||||
- `cd apps/macos && swift build`
|
- `cd apps/macos && swift build`
|
||||||
- `swift run Clawdbot` (or Xcode)
|
- `swift run Clawdbot` (or Xcode)
|
||||||
- Package app + CLI: `scripts/package-mac-app.sh`
|
- Package app: `scripts/package-mac-app.sh`
|
||||||
- Switch bundled gateway runtime with `BUNDLED_RUNTIME=node|bun` (default: node).
|
|
||||||
|
|
||||||
## Debug gateway discovery (macOS CLI)
|
## Debug gateway discovery (macOS CLI)
|
||||||
|
|
||||||
@@ -115,6 +113,6 @@ the Node CLI’s `dns-sd` based discovery.
|
|||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
- [Gateway runbook](/gateway)
|
- [Gateway runbook](/gateway)
|
||||||
- [Bundled Node Gateway](/platforms/mac/bundled-gateway)
|
- [Gateway (macOS)](/platforms/mac/bundled-gateway)
|
||||||
- [macOS permissions](/platforms/mac/permissions)
|
- [macOS permissions](/platforms/mac/permissions)
|
||||||
- [Canvas](/platforms/mac/canvas)
|
- [Canvas](/platforms/mac/canvas)
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
- [macOS remote](/platforms/mac/remote)
|
- [macOS remote](/platforms/mac/remote)
|
||||||
- [macOS signing](/platforms/mac/signing)
|
- [macOS signing](/platforms/mac/signing)
|
||||||
- [macOS release](/platforms/mac/release)
|
- [macOS release](/platforms/mac/release)
|
||||||
- [macOS bundled gateway (Node)](/platforms/mac/bundled-gateway)
|
- [macOS gateway (launchd)](/platforms/mac/bundled-gateway)
|
||||||
- [macOS XPC](/platforms/mac/xpc)
|
- [macOS XPC](/platforms/mac/xpc)
|
||||||
- [macOS skills](/platforms/mac/skills)
|
- [macOS skills](/platforms/mac/skills)
|
||||||
- [macOS Peekaboo](/platforms/mac/peekaboo)
|
- [macOS Peekaboo](/platforms/mac/peekaboo)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ wizard, and let the agent bootstrap itself.
|
|||||||
3) **Auth (Anthropic OAuth)** — local only
|
3) **Auth (Anthropic OAuth)** — local only
|
||||||
4) **Setup Wizard** (Gateway‑driven)
|
4) **Setup Wizard** (Gateway‑driven)
|
||||||
5) **Permissions** (TCC prompts)
|
5) **Permissions** (TCC prompts)
|
||||||
6) **CLI helper** (optional)
|
6) **CLI** (optional)
|
||||||
7) **Onboarding chat** (dedicated session)
|
7) **Onboarding chat** (dedicated session)
|
||||||
8) Ready
|
8) Ready
|
||||||
|
|
||||||
@@ -62,10 +62,10 @@ Onboarding requests TCC permissions needed for:
|
|||||||
- Microphone / Speech Recognition
|
- Microphone / Speech Recognition
|
||||||
- Automation (AppleScript)
|
- Automation (AppleScript)
|
||||||
|
|
||||||
## 5) CLI helper (optional)
|
## 5) CLI (optional)
|
||||||
|
|
||||||
The app can symlink the bundled `clawdbot` CLI into `/usr/local/bin` and
|
The app can install the global `clawdbot` CLI via npm/pnpm/bun so terminal
|
||||||
`/opt/homebrew/bin` so terminal workflows work out of the box.
|
workflows and launchd tasks work out of the box.
|
||||||
|
|
||||||
## 6) Onboarding chat (dedicated session)
|
## 6) Onboarding chat (dedicated session)
|
||||||
|
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ All endpoints accept `?profile=<name>`.
|
|||||||
### Playwright requirement
|
### Playwright requirement
|
||||||
|
|
||||||
Some features (navigate/act/ai snapshot, element screenshots, PDF) require
|
Some features (navigate/act/ai snapshot, element screenshots, PDF) require
|
||||||
Playwright. In embedded gateway builds, Playwright may be unavailable; those
|
Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
||||||
endpoints return a clear 501 error. ARIA snapshots and basic screenshots still work.
|
error. ARIA snapshots and basic screenshots still work.
|
||||||
|
|
||||||
## How it works (internal)
|
## How it works (internal)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then
|
|||||||
fi
|
fi
|
||||||
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
|
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
|
||||||
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
|
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
|
||||||
BUNDLED_RUNTIME="${BUNDLED_RUNTIME:-node}"
|
|
||||||
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
|
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
|
||||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml}"
|
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml}"
|
||||||
AUTO_CHECKS=true
|
AUTO_CHECKS=true
|
||||||
@@ -108,220 +107,6 @@ merge_framework_machos() {
|
|||||||
done < <(find "$primary" -type f -print0)
|
done < <(find "$primary" -type f -print0)
|
||||||
}
|
}
|
||||||
|
|
||||||
build_relay_binary() {
|
|
||||||
local arch="$1"
|
|
||||||
local out="$2"
|
|
||||||
local define_arg="__CLAWDBOT_VERSION__=\\\"$PKG_VERSION\\\""
|
|
||||||
local bun_bin="bun"
|
|
||||||
local -a cmd=("$bun_bin" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
|
|
||||||
if [[ "$arch" == "x86_64" ]]; then
|
|
||||||
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: Rosetta is required to build the x86_64 relay. Install Rosetta and retry." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
local bun_x86="${BUN_X86_64_BIN:-$HOME/.bun-x64/bun-darwin-x64/bun}"
|
|
||||||
if [[ ! -x "$bun_x86" ]]; then
|
|
||||||
bun_x86="$HOME/.bun-x64/bin/bun"
|
|
||||||
fi
|
|
||||||
if [[ "$bun_x86" == *baseline* ]]; then
|
|
||||||
echo "ERROR: x86_64 relay builds are locked to AVX2; baseline Bun is not allowed." >&2
|
|
||||||
echo "Set BUN_X86_64_BIN to a non-baseline Bun (bun-darwin-x64)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ -x "$bun_x86" ]]; then
|
|
||||||
cmd=("$bun_x86" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
|
|
||||||
fi
|
|
||||||
arch -x86_64 "${cmd[@]}"
|
|
||||||
else
|
|
||||||
"${cmd[@]}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve_node_version() {
|
|
||||||
if [[ -n "${NODE_VERSION:-}" ]]; then
|
|
||||||
echo "${NODE_VERSION#v}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local mirror="${NODE_DIST_MIRROR:-https://nodejs.org/dist}"
|
|
||||||
local latest
|
|
||||||
if latest="$(/usr/bin/curl -fsSL "$mirror/index.tab" 2>/dev/null | /usr/bin/awk 'NR==2 {print $1}')" && [[ -n "$latest" ]]; then
|
|
||||||
echo "${latest#v}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v node >/dev/null 2>&1; then
|
|
||||||
node -p "process.versions.node"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "22.12.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
node_dist_filename() {
|
|
||||||
local version="$1"
|
|
||||||
local arch="$2"
|
|
||||||
local node_arch="$arch"
|
|
||||||
if [[ "$arch" == "x86_64" ]]; then
|
|
||||||
node_arch="x64"
|
|
||||||
fi
|
|
||||||
echo "node-v${version}-darwin-${node_arch}.tar.gz"
|
|
||||||
}
|
|
||||||
|
|
||||||
download_node_binary() {
|
|
||||||
local version="$1"
|
|
||||||
local arch="$2"
|
|
||||||
local out="$3"
|
|
||||||
local mirror="${NODE_DIST_MIRROR:-https://nodejs.org/dist}"
|
|
||||||
local tarball
|
|
||||||
tarball="$(node_dist_filename "$version" "$arch")"
|
|
||||||
|
|
||||||
local tmp_dir
|
|
||||||
tmp_dir="$(mktemp -d)"
|
|
||||||
local url="$mirror/v${version}/${tarball}"
|
|
||||||
echo "⬇️ Downloading Node ${version} (${arch})"
|
|
||||||
/usr/bin/curl -fsSL "$url" -o "$tmp_dir/node.tgz"
|
|
||||||
|
|
||||||
/usr/bin/tar -xzf "$tmp_dir/node.tgz" -C "$tmp_dir"
|
|
||||||
local node_arch="$arch"
|
|
||||||
if [[ "$arch" == "x86_64" ]]; then
|
|
||||||
node_arch="x64"
|
|
||||||
fi
|
|
||||||
local node_src="$tmp_dir/node-v${version}-darwin-${node_arch}/bin/node"
|
|
||||||
if [[ ! -f "$node_src" ]]; then
|
|
||||||
echo "ERROR: Node binary missing in $tarball" >&2
|
|
||||||
rm -rf "$tmp_dir"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cp "$node_src" "$out"
|
|
||||||
chmod +x "$out"
|
|
||||||
rm -rf "$tmp_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_relay_deps() {
|
|
||||||
local relay_dir="$1"
|
|
||||||
|
|
||||||
if [[ "${SKIP_RELAY_DEPS:-0}" == "1" ]]; then
|
|
||||||
echo "📦 Skipping relay dependency staging (SKIP_RELAY_DEPS=1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local stage_dir="$relay_dir/.relay-deploy"
|
|
||||||
rm -rf "$stage_dir"
|
|
||||||
mkdir -p "$stage_dir"
|
|
||||||
echo "📦 Staging relay dependencies (pnpm deploy --prod --legacy)"
|
|
||||||
(cd "$ROOT_DIR" && pnpm --filter . deploy "$stage_dir" --prod --legacy)
|
|
||||||
rm -rf "$relay_dir/node_modules"
|
|
||||||
cp -a "$stage_dir/node_modules" "$relay_dir/node_modules"
|
|
||||||
rm -rf "$stage_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_relay_dist() {
|
|
||||||
local relay_dir="$1"
|
|
||||||
echo "📦 Copying relay dist payload"
|
|
||||||
rm -rf "$relay_dir/dist"
|
|
||||||
mkdir -p "$relay_dir/dist"
|
|
||||||
# Only ship runtime JS payload; exclude build artifacts (app/zips/dmgs) to avoid
|
|
||||||
# recursive bundling and notarization failures.
|
|
||||||
/usr/bin/rsync -a --delete \
|
|
||||||
--exclude 'Clawdbot.app' \
|
|
||||||
--exclude 'Clawdbot-*.zip' \
|
|
||||||
--exclude 'Clawdbot-*.dmg' \
|
|
||||||
--exclude 'Clawdbot-*.notary.zip' \
|
|
||||||
--exclude 'Clawdbot-*.dSYM.zip' \
|
|
||||||
--exclude 'Clawdbot-*.dSYM' \
|
|
||||||
"$ROOT_DIR/dist/" "$relay_dir/dist/"
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_relay_payload() {
|
|
||||||
local relay_dir="$1"
|
|
||||||
stage_relay_deps "$relay_dir"
|
|
||||||
stage_relay_dist "$relay_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
write_relay_wrapper() {
|
|
||||||
local relay_dir="$1"
|
|
||||||
local wrapper="$relay_dir/clawdbot"
|
|
||||||
cat > "$wrapper" <<SH
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
DIR="\$(cd "\$(dirname "\$0")" && pwd)"
|
|
||||||
NODE="\$DIR/node"
|
|
||||||
REL="\$DIR/dist/macos/relay.js"
|
|
||||||
export CLAWDBOT_BUNDLED_VERSION="\${CLAWDBOT_BUNDLED_VERSION:-$PKG_VERSION}"
|
|
||||||
export CLAWDBOT_IMAGE_BACKEND="\${CLAWDBOT_IMAGE_BACKEND:-sips}"
|
|
||||||
NODE_PATH="\$DIR/node_modules\${NODE_PATH:+:\$NODE_PATH}"
|
|
||||||
export NODE_PATH
|
|
||||||
exec "\$NODE" "\$REL" "\$@"
|
|
||||||
SH
|
|
||||||
chmod +x "$wrapper"
|
|
||||||
}
|
|
||||||
|
|
||||||
package_relay_bun() {
|
|
||||||
local relay_dir="$1"
|
|
||||||
RELAY_CMD="$relay_dir/clawdbot"
|
|
||||||
|
|
||||||
if ! command -v bun >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: bun missing. Install bun or set BUNDLED_RUNTIME=node." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🧰 Building bundled relay (bun --compile)"
|
|
||||||
local relay_build_dir="$relay_dir/.relay-build"
|
|
||||||
rm -rf "$relay_build_dir"
|
|
||||||
mkdir -p "$relay_build_dir"
|
|
||||||
for arch in "${BUILD_ARCHS[@]}"; do
|
|
||||||
local relay_arch_out="$relay_build_dir/clawdbot-$arch"
|
|
||||||
build_relay_binary "$arch" "$relay_arch_out"
|
|
||||||
chmod +x "$relay_arch_out"
|
|
||||||
done
|
|
||||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
|
||||||
/usr/bin/lipo -create "$relay_build_dir"/clawdbot-* -output "$RELAY_CMD"
|
|
||||||
else
|
|
||||||
cp "$relay_build_dir/clawdbot-${BUILD_ARCHS[0]}" "$RELAY_CMD"
|
|
||||||
fi
|
|
||||||
rm -rf "$relay_build_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
package_relay_node() {
|
|
||||||
local relay_dir="$1"
|
|
||||||
RELAY_CMD="$relay_dir/clawdbot"
|
|
||||||
|
|
||||||
local node_version
|
|
||||||
node_version="$(resolve_node_version)"
|
|
||||||
echo "🧰 Preparing bundled Node runtime (v${node_version})"
|
|
||||||
local relay_node="$relay_dir/node"
|
|
||||||
local relay_node_build_dir="$relay_dir/.node-build"
|
|
||||||
rm -rf "$relay_node_build_dir"
|
|
||||||
mkdir -p "$relay_node_build_dir"
|
|
||||||
for arch in "${BUILD_ARCHS[@]}"; do
|
|
||||||
local node_arch_out="$relay_node_build_dir/node-$arch"
|
|
||||||
download_node_binary "$node_version" "$arch" "$node_arch_out"
|
|
||||||
done
|
|
||||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
|
||||||
/usr/bin/lipo -create "$relay_node_build_dir"/node-* -output "$relay_node"
|
|
||||||
else
|
|
||||||
cp "$relay_node_build_dir/node-${BUILD_ARCHS[0]}" "$relay_node"
|
|
||||||
fi
|
|
||||||
chmod +x "$relay_node"
|
|
||||||
if [[ "${STRIP_NODE:-0}" == "1" ]]; then
|
|
||||||
/usr/bin/strip -x "$relay_node" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
rm -rf "$relay_node_build_dir"
|
|
||||||
stage_relay_payload "$relay_dir"
|
|
||||||
write_relay_wrapper "$relay_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_bundled_runtime() {
|
|
||||||
case "$BUNDLED_RUNTIME" in
|
|
||||||
node|bun) return 0 ;;
|
|
||||||
*)
|
|
||||||
echo "ERROR: Unsupported BUNDLED_RUNTIME=$BUNDLED_RUNTIME (use node|bun)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "📦 Ensuring deps (pnpm install)"
|
echo "📦 Ensuring deps (pnpm install)"
|
||||||
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
|
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
|
||||||
if [[ "${SKIP_TSC:-0}" != "1" ]]; then
|
if [[ "${SKIP_TSC:-0}" != "1" ]]; then
|
||||||
@@ -352,7 +137,6 @@ echo "🧹 Cleaning old app bundle"
|
|||||||
rm -rf "$APP_ROOT"
|
rm -rf "$APP_ROOT"
|
||||||
mkdir -p "$APP_ROOT/Contents/MacOS"
|
mkdir -p "$APP_ROOT/Contents/MacOS"
|
||||||
mkdir -p "$APP_ROOT/Contents/Resources"
|
mkdir -p "$APP_ROOT/Contents/Resources"
|
||||||
mkdir -p "$APP_ROOT/Contents/Resources/Relay"
|
|
||||||
mkdir -p "$APP_ROOT/Contents/Frameworks"
|
mkdir -p "$APP_ROOT/Contents/Frameworks"
|
||||||
|
|
||||||
echo "📄 Copying Info.plist template"
|
echo "📄 Copying Info.plist template"
|
||||||
@@ -432,61 +216,6 @@ else
|
|||||||
echo "WARN: ClawdbotKit resource bundle not found at $CLAWDBOTKIT_BUNDLE (continuing)" >&2
|
echo "WARN: ClawdbotKit resource bundle not found at $CLAWDBOTKIT_BUNDLE (continuing)" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RELAY_DIR="$APP_ROOT/Contents/Resources/Relay"
|
|
||||||
|
|
||||||
if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
|
||||||
validate_bundled_runtime
|
|
||||||
mkdir -p "$RELAY_DIR"
|
|
||||||
|
|
||||||
if [[ "$BUNDLED_RUNTIME" == "bun" ]]; then
|
|
||||||
package_relay_bun "$RELAY_DIR"
|
|
||||||
else
|
|
||||||
package_relay_node "$RELAY_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🧪 Verifying bundled relay (version)"
|
|
||||||
"$RELAY_CMD" --version >/dev/null
|
|
||||||
|
|
||||||
echo "🎨 Copying gateway A2UI host assets"
|
|
||||||
rm -rf "$RELAY_DIR/a2ui"
|
|
||||||
cp -R "$ROOT_DIR/src/canvas-host/a2ui" "$RELAY_DIR/a2ui"
|
|
||||||
|
|
||||||
echo "🎛 Copying Control UI assets"
|
|
||||||
rm -rf "$RELAY_DIR/control-ui"
|
|
||||||
cp -R "$ROOT_DIR/dist/control-ui" "$RELAY_DIR/control-ui"
|
|
||||||
|
|
||||||
echo "🧠 Copying bundled skills"
|
|
||||||
rm -rf "$RELAY_DIR/skills"
|
|
||||||
cp -R "$ROOT_DIR/skills" "$RELAY_DIR/skills"
|
|
||||||
|
|
||||||
echo "📄 Writing embedded runtime package.json (Pi compatibility)"
|
|
||||||
cat > "$RELAY_DIR/package.json" <<JSON
|
|
||||||
{
|
|
||||||
"name": "clawdbot-embedded",
|
|
||||||
"version": "$PKG_VERSION",
|
|
||||||
"type": "module",
|
|
||||||
"piConfig": {
|
|
||||||
"name": "pi",
|
|
||||||
"configDir": ".pi"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
echo "🎨 Copying Pi theme payload (optional)"
|
|
||||||
PI_ENTRY_URL="$(cd "$ROOT_DIR" && node --input-type=module -e "console.log(import.meta.resolve('@mariozechner/pi-coding-agent'))")"
|
|
||||||
PI_ENTRY="$(cd "$ROOT_DIR" && node --input-type=module -e "console.log(new URL(process.argv[1]).pathname)" "$PI_ENTRY_URL")"
|
|
||||||
PI_DIR="$(cd "$(dirname "$PI_ENTRY")/.." && pwd)"
|
|
||||||
THEME_SRC="$PI_DIR/dist/modes/interactive/theme"
|
|
||||||
if [ -d "$THEME_SRC" ]; then
|
|
||||||
rm -rf "$RELAY_DIR/theme"
|
|
||||||
cp -R "$THEME_SRC" "$RELAY_DIR/theme"
|
|
||||||
else
|
|
||||||
echo "WARN: Pi theme dir missing at $THEME_SRC (continuing)" >&2
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "🧰 Skipping gateway payload packaging (SKIP_GATEWAY_PACKAGE=1)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "⏹ Stopping any running Clawdbot"
|
echo "⏹ Stopping any running Clawdbot"
|
||||||
killall -q Clawdbot 2>/dev/null || true
|
killall -q Clawdbot 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ elif [ "$SIGN" -eq 1 ]; then
|
|||||||
unset SIGN_IDENTITY
|
unset SIGN_IDENTITY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3) Package app (default to bundling the embedded gateway + CLI).
|
# 3) Package app (no embedded gateway).
|
||||||
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} SKIP_GATEWAY_PACKAGE=${SKIP_GATEWAY_PACKAGE:-0} '${ROOT_DIR}/scripts/package-mac-app.sh'"
|
run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} '${ROOT_DIR}/scripts/package-mac-app.sh'"
|
||||||
|
|
||||||
choose_app_bundle() {
|
choose_app_bundle() {
|
||||||
if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then
|
if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then
|
||||||
@@ -205,7 +205,7 @@ choose_app_bundle
|
|||||||
|
|
||||||
APP_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${APP_BUNDLE}/Contents/Info.plist" 2>/dev/null || true)"
|
APP_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${APP_BUNDLE}/Contents/Info.plist" 2>/dev/null || true)"
|
||||||
|
|
||||||
# When unsigned, avoid the app overwriting the LaunchAgent with the relay binary.
|
# When unsigned, avoid the app overwriting the LaunchAgent while iterating.
|
||||||
if [ "$NO_SIGN" -eq 1 ]; then
|
if [ "$NO_SIGN" -eq 1 ]; then
|
||||||
if [[ -n "${APP_BUNDLE_ID}" ]]; then
|
if [[ -n "${APP_BUNDLE_ID}" ]]; then
|
||||||
run_step "set attach-existing-only" \
|
run_step "set attach-existing-only" \
|
||||||
@@ -239,8 +239,7 @@ else
|
|||||||
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# When unsigned, launchd cannot exec the app relay binary. Ensure the gateway
|
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (after the app launches).
|
||||||
# LaunchAgent targets the repo CLI instead (after the app has launched).
|
|
||||||
if [ "$NO_SIGN" -eq 1 ]; then
|
if [ "$NO_SIGN" -eq 1 ]; then
|
||||||
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
||||||
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
||||||
|
|||||||
Reference in New Issue
Block a user