mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(macos): recover after installed app replacement (#108991)
* fix(macos): relaunch trusted app replacements * refactor(macos): split relocator extension * fix(macos): revalidate replacement bundle in child * chore(macos): refresh native i18n inventory * style(macos): satisfy Swift formatting and dead-code gates
This commit is contained in:
committed by
GitHub
parent
d2e9fa22bc
commit
8bcff3bad9
@@ -26947,7 +26947,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 130,
|
||||
"line": 217,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "OpenClaw couldn’t be installed in Applications. Move it there manually, then open that copy.",
|
||||
"surface": "apple",
|
||||
@@ -26955,7 +26955,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 282,
|
||||
"line": 907,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "Replace the older OpenClaw in Applications?",
|
||||
"surface": "apple",
|
||||
@@ -26963,7 +26963,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 283,
|
||||
"line": 908,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "Install OpenClaw in Applications?",
|
||||
"surface": "apple",
|
||||
@@ -26971,7 +26971,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 285,
|
||||
"line": 910,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "This copy is newer than the installed app. OpenClaw will replace it and reopen from Applications.",
|
||||
"surface": "apple",
|
||||
@@ -26979,7 +26979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 286,
|
||||
"line": 911,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "OpenClaw will copy itself to Applications and reopen there so updates and launch at login stay reliable.",
|
||||
"surface": "apple",
|
||||
@@ -26987,7 +26987,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 288,
|
||||
"line": 913,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "Install and Relaunch",
|
||||
"surface": "apple",
|
||||
@@ -26995,7 +26995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 288,
|
||||
"line": 913,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "Replace and Relaunch",
|
||||
"surface": "apple",
|
||||
@@ -27003,7 +27003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 312,
|
||||
"line": 938,
|
||||
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
|
||||
"source": "OpenClaw is installed in Applications, but couldn’t reopen automatically. Open it there manually.",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Dispatch
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
@_silgen_name("csops")
|
||||
private func csops(
|
||||
_: pid_t,
|
||||
_: UInt32,
|
||||
_: UnsafeMutableRawPointer?,
|
||||
_: Int) -> Int32
|
||||
|
||||
@MainActor
|
||||
enum ApplicationRelocator {
|
||||
struct ApplicationIdentity: Equatable, Sendable {
|
||||
@@ -39,7 +48,75 @@ enum ApplicationRelocator {
|
||||
case terminating
|
||||
}
|
||||
|
||||
struct ApplicationOnDisk: Equatable, Sendable {
|
||||
let bundleIdentifier: String
|
||||
let executableURL: URL
|
||||
}
|
||||
|
||||
enum ReplacementAction: Equatable, Sendable {
|
||||
case unchanged
|
||||
case waitForTrustedReplacement
|
||||
case relaunch
|
||||
}
|
||||
|
||||
enum RelaunchStrategy: Equatable, Sendable {
|
||||
case openAfterTermination
|
||||
case externalSupervisor
|
||||
}
|
||||
|
||||
struct BundleFileReference: Equatable, Sendable {
|
||||
let deviceIdentifier: UInt64
|
||||
let fileIdentifier: UInt64
|
||||
let executableRelativePath: String
|
||||
|
||||
var executableURL: URL {
|
||||
self.bundleURL.appendingPathComponent(self.executableRelativePath)
|
||||
}
|
||||
|
||||
var bundleURL: URL {
|
||||
URL(
|
||||
fileURLWithPath: "/.vol/\(self.deviceIdentifier)/\(self.fileIdentifier)",
|
||||
isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeepAliveSupervisor: Sendable {
|
||||
let label: String
|
||||
let plistURL: URL
|
||||
}
|
||||
|
||||
private struct BundleReplacementSnapshot: Sendable {
|
||||
let bundleURL: URL
|
||||
let bundleIdentifier: String
|
||||
let executableURL: URL
|
||||
let codeDirectoryHash: Data
|
||||
let requirementData: Data
|
||||
}
|
||||
|
||||
private struct ReplacementEvaluation: Sendable {
|
||||
let action: ReplacementAction
|
||||
let launchReference: BundleFileReference?
|
||||
let launchCodeDirectoryHash: Data?
|
||||
}
|
||||
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "app-relocation")
|
||||
private static var bundleReplacementSnapshot: BundleReplacementSnapshot?
|
||||
private static var bundleReplacementSource: DispatchSourceFileSystemObject?
|
||||
private static var bundleReplacementRecoveryTask: Task<Void, Never>?
|
||||
private static var bundleReplacementCheckPending = false
|
||||
private static var bundleReplacementHandoffInProgress = false
|
||||
private static var inheritedReplacementSupervisor: KeepAliveSupervisor?
|
||||
private static var supervisorRestorationWatcher: Process?
|
||||
private static var authenticatedReplacementSourceBundleURL: URL?
|
||||
private nonisolated static let replacementSourceBundleEnvironmentKey = "OPENCLAW_REPLACEMENT_SOURCE_BUNDLE"
|
||||
private nonisolated static let replacementParentPIDEnvironmentKey = "OPENCLAW_REPLACEMENT_PARENT_PID"
|
||||
private nonisolated static let replacementCodeHashEnvironmentKey = "OPENCLAW_REPLACEMENT_CODE_HASH"
|
||||
private nonisolated static let replacementReadyFDEnvironmentKey = "OPENCLAW_REPLACEMENT_READY_FD"
|
||||
private nonisolated static let replacementBootoutTargetEnvironmentKey = "OPENCLAW_REPLACEMENT_BOOTOUT_TARGET"
|
||||
private nonisolated static let replacementSupervisorLabelEnvironmentKey =
|
||||
"OPENCLAW_REPLACEMENT_SUPERVISOR_LABEL"
|
||||
private nonisolated static let replacementSupervisorPlistEnvironmentKey =
|
||||
"OPENCLAW_REPLACEMENT_SUPERVISOR_PLIST"
|
||||
|
||||
static func recommendation(for environment: Environment) -> Recommendation {
|
||||
guard !environment.isDebugOrTesting,
|
||||
@@ -56,9 +133,8 @@ enum ApplicationRelocator {
|
||||
guard let installedIdentity = candidate.identity,
|
||||
candidate.isTrusted,
|
||||
installedIdentity.bundleIdentifier == currentIdentity.bundleIdentifier,
|
||||
self
|
||||
.compareBuild(installedIdentity.buildVersion, currentIdentity.buildVersion) !=
|
||||
.orderedAscending
|
||||
compareBuild(installedIdentity.buildVersion, currentIdentity.buildVersion) !=
|
||||
.orderedAscending
|
||||
else { continue }
|
||||
return .handOff(candidate.url)
|
||||
}
|
||||
@@ -97,7 +173,7 @@ enum ApplicationRelocator {
|
||||
if transientRoots.contains(where: { self.isInside(path, root: $0) }) {
|
||||
return true
|
||||
}
|
||||
return self.isInside(path, root: "/Volumes") && isReadOnlyVolume
|
||||
return isInside(path, root: "/Volumes") && isReadOnlyVolume
|
||||
}
|
||||
|
||||
static func handleLaunch(
|
||||
@@ -105,29 +181,40 @@ enum ApplicationRelocator {
|
||||
fileManager: FileManager = .default,
|
||||
processInfo: ProcessInfo = .processInfo) -> LaunchDisposition
|
||||
{
|
||||
let environment = self.currentEnvironment(
|
||||
let environment = currentEnvironment(
|
||||
bundle: bundle,
|
||||
fileManager: fileManager,
|
||||
processInfo: processInfo)
|
||||
switch self.recommendation(for: environment) {
|
||||
case .continueLaunch:
|
||||
#if DEBUG
|
||||
let monitorDebugReplacement = processInfo.environment["OPENCLAW_MONITOR_APP_REPLACEMENT"] == "1"
|
||||
#else
|
||||
let monitorDebugReplacement = true
|
||||
#endif
|
||||
if !processInfo.isRunningTests, !processInfo.isPreview, monitorDebugReplacement {
|
||||
let monitoredBundleURL = replacementSourceBundleURL(
|
||||
environment: processInfo.environment,
|
||||
fallback: bundle.bundleURL)
|
||||
startBundleReplacementMonitoring(bundle: bundle, at: monitoredBundleURL)
|
||||
}
|
||||
return .continueLaunch(startUpdater: true)
|
||||
case let .handOff(destination):
|
||||
return self.relaunchAndTerminate(at: destination)
|
||||
return relaunchAndTerminate(at: destination)
|
||||
case let .offerInstall(destination, replacing):
|
||||
guard self.confirmInstall(replacing: replacing) else {
|
||||
guard confirmInstall(replacing: replacing) else {
|
||||
return .continueLaunch(startUpdater: false)
|
||||
}
|
||||
do {
|
||||
try self.install(
|
||||
try install(
|
||||
source: environment.bundleURL,
|
||||
destination: destination,
|
||||
replacing: replacing,
|
||||
fileManager: fileManager)
|
||||
return self.relaunchAndTerminate(at: destination)
|
||||
return relaunchAndTerminate(at: destination)
|
||||
} catch {
|
||||
self.logger.error("Could not install app: \(error.localizedDescription, privacy: .public)")
|
||||
self.showFailure(
|
||||
showFailure(
|
||||
"OpenClaw couldn’t be installed in Applications. Move it there manually, then open that copy.")
|
||||
return .continueLaunch(startUpdater: false)
|
||||
}
|
||||
@@ -135,7 +222,7 @@ enum ApplicationRelocator {
|
||||
let message =
|
||||
"OpenClaw is running from a temporary location. " +
|
||||
"Move it to Applications manually to enable updates and launch at login."
|
||||
self.showFailure(message)
|
||||
showFailure(message)
|
||||
return .continueLaunch(startUpdater: false)
|
||||
}
|
||||
}
|
||||
@@ -154,7 +241,9 @@ enum ApplicationRelocator {
|
||||
return true
|
||||
}
|
||||
|
||||
let bundleURL = bundle.bundleURL.standardizedFileURL
|
||||
let bundleURL = replacementSourceBundleURL(
|
||||
environment: processInfo.environment,
|
||||
fallback: bundle.bundleURL)
|
||||
let isReadOnlyVolume = (try? bundleURL.resourceValues(forKeys: [.volumeIsReadOnlyKey]))?
|
||||
.volumeIsReadOnly ?? false
|
||||
return !self.isTransientLocation(
|
||||
@@ -162,13 +251,241 @@ enum ApplicationRelocator {
|
||||
homeDirectory: fileManager.homeDirectoryForCurrentUser,
|
||||
isReadOnlyVolume: isReadOnlyVolume)
|
||||
}
|
||||
}
|
||||
|
||||
extension ApplicationRelocator {
|
||||
nonisolated static func replacementAction(
|
||||
launchedCodeDirectoryHash: Data,
|
||||
installedCodeDirectoryHash: Data?,
|
||||
sameBundleIdentifier: Bool,
|
||||
trusted: Bool) -> ReplacementAction
|
||||
{
|
||||
guard let installedCodeDirectoryHash else { return .waitForTrustedReplacement }
|
||||
guard installedCodeDirectoryHash != launchedCodeDirectoryHash else { return .unchanged }
|
||||
guard sameBundleIdentifier, trusted else { return .waitForTrustedReplacement }
|
||||
return .relaunch
|
||||
}
|
||||
|
||||
static func relaunchStrategy(
|
||||
xpcServiceName: String?,
|
||||
executableURL: URL?,
|
||||
homeDirectory: URL,
|
||||
fileManager: FileManager = .default) -> RelaunchStrategy
|
||||
{
|
||||
self.verifiedKeepAliveSupervisor(
|
||||
xpcServiceName: xpcServiceName,
|
||||
executableURL: executableURL,
|
||||
homeDirectory: homeDirectory,
|
||||
fileManager: fileManager) == nil ? .openAfterTermination : .externalSupervisor
|
||||
}
|
||||
|
||||
private static func verifiedKeepAliveSupervisor(
|
||||
xpcServiceName: String?,
|
||||
executableURL: URL?,
|
||||
homeDirectory: URL,
|
||||
fileManager _: FileManager = .default) -> KeepAliveSupervisor?
|
||||
{
|
||||
guard let serviceName = xpcServiceName?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!serviceName.isEmpty,
|
||||
serviceName != "0",
|
||||
!serviceName.hasPrefix("application."),
|
||||
URL(fileURLWithPath: serviceName).lastPathComponent == serviceName,
|
||||
let executableURL
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let launchAgentURLs = [
|
||||
homeDirectory.appendingPathComponent("Library/LaunchAgents/\(serviceName).plist"),
|
||||
URL(fileURLWithPath: "/Library/LaunchAgents/\(serviceName).plist"),
|
||||
URL(fileURLWithPath: "/System/Library/LaunchAgents/\(serviceName).plist"),
|
||||
]
|
||||
let expectedExecutable = executableURL.standardizedFileURL.path
|
||||
for url in launchAgentURLs {
|
||||
if let supervisor = keepAliveSupervisor(
|
||||
label: serviceName,
|
||||
plistURL: url,
|
||||
expectedExecutablePath: expectedExecutable)
|
||||
{
|
||||
return supervisor
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func inheritedSupervisor(
|
||||
environment: [String: String],
|
||||
monitoredBundleURL: URL) -> KeepAliveSupervisor?
|
||||
{
|
||||
guard let label = environment[replacementSupervisorLabelEnvironmentKey],
|
||||
let plistPath = environment[replacementSupervisorPlistEnvironmentKey],
|
||||
plistPath.hasPrefix("/"),
|
||||
let executablePath = applicationOnDisk(at: monitoredBundleURL)?.executableURL.path
|
||||
else { return nil }
|
||||
let plistURL = URL(fileURLWithPath: plistPath).standardizedFileURL
|
||||
let allowedDirectories = [
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents"),
|
||||
URL(fileURLWithPath: "/Library/LaunchAgents"),
|
||||
URL(fileURLWithPath: "/System/Library/LaunchAgents"),
|
||||
].map(\.standardizedFileURL.path)
|
||||
guard allowedDirectories.contains(plistURL.deletingLastPathComponent().path) else { return nil }
|
||||
return self.keepAliveSupervisor(
|
||||
label: label,
|
||||
plistURL: plistURL,
|
||||
expectedExecutablePath: executablePath)
|
||||
}
|
||||
|
||||
private static func keepAliveSupervisor(
|
||||
label: String,
|
||||
plistURL: URL,
|
||||
expectedExecutablePath: String) -> KeepAliveSupervisor?
|
||||
{
|
||||
guard !label.isEmpty,
|
||||
label != "0",
|
||||
!label.hasPrefix("application."),
|
||||
URL(fileURLWithPath: label).lastPathComponent == label,
|
||||
let data = try? Data(contentsOf: plistURL),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil)
|
||||
as? [String: Any],
|
||||
plist["Label"] as? String == label,
|
||||
plist["KeepAlive"] as? Bool == true
|
||||
else { return nil }
|
||||
let configuredExecutable = (plist["Program"] as? String) ??
|
||||
(plist["ProgramArguments"] as? [String])?.first
|
||||
guard configuredExecutable.map({ URL(fileURLWithPath: $0).standardizedFileURL.path }) ==
|
||||
URL(fileURLWithPath: expectedExecutablePath).standardizedFileURL.path
|
||||
else { return nil }
|
||||
return KeepAliveSupervisor(label: label, plistURL: plistURL)
|
||||
}
|
||||
|
||||
static func acceptReplacementHandoff(
|
||||
environment: [String: String],
|
||||
bundle: Bundle = .main) -> Bool
|
||||
{
|
||||
guard let sourcePath = environment[replacementSourceBundleEnvironmentKey],
|
||||
sourcePath.hasPrefix("/"),
|
||||
URL(fileURLWithPath: sourcePath).pathExtension == "app",
|
||||
let parentPIDText = environment[replacementParentPIDEnvironmentKey],
|
||||
let parentPID = pid_t(parentPIDText),
|
||||
parentPID == getppid(),
|
||||
let expectedHashText = environment[replacementCodeHashEnvironmentKey],
|
||||
let expectedHash = Data(base64Encoded: expectedHashText),
|
||||
expectedHash == kernelCodeDirectoryHash(),
|
||||
let readyFDText = environment[replacementReadyFDEnvironmentKey],
|
||||
let readyFD = Int32(readyFDText),
|
||||
readyFD >= 3,
|
||||
fcntl(readyFD, F_GETFD) != -1,
|
||||
let bundleIdentifier = bundle.bundleIdentifier,
|
||||
let identity = runningCodeIdentity(bundleIdentifier: bundleIdentifier),
|
||||
let executableURL = bundle.executableURL,
|
||||
// Revalidate the bound bundle in the child. The parent cannot make
|
||||
// the sealed resources immutable across posix_spawn.
|
||||
trustedCodeDirectoryHash(
|
||||
at: bundle.bundleURL,
|
||||
executableURL: executableURL,
|
||||
matching: identity.requirementData) == expectedHash,
|
||||
process(parentPID, matches: identity.requirementData)
|
||||
else { return false }
|
||||
|
||||
let monitoredBundleURL = URL(fileURLWithPath: sourcePath).standardizedFileURL
|
||||
let supervisor = self.inheritedSupervisor(
|
||||
environment: environment,
|
||||
monitoredBundleURL: monitoredBundleURL)
|
||||
let hasSupervisorMetadata = environment[replacementSupervisorLabelEnvironmentKey] != nil ||
|
||||
environment[self.replacementSupervisorPlistEnvironmentKey] != nil
|
||||
guard !hasSupervisorMetadata || supervisor != nil else {
|
||||
self.writeHandoffStatus("FAIL", to: readyFD)
|
||||
return false
|
||||
}
|
||||
self.inheritedReplacementSupervisor = supervisor
|
||||
if let supervisor, !startSupervisorRestorationWatcher(supervisor) {
|
||||
self.writeHandoffStatus("FAIL", to: readyFD)
|
||||
return false
|
||||
}
|
||||
|
||||
let bootoutTarget = environment[replacementBootoutTargetEnvironmentKey]
|
||||
if let target = bootoutTarget {
|
||||
guard let supervisor,
|
||||
target == "gui/\(getuid())/\(supervisor.label)",
|
||||
bootoutLaunchdTarget(target)
|
||||
else {
|
||||
self.cancelSupervisorRestorationWatcher()
|
||||
self.writeHandoffStatus("FAIL", to: readyFD)
|
||||
return false
|
||||
}
|
||||
}
|
||||
self.authenticatedReplacementSourceBundleURL = monitoredBundleURL
|
||||
self.writeHandoffStatus("READY", to: readyFD)
|
||||
return true
|
||||
}
|
||||
|
||||
nonisolated static func hasReplacementHandoffMetadata(environment: [String: String]) -> Bool {
|
||||
environment[self.replacementParentPIDEnvironmentKey] != nil ||
|
||||
environment[self.replacementReadyFDEnvironmentKey] != nil ||
|
||||
environment[self.replacementCodeHashEnvironmentKey] != nil
|
||||
}
|
||||
|
||||
private static func bootoutLaunchdTarget(_ target: String) -> Bool {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
||||
process.arguments = ["bootout", target]
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
} catch {
|
||||
self.logger.error("Could not unload launchd owner: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func writeHandoffStatus(_ status: String, to descriptor: Int32) {
|
||||
defer { Darwin.close(descriptor) }
|
||||
_ = fcntl(descriptor, F_SETNOSIGPIPE, 1)
|
||||
let bytes = Array(status.utf8)
|
||||
_ = bytes.withUnsafeBytes { buffer in
|
||||
Darwin.write(descriptor, buffer.baseAddress, buffer.count)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func process(_ pid: pid_t, matches requirementData: Data) -> Bool {
|
||||
var requirement: SecRequirement?
|
||||
guard SecRequirementCreateWithData(requirementData as CFData, SecCSFlags(), &requirement) == errSecSuccess,
|
||||
let requirement
|
||||
else { return false }
|
||||
let attributes = [kSecGuestAttributePid as String: NSNumber(value: pid)] as CFDictionary
|
||||
var code: SecCode?
|
||||
guard SecCodeCopyGuestWithAttributes(nil, attributes, SecCSFlags(), &code) == errSecSuccess,
|
||||
let code
|
||||
else { return false }
|
||||
return SecCodeCheckValidity(code, SecCSFlags(), requirement) == errSecSuccess
|
||||
}
|
||||
|
||||
nonisolated static func applicationOnDisk(at bundleURL: URL) -> ApplicationOnDisk? {
|
||||
let infoPlistURL = bundleURL.appendingPathComponent("Contents/Info.plist")
|
||||
guard let data = try? Data(contentsOf: infoPlistURL),
|
||||
let values = try? PropertyListSerialization.propertyList(from: data, format: nil)
|
||||
as? [String: Any],
|
||||
let bundleIdentifier = values["CFBundleIdentifier"] as? String,
|
||||
!bundleIdentifier.isEmpty,
|
||||
let executableName = values["CFBundleExecutable"] as? String,
|
||||
!executableName.isEmpty,
|
||||
URL(fileURLWithPath: executableName).lastPathComponent == executableName
|
||||
else { return nil }
|
||||
return ApplicationOnDisk(
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
executableURL: bundleURL
|
||||
.appendingPathComponent("Contents/MacOS")
|
||||
.appendingPathComponent(executableName))
|
||||
}
|
||||
|
||||
private static func currentEnvironment(
|
||||
bundle: Bundle,
|
||||
fileManager: FileManager,
|
||||
processInfo: ProcessInfo) -> Environment
|
||||
{
|
||||
let bundleURL = bundle.bundleURL.standardizedFileURL
|
||||
let bundleURL = self.replacementSourceBundleURL(
|
||||
environment: processInfo.environment,
|
||||
fallback: bundle.bundleURL)
|
||||
let homeDirectory = fileManager.homeDirectoryForCurrentUser.standardizedFileURL
|
||||
let appName = bundleURL.lastPathComponent
|
||||
let destinations = [
|
||||
@@ -211,6 +528,303 @@ enum ApplicationRelocator {
|
||||
return ApplicationIdentity(bundleIdentifier: bundleIdentifier, buildVersion: buildVersion)
|
||||
}
|
||||
|
||||
private static func replacementSourceBundleURL(
|
||||
environment _: [String: String],
|
||||
fallback: URL) -> URL
|
||||
{
|
||||
self.authenticatedReplacementSourceBundleURL ?? fallback.standardizedFileURL
|
||||
}
|
||||
|
||||
private static func startBundleReplacementMonitoring(bundle: Bundle, at monitoredBundleURL: URL) {
|
||||
self.bundleReplacementRecoveryTask?.cancel()
|
||||
self.bundleReplacementRecoveryTask = nil
|
||||
self.bundleReplacementSource?.cancel()
|
||||
self.bundleReplacementSource = nil
|
||||
self.bundleReplacementSnapshot = nil
|
||||
self.bundleReplacementCheckPending = false
|
||||
self.bundleReplacementHandoffInProgress = false
|
||||
|
||||
let bundleURL = monitoredBundleURL.standardizedFileURL
|
||||
guard bundleURL.pathExtension == "app",
|
||||
let bundleIdentifier = bundle.bundleIdentifier,
|
||||
let installedApp = applicationOnDisk(at: bundleURL),
|
||||
installedApp.bundleIdentifier == bundleIdentifier,
|
||||
let runningIdentity = runningCodeIdentity(bundleIdentifier: bundleIdentifier)
|
||||
else {
|
||||
self.logger.warning("Installed app replacement monitoring is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
self.bundleReplacementSnapshot = BundleReplacementSnapshot(
|
||||
bundleURL: bundleURL,
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
executableURL: installedApp.executableURL,
|
||||
codeDirectoryHash: runningIdentity.codeDirectoryHash,
|
||||
requirementData: runningIdentity.requirementData)
|
||||
|
||||
let descriptor = open(bundleURL.deletingLastPathComponent().path, O_EVTONLY | O_CLOEXEC)
|
||||
guard descriptor >= 0 else {
|
||||
self.logger.error("Could not monitor installed app directory: errno \(errno)")
|
||||
self.bundleReplacementSnapshot = nil
|
||||
return
|
||||
}
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: descriptor,
|
||||
eventMask: [.write, .delete, .rename, .revoke],
|
||||
queue: .main)
|
||||
source.setEventHandler {
|
||||
Task { @MainActor in
|
||||
ApplicationRelocator.bundleDirectoryDidChange()
|
||||
}
|
||||
}
|
||||
source.setCancelHandler {
|
||||
Darwin.close(descriptor)
|
||||
}
|
||||
self.bundleReplacementSource = source
|
||||
source.resume()
|
||||
// Reconcile once after arming. This closes the launch-to-watch window even
|
||||
// when the directory event coalesced before the source became active.
|
||||
self.bundleDirectoryDidChange()
|
||||
self.logger.notice("Monitoring the installed app for signed replacement")
|
||||
}
|
||||
|
||||
private static func bundleDirectoryDidChange() {
|
||||
self.bundleReplacementCheckPending = true
|
||||
guard !self.bundleReplacementHandoffInProgress,
|
||||
self.bundleReplacementRecoveryTask == nil,
|
||||
let snapshot = bundleReplacementSnapshot
|
||||
else { return }
|
||||
|
||||
self.bundleReplacementRecoveryTask = Task { @MainActor in
|
||||
defer {
|
||||
self.bundleReplacementRecoveryTask = nil
|
||||
if self.bundleReplacementCheckPending {
|
||||
self.bundleDirectoryDidChange()
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic replacements can briefly remove the bundle or expose it before
|
||||
// code-signature validation is complete. Keep the old process alive until
|
||||
// the complete replacement is present and trusted.
|
||||
var attempt = 0
|
||||
while !Task.isCancelled {
|
||||
self.bundleReplacementCheckPending = false
|
||||
let evaluation = await Task.detached(priority: .utility) {
|
||||
self.replacementEvaluationOnDisk(for: snapshot)
|
||||
}.value
|
||||
switch evaluation.action {
|
||||
case .unchanged:
|
||||
if self.bundleReplacementCheckPending {
|
||||
continue
|
||||
}
|
||||
return
|
||||
case .waitForTrustedReplacement:
|
||||
if attempt == 120 {
|
||||
self.logger.warning(
|
||||
"Installed app is still incomplete or untrusted; continuing replacement recovery")
|
||||
}
|
||||
let retryDelay: Duration = attempt < 120 ? .milliseconds(250) : .seconds(5)
|
||||
attempt += 1
|
||||
try? await Task.sleep(for: retryDelay)
|
||||
guard !Task.isCancelled else { return }
|
||||
case .relaunch:
|
||||
guard let launchReference = evaluation.launchReference,
|
||||
let launchCodeDirectoryHash = evaluation.launchCodeDirectoryHash
|
||||
else {
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
continue
|
||||
}
|
||||
self.bundleReplacementCheckPending = false
|
||||
self.logger.notice("Installed app changed; relaunching trusted replacement")
|
||||
self.bundleReplacementHandoffInProgress = true
|
||||
let scheduled = self.scheduleReplacementRelaunch(
|
||||
at: snapshot.bundleURL,
|
||||
launchReference: launchReference,
|
||||
codeDirectoryHash: launchCodeDirectoryHash)
|
||||
if !scheduled {
|
||||
self.bundleReplacementHandoffInProgress = false
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func replacementEvaluationOnDisk(
|
||||
for snapshot: BundleReplacementSnapshot) -> ReplacementEvaluation
|
||||
{
|
||||
guard let installedApp = applicationOnDisk(at: snapshot.bundleURL) else {
|
||||
return ReplacementEvaluation(
|
||||
action: .waitForTrustedReplacement,
|
||||
launchReference: nil,
|
||||
launchCodeDirectoryHash: nil)
|
||||
}
|
||||
guard let launchReference = bundleFileReference(
|
||||
bundleURL: snapshot.bundleURL,
|
||||
executableURL: installedApp.executableURL)
|
||||
else {
|
||||
return ReplacementEvaluation(
|
||||
action: .waitForTrustedReplacement,
|
||||
launchReference: nil,
|
||||
launchCodeDirectoryHash: nil)
|
||||
}
|
||||
let sameBundleIdentifier = installedApp.bundleIdentifier == snapshot.bundleIdentifier
|
||||
let installedCodeDirectoryHash = self.trustedCodeDirectoryHash(
|
||||
at: snapshot.bundleURL,
|
||||
executableURL: installedApp.executableURL,
|
||||
matching: snapshot.requirementData)
|
||||
// Security validates the canonical path. Re-capture its object identity
|
||||
// afterward so the launch reference can only name that validated bundle.
|
||||
guard self.bundleFileReference(
|
||||
bundleURL: snapshot.bundleURL,
|
||||
executableURL: installedApp.executableURL) == launchReference
|
||||
else {
|
||||
return ReplacementEvaluation(
|
||||
action: .waitForTrustedReplacement,
|
||||
launchReference: nil,
|
||||
launchCodeDirectoryHash: nil)
|
||||
}
|
||||
let action = self.replacementAction(
|
||||
launchedCodeDirectoryHash: snapshot.codeDirectoryHash,
|
||||
installedCodeDirectoryHash: installedCodeDirectoryHash,
|
||||
sameBundleIdentifier: sameBundleIdentifier,
|
||||
trusted: installedCodeDirectoryHash != nil)
|
||||
return ReplacementEvaluation(
|
||||
action: action,
|
||||
launchReference: action == .relaunch ? launchReference : nil,
|
||||
launchCodeDirectoryHash: action == .relaunch ? installedCodeDirectoryHash : nil)
|
||||
}
|
||||
|
||||
nonisolated static func bundleFileReference(
|
||||
bundleURL: URL,
|
||||
executableURL: URL) -> BundleFileReference?
|
||||
{
|
||||
let bundlePath = bundleURL.standardizedFileURL.path
|
||||
let executablePath = executableURL.standardizedFileURL.path
|
||||
let prefix = bundlePath + "/"
|
||||
guard executablePath.hasPrefix(prefix) else { return nil }
|
||||
|
||||
var bundleInformation = stat()
|
||||
guard stat(bundlePath, &bundleInformation) == 0,
|
||||
bundleInformation.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR)
|
||||
else { return nil }
|
||||
let relativePath = String(executablePath.dropFirst(prefix.count))
|
||||
guard !relativePath.isEmpty else { return nil }
|
||||
|
||||
let reference = BundleFileReference(
|
||||
deviceIdentifier: UInt64(truncatingIfNeeded: bundleInformation.st_dev),
|
||||
fileIdentifier: UInt64(bundleInformation.st_ino),
|
||||
executableRelativePath: relativePath)
|
||||
var canonicalExecutableInformation = stat()
|
||||
var referencedExecutableInformation = stat()
|
||||
guard stat(executablePath, &canonicalExecutableInformation) == 0,
|
||||
stat(reference.executableURL.path, &referencedExecutableInformation) == 0,
|
||||
canonicalExecutableInformation.st_dev == referencedExecutableInformation.st_dev,
|
||||
canonicalExecutableInformation.st_ino == referencedExecutableInformation.st_ino,
|
||||
canonicalExecutableInformation.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
|
||||
canonicalExecutableInformation.st_mode & mode_t(S_IXUSR) != 0
|
||||
else { return nil }
|
||||
return reference
|
||||
}
|
||||
|
||||
private static func runningCodeIdentity(
|
||||
bundleIdentifier: String) -> (codeDirectoryHash: Data, requirementData: Data)?
|
||||
{
|
||||
guard let codeDirectoryHash = kernelCodeDirectoryHash(),
|
||||
let teamIdentifier = kernelTeamIdentifier(),
|
||||
let requirementString = developerIDRequirementString(
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
teamIdentifier: teamIdentifier)
|
||||
else { return nil }
|
||||
var requirement: SecRequirement?
|
||||
var requirementData: CFData?
|
||||
guard SecRequirementCreateWithString(
|
||||
requirementString as CFString,
|
||||
SecCSFlags(),
|
||||
&requirement) == errSecSuccess,
|
||||
let requirement,
|
||||
SecRequirementCopyData(requirement, SecCSFlags(), &requirementData) == errSecSuccess,
|
||||
let requirementData
|
||||
else { return nil }
|
||||
return (codeDirectoryHash, requirementData as Data)
|
||||
}
|
||||
|
||||
nonisolated static func developerIDRequirementString(
|
||||
bundleIdentifier: String,
|
||||
teamIdentifier: String) -> String?
|
||||
{
|
||||
guard self.isRequirementToken(bundleIdentifier), self.isRequirementToken(teamIdentifier) else { return nil }
|
||||
return "identifier \"\(bundleIdentifier)\" and anchor apple generic and " +
|
||||
"certificate 1[field.1.2.840.113635.100.6.2.6] exists and " +
|
||||
"certificate leaf[field.1.2.840.113635.100.6.1.13] exists and " +
|
||||
"certificate leaf[subject.OU] = \"\(teamIdentifier)\""
|
||||
}
|
||||
|
||||
private static func kernelCodeDirectoryHash() -> Data? {
|
||||
var bytes = [UInt8](repeating: 0, count: 20)
|
||||
let result = bytes.withUnsafeMutableBytes {
|
||||
csops(getpid(), 5, $0.baseAddress, $0.count)
|
||||
}
|
||||
return result == 0 ? Data(bytes) : nil
|
||||
}
|
||||
|
||||
private static func kernelTeamIdentifier() -> String? {
|
||||
// XNU kern_proc.c prefixes CS_OPS_TEAMID with an 8-byte fake blob header;
|
||||
// the NUL-terminated identifier starts immediately after that header.
|
||||
var bytes = [UInt8](repeating: 0, count: 8 + 64)
|
||||
let result = bytes.withUnsafeMutableBytes {
|
||||
csops(getpid(), 14, $0.baseAddress, $0.count)
|
||||
}
|
||||
guard result == 0 else { return nil }
|
||||
return self.teamIdentifier(fromCSOpsToken: bytes)
|
||||
}
|
||||
|
||||
nonisolated static func teamIdentifier(fromCSOpsToken bytes: [UInt8]) -> String? {
|
||||
guard bytes.count > 8 else { return nil }
|
||||
let payload = bytes.dropFirst(8)
|
||||
guard let terminator = payload.firstIndex(of: 0),
|
||||
terminator > payload.startIndex
|
||||
else { return nil }
|
||||
return String(bytes: payload[..<terminator], encoding: .utf8)
|
||||
}
|
||||
|
||||
private nonisolated static func isRequirementToken(_ value: String) -> Bool {
|
||||
!value.isEmpty && value.unicodeScalars.allSatisfy {
|
||||
CharacterSet.alphanumerics.contains($0) || $0 == "." || $0 == "-"
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func codeDirectoryHash(for code: SecStaticCode) -> Data? {
|
||||
var information: CFDictionary?
|
||||
guard SecCodeCopySigningInformation(code, SecCSFlags(), &information) == errSecSuccess,
|
||||
let information
|
||||
else { return nil }
|
||||
return (information as NSDictionary)[kSecCodeInfoUnique] as? Data
|
||||
}
|
||||
|
||||
private nonisolated static func trustedCodeDirectoryHash(
|
||||
at bundleURL: URL,
|
||||
executableURL: URL,
|
||||
matching requirementData: Data) -> Data?
|
||||
{
|
||||
guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { return nil }
|
||||
var requirement: SecRequirement?
|
||||
guard SecRequirementCreateWithData(requirementData as CFData, SecCSFlags(), &requirement) == errSecSuccess,
|
||||
let requirement
|
||||
else { return nil }
|
||||
var code: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess,
|
||||
let code,
|
||||
SecStaticCodeCheckValidity(
|
||||
code,
|
||||
SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode),
|
||||
requirement) == errSecSuccess
|
||||
else { return nil }
|
||||
return self.codeDirectoryHash(for: code)
|
||||
}
|
||||
|
||||
private static func designatedRequirement(for bundleURL: URL) -> SecRequirement? {
|
||||
var code: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess,
|
||||
@@ -227,18 +841,29 @@ enum ApplicationRelocator {
|
||||
matching requirement: SecRequirement?,
|
||||
fileManager: FileManager) -> Bool
|
||||
{
|
||||
guard let requirement,
|
||||
let executableURL = bundle.executableURL,
|
||||
fileManager.isExecutableFile(atPath: executableURL.path)
|
||||
else { return false }
|
||||
guard let executableURL = bundle.executableURL else { return false }
|
||||
return self.isTrustedInstalledApp(
|
||||
at: bundle.bundleURL,
|
||||
executableURL: executableURL,
|
||||
matching: requirement,
|
||||
fileManager: fileManager)
|
||||
}
|
||||
|
||||
private static func isTrustedInstalledApp(
|
||||
at bundleURL: URL,
|
||||
executableURL: URL,
|
||||
matching requirement: SecRequirement?,
|
||||
fileManager: FileManager) -> Bool
|
||||
{
|
||||
guard let requirement, fileManager.isExecutableFile(atPath: executableURL.path) else { return false }
|
||||
|
||||
var code: SecStaticCode?
|
||||
guard SecStaticCodeCreateWithPath(bundle.bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess,
|
||||
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess,
|
||||
let code
|
||||
else { return false }
|
||||
return SecStaticCodeCheckValidity(
|
||||
code,
|
||||
SecCSFlags(rawValue: kSecCSCheckAllArchitectures),
|
||||
SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode),
|
||||
requirement) == errSecSuccess
|
||||
}
|
||||
|
||||
@@ -295,26 +920,239 @@ enum ApplicationRelocator {
|
||||
private static func relaunchAndTerminate(at destination: URL) -> LaunchDisposition {
|
||||
let helper = Process()
|
||||
helper.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
let processInfo = ProcessInfo.processInfo
|
||||
helper.arguments = [
|
||||
"-c",
|
||||
"while /bin/kill -0 \"$2\" 2>/dev/null; do /bin/sleep 0.1; done; exec /usr/bin/open -n \"$1\"",
|
||||
"openclaw-relocation",
|
||||
destination.path,
|
||||
String(ProcessInfo.processInfo.processIdentifier),
|
||||
String(processInfo.processIdentifier),
|
||||
]
|
||||
do {
|
||||
try helper.run()
|
||||
TerminationSignalWatcher.scheduleExitFailsafe()
|
||||
NSApp.terminate(nil)
|
||||
return .terminating
|
||||
} catch {
|
||||
self.logger.error("Could not schedule relaunch: \(error.localizedDescription, privacy: .public)")
|
||||
self
|
||||
.showFailure(
|
||||
"OpenClaw is installed in Applications, but couldn’t reopen automatically. Open it there manually.")
|
||||
self.showFailure(
|
||||
"OpenClaw is installed in Applications, but couldn’t reopen automatically. Open it there manually.")
|
||||
return .continueLaunch(startUpdater: false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func startSupervisorRestorationWatcher(_ supervisor: KeepAliveSupervisor) -> Bool {
|
||||
guard self.supervisorRestorationWatcher == nil else { return true }
|
||||
let watcher = Process()
|
||||
let processIdentifier = ProcessInfo.processInfo.processIdentifier
|
||||
let domain = "gui/\(getuid())"
|
||||
watcher.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
watcher.arguments = [
|
||||
"-c",
|
||||
"while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.1; done; " +
|
||||
"/bin/launchctl bootstrap \"$2\" \"$3\" 2>/dev/null || true; " +
|
||||
"/bin/launchctl kickstart \"$4\"",
|
||||
"openclaw-supervisor-restoration",
|
||||
String(processIdentifier),
|
||||
domain,
|
||||
supervisor.plistURL.path,
|
||||
"\(domain)/\(supervisor.label)",
|
||||
]
|
||||
do {
|
||||
try watcher.run()
|
||||
self.supervisorRestorationWatcher = watcher
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("Could not schedule launchd restoration: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func cancelSupervisorRestorationWatcher() {
|
||||
guard let watcher = supervisorRestorationWatcher else { return }
|
||||
self.supervisorRestorationWatcher = nil
|
||||
guard watcher.isRunning else { return }
|
||||
Darwin.kill(watcher.processIdentifier, SIGKILL)
|
||||
watcher.waitUntilExit()
|
||||
}
|
||||
|
||||
private static func scheduleReplacementRelaunch(
|
||||
at destination: URL,
|
||||
launchReference: BundleFileReference,
|
||||
codeDirectoryHash: Data) -> Bool
|
||||
{
|
||||
let processInfo = ProcessInfo.processInfo
|
||||
let activeSupervisor = self.verifiedKeepAliveSupervisor(
|
||||
xpcServiceName: processInfo.environment["XPC_SERVICE_NAME"],
|
||||
executableURL: Bundle.main.executableURL,
|
||||
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
|
||||
let supervisor = self.inheritedReplacementSupervisor ?? activeSupervisor
|
||||
let forwardedArguments = Array(processInfo.arguments.dropFirst())
|
||||
let bootoutTarget = activeSupervisor.map { "gui/\(getuid())/\($0.label)" }
|
||||
// The volfs path pins atomic bundle swaps and direct exec bypasses mutable
|
||||
// Info.plist routing. The child must also prove its kernel CDHash over the
|
||||
// inherited pipe before the old process yields ownership.
|
||||
guard let spawned = spawnReplacement(
|
||||
launchReference: launchReference,
|
||||
sourceBundleURL: destination,
|
||||
codeDirectoryHash: codeDirectoryHash,
|
||||
supervisor: supervisor,
|
||||
bootoutTarget: bootoutTarget,
|
||||
forwardedArguments: forwardedArguments)
|
||||
else {
|
||||
self.logger.error("Could not spawn the trusted replacement")
|
||||
return false
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
let handoffStatus = await Task.detached(priority: .utility) {
|
||||
self.awaitReplacementHandoff(on: spawned.readyDescriptor)
|
||||
}.value
|
||||
Darwin.close(spawned.readyDescriptor)
|
||||
guard handoffStatus == "READY" else {
|
||||
Darwin.kill(spawned.processIdentifier, SIGKILL)
|
||||
await Task.detached(priority: .utility) {
|
||||
self.reapProcess(spawned.processIdentifier)
|
||||
}.value
|
||||
self.logger.error("Trusted replacement did not complete its authenticated handoff")
|
||||
self.bundleReplacementHandoffInProgress = false
|
||||
self.bundleReplacementCheckPending = true
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
self.bundleDirectoryDidChange()
|
||||
return
|
||||
}
|
||||
self.cancelSupervisorRestorationWatcher()
|
||||
TerminationSignalWatcher.scheduleExitFailsafe()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func spawnReplacement(
|
||||
launchReference: BundleFileReference,
|
||||
sourceBundleURL: URL,
|
||||
codeDirectoryHash: Data,
|
||||
supervisor: KeepAliveSupervisor?,
|
||||
bootoutTarget: String?,
|
||||
forwardedArguments: [String]) -> (processIdentifier: pid_t, readyDescriptor: Int32)?
|
||||
{
|
||||
let childReadyDescriptor: Int32 = 19
|
||||
var descriptors = [Int32](repeating: -1, count: 2)
|
||||
guard pipe(&descriptors) == 0 else { return nil }
|
||||
let readDescriptor = descriptors[0]
|
||||
let writeDescriptor = descriptors[1]
|
||||
|
||||
var environmentAssignments = [
|
||||
"\(replacementSourceBundleEnvironmentKey)=\(sourceBundleURL.path)",
|
||||
"\(self.replacementParentPIDEnvironmentKey)=\(getpid())",
|
||||
"\(self.replacementCodeHashEnvironmentKey)=\(codeDirectoryHash.base64EncodedString())",
|
||||
"\(self.replacementReadyFDEnvironmentKey)=\(childReadyDescriptor)",
|
||||
]
|
||||
if let supervisor {
|
||||
environmentAssignments += [
|
||||
"\(self.replacementSupervisorLabelEnvironmentKey)=\(supervisor.label)",
|
||||
"\(self.replacementSupervisorPlistEnvironmentKey)=\(supervisor.plistURL.path)",
|
||||
]
|
||||
}
|
||||
if let bootoutTarget {
|
||||
environmentAssignments.append("\(self.replacementBootoutTargetEnvironmentKey)=\(bootoutTarget)")
|
||||
}
|
||||
// The detached child is no longer owned by the current launchd job. Do not
|
||||
// let it inherit that job's identity and attempt a second bootout later.
|
||||
let arguments = [
|
||||
"/usr/bin/env",
|
||||
"-u",
|
||||
"XPC_SERVICE_NAME",
|
||||
"-u",
|
||||
replacementSourceBundleEnvironmentKey,
|
||||
"-u",
|
||||
replacementParentPIDEnvironmentKey,
|
||||
"-u",
|
||||
replacementCodeHashEnvironmentKey,
|
||||
"-u",
|
||||
replacementReadyFDEnvironmentKey,
|
||||
"-u",
|
||||
replacementBootoutTargetEnvironmentKey,
|
||||
"-u",
|
||||
replacementSupervisorLabelEnvironmentKey,
|
||||
"-u",
|
||||
replacementSupervisorPlistEnvironmentKey,
|
||||
] + environmentAssignments +
|
||||
[launchReference.executableURL.path] + forwardedArguments
|
||||
var cArguments = arguments.map { strdup($0) } + [nil]
|
||||
defer { cArguments.compactMap(\.self).forEach { free($0) } }
|
||||
|
||||
var fileActions: posix_spawn_file_actions_t?
|
||||
var attributes: posix_spawnattr_t?
|
||||
guard posix_spawn_file_actions_init(&fileActions) == 0,
|
||||
posix_spawnattr_init(&attributes) == 0
|
||||
else {
|
||||
Darwin.close(readDescriptor)
|
||||
Darwin.close(writeDescriptor)
|
||||
return nil
|
||||
}
|
||||
defer {
|
||||
posix_spawn_file_actions_destroy(&fileActions)
|
||||
posix_spawnattr_destroy(&attributes)
|
||||
}
|
||||
guard posix_spawn_file_actions_adddup2(&fileActions, writeDescriptor, childReadyDescriptor) == 0,
|
||||
posix_spawnattr_setflags(
|
||||
&attributes,
|
||||
Int16(POSIX_SPAWN_SETSID | POSIX_SPAWN_CLOEXEC_DEFAULT)) == 0
|
||||
else {
|
||||
Darwin.close(readDescriptor)
|
||||
Darwin.close(writeDescriptor)
|
||||
return nil
|
||||
}
|
||||
if readDescriptor != childReadyDescriptor,
|
||||
posix_spawn_file_actions_addclose(&fileActions, readDescriptor) != 0
|
||||
{
|
||||
Darwin.close(readDescriptor)
|
||||
Darwin.close(writeDescriptor)
|
||||
return nil
|
||||
}
|
||||
if writeDescriptor != childReadyDescriptor,
|
||||
posix_spawn_file_actions_addclose(&fileActions, writeDescriptor) != 0
|
||||
{
|
||||
Darwin.close(readDescriptor)
|
||||
Darwin.close(writeDescriptor)
|
||||
return nil
|
||||
}
|
||||
|
||||
var processIdentifier = pid_t()
|
||||
let spawnResult = cArguments.withUnsafeMutableBufferPointer { buffer in
|
||||
posix_spawn(
|
||||
&processIdentifier,
|
||||
"/usr/bin/env",
|
||||
&fileActions,
|
||||
&attributes,
|
||||
buffer.baseAddress,
|
||||
environ)
|
||||
}
|
||||
Darwin.close(writeDescriptor)
|
||||
guard spawnResult == 0 else {
|
||||
Darwin.close(readDescriptor)
|
||||
return nil
|
||||
}
|
||||
return (processIdentifier, readDescriptor)
|
||||
}
|
||||
|
||||
private nonisolated static func awaitReplacementHandoff(on descriptor: Int32) -> String? {
|
||||
var event = pollfd(fd: descriptor, events: Int16(POLLIN | POLLHUP), revents: 0)
|
||||
guard poll(&event, 1, 10000) > 0 else { return nil }
|
||||
var bytes = [UInt8](repeating: 0, count: 16)
|
||||
let count = bytes.withUnsafeMutableBytes { buffer in
|
||||
Darwin.read(descriptor, buffer.baseAddress, buffer.count)
|
||||
}
|
||||
guard count > 0 else { return nil }
|
||||
return String(bytes: bytes.prefix(count), encoding: .utf8)
|
||||
}
|
||||
|
||||
private nonisolated static func reapProcess(_ processIdentifier: pid_t) {
|
||||
var processStatus: Int32 = 0
|
||||
while waitpid(processIdentifier, &processStatus, 0) == -1, errno == EINTR {}
|
||||
}
|
||||
|
||||
private static func showFailure(_ message: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Move OpenClaw to Applications"
|
||||
|
||||
@@ -504,7 +504,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@MainActor
|
||||
func applicationDidFinishLaunching(_: Notification) {
|
||||
if self.isDuplicateInstance() {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let hasReplacementHandoff = ApplicationRelocator.hasReplacementHandoffMetadata(
|
||||
environment: environment)
|
||||
let isReplacementHandoff = ApplicationRelocator.acceptReplacementHandoff(
|
||||
environment: environment)
|
||||
if hasReplacementHandoff, !isReplacementHandoff {
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
}
|
||||
// Only a child whose signed parent and inherited readiness pipe authenticate
|
||||
// may overlap the old process during replacement handoff.
|
||||
if !isReplacementHandoff, self.isDuplicateInstance() {
|
||||
NSWorkspace.shared.open(Self.dashboardURL)
|
||||
NSApp.terminate(nil)
|
||||
return
|
||||
|
||||
@@ -48,10 +48,16 @@ final class TerminationSignalWatcher {
|
||||
// Ensure any pairing prompt can't accidentally approve during shutdown.
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
DevicePairingApprovalPrompter.shared.stop()
|
||||
Self.scheduleExitFailsafe()
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
// Safety net: don't hang forever if something blocks termination.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + AppTerminationTiming.signalExitFailsafeSeconds) {
|
||||
static func scheduleExitFailsafe() {
|
||||
// AppKit waits in a nested event loop while async termination cleanup runs.
|
||||
// A main-queue failsafe cannot fire from that loop, so enforce the deadline off-main.
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||
deadline: .now() + AppTerminationTiming.signalExitFailsafeSeconds)
|
||||
{
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
import Testing
|
||||
|
||||
@Suite("Application Relocator Tests")
|
||||
@MainActor
|
||||
@@ -8,7 +8,8 @@ struct ApplicationRelocatorTests {
|
||||
private let home = URL(fileURLWithPath: "/Users/tester")
|
||||
private let current = ApplicationRelocator.ApplicationIdentity(
|
||||
bundleIdentifier: "ai.openclaw.mac",
|
||||
buildVersion: "100")
|
||||
buildVersion: "100"
|
||||
)
|
||||
|
||||
@Test
|
||||
func `stable application locations continue normally`() {
|
||||
@@ -20,7 +21,8 @@ struct ApplicationRelocatorTests {
|
||||
]
|
||||
for path in paths {
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(path: path, readOnlyVolume: false))
|
||||
for: environment(path: path, readOnlyVolume: false)
|
||||
)
|
||||
#expect(recommendation == .continueLaunch)
|
||||
}
|
||||
}
|
||||
@@ -28,9 +30,11 @@ struct ApplicationRelocatorTests {
|
||||
@Test
|
||||
func `debug and test builds never relocate`() {
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Users/tester/Downloads/OpenClaw.app",
|
||||
debugOrTesting: true))
|
||||
debugOrTesting: true
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .continueLaunch)
|
||||
}
|
||||
@@ -39,9 +43,11 @@ struct ApplicationRelocatorTests {
|
||||
func `transient copy offers system installation when available`() {
|
||||
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Users/tester/Downloads/OpenClaw.app",
|
||||
candidates: [self.missing(destination, writable: true)]))
|
||||
candidates: [missing(destination, writable: true)]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .offerInstall(destination: destination, replacing: false))
|
||||
}
|
||||
@@ -50,10 +56,12 @@ struct ApplicationRelocatorTests {
|
||||
func `read only mounted copy offers installation`() {
|
||||
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Volumes/OpenClaw/OpenClaw.app",
|
||||
candidates: [self.missing(destination, writable: true)],
|
||||
readOnlyVolume: true))
|
||||
candidates: [missing(destination, writable: true)],
|
||||
readOnlyVolume: true
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .offerInstall(destination: destination, replacing: false))
|
||||
}
|
||||
@@ -62,9 +70,11 @@ struct ApplicationRelocatorTests {
|
||||
func `translocated copy offers installation`() {
|
||||
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/private/var/folders/x/AppTranslocation/y/d/OpenClaw.app",
|
||||
candidates: [self.missing(destination, writable: true)]))
|
||||
candidates: [missing(destination, writable: true)]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .offerInstall(destination: destination, replacing: false))
|
||||
}
|
||||
@@ -74,12 +84,15 @@ struct ApplicationRelocatorTests {
|
||||
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
||||
for build in ["100", "110"] {
|
||||
let installed = ApplicationRelocator.ApplicationIdentity(
|
||||
bundleIdentifier: self.current.bundleIdentifier,
|
||||
buildVersion: build)
|
||||
bundleIdentifier: current.bundleIdentifier,
|
||||
buildVersion: build
|
||||
)
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Users/tester/Downloads/OpenClaw.app",
|
||||
candidates: [self.installed(destination, identity: installed)]))
|
||||
candidates: [self.installed(destination, identity: installed)]
|
||||
)
|
||||
)
|
||||
#expect(recommendation == .handOff(destination))
|
||||
}
|
||||
}
|
||||
@@ -88,12 +101,15 @@ struct ApplicationRelocatorTests {
|
||||
func `older installed build can be replaced`() {
|
||||
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
||||
let installed = ApplicationRelocator.ApplicationIdentity(
|
||||
bundleIdentifier: self.current.bundleIdentifier,
|
||||
buildVersion: "90")
|
||||
bundleIdentifier: current.bundleIdentifier,
|
||||
buildVersion: "90"
|
||||
)
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Users/tester/Downloads/OpenClaw.app",
|
||||
candidates: [self.installed(destination, identity: installed)]))
|
||||
candidates: [self.installed(destination, identity: installed)]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .offerInstall(destination: destination, replacing: true))
|
||||
}
|
||||
@@ -101,17 +117,20 @@ struct ApplicationRelocatorTests {
|
||||
@Test
|
||||
func `different same named app is never replaced`() {
|
||||
let systemDestination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
||||
let userDestination = self.home.appendingPathComponent("Applications/OpenClaw.app")
|
||||
let userDestination = home.appendingPathComponent("Applications/OpenClaw.app")
|
||||
let unrelated = ApplicationRelocator.ApplicationIdentity(
|
||||
bundleIdentifier: "example.unrelated",
|
||||
buildVersion: "999")
|
||||
buildVersion: "999"
|
||||
)
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Users/tester/Desktop/OpenClaw.app",
|
||||
candidates: [
|
||||
self.installed(systemDestination, identity: unrelated),
|
||||
self.missing(userDestination, writable: true),
|
||||
]))
|
||||
installed(systemDestination, identity: unrelated),
|
||||
missing(userDestination, writable: true),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .offerInstall(destination: userDestination, replacing: false))
|
||||
}
|
||||
@@ -119,14 +138,16 @@ struct ApplicationRelocatorTests {
|
||||
@Test
|
||||
func `untrusted same identity app never receives handoff`() {
|
||||
let systemDestination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
||||
let userDestination = self.home.appendingPathComponent("Applications/OpenClaw.app")
|
||||
let userDestination = home.appendingPathComponent("Applications/OpenClaw.app")
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Users/tester/Downloads/OpenClaw.app",
|
||||
candidates: [
|
||||
self.installed(systemDestination, identity: self.current, trusted: false),
|
||||
self.missing(userDestination, writable: true),
|
||||
]))
|
||||
installed(systemDestination, identity: current, trusted: false),
|
||||
missing(userDestination, writable: true),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .offerInstall(destination: userDestination, replacing: false))
|
||||
}
|
||||
@@ -134,48 +155,279 @@ struct ApplicationRelocatorTests {
|
||||
@Test
|
||||
func `unwritable destinations require manual installation`() {
|
||||
let recommendation = ApplicationRelocator.recommendation(
|
||||
for: self.environment(
|
||||
for: environment(
|
||||
path: "/Users/tester/Downloads/OpenClaw.app",
|
||||
candidates: [
|
||||
self.missing(URL(fileURLWithPath: "/Applications/OpenClaw.app"), writable: false),
|
||||
self.missing(self.home.appendingPathComponent("Applications/OpenClaw.app"), writable: false),
|
||||
]))
|
||||
missing(URL(fileURLWithPath: "/Applications/OpenClaw.app"), writable: false),
|
||||
missing(home.appendingPathComponent("Applications/OpenClaw.app"), writable: false),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
#expect(recommendation == .cannotInstall)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unchanged executable does not relaunch`() {
|
||||
let launched = Data([10])
|
||||
|
||||
let action = ApplicationRelocator.replacementAction(
|
||||
launchedCodeDirectoryHash: launched,
|
||||
installedCodeDirectoryHash: launched,
|
||||
sameBundleIdentifier: true,
|
||||
trusted: true
|
||||
)
|
||||
|
||||
#expect(action == .unchanged)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `missing or untrusted replacement waits`() {
|
||||
let launched = Data([10])
|
||||
let replacement = Data([11])
|
||||
|
||||
#expect(ApplicationRelocator.replacementAction(
|
||||
launchedCodeDirectoryHash: launched,
|
||||
installedCodeDirectoryHash: nil,
|
||||
sameBundleIdentifier: true,
|
||||
trusted: true
|
||||
) == .waitForTrustedReplacement)
|
||||
#expect(ApplicationRelocator.replacementAction(
|
||||
launchedCodeDirectoryHash: launched,
|
||||
installedCodeDirectoryHash: replacement,
|
||||
sameBundleIdentifier: true,
|
||||
trusted: false
|
||||
) == .waitForTrustedReplacement)
|
||||
#expect(ApplicationRelocator.replacementAction(
|
||||
launchedCodeDirectoryHash: launched,
|
||||
installedCodeDirectoryHash: replacement,
|
||||
sameBundleIdentifier: false,
|
||||
trusted: true
|
||||
) == .waitForTrustedReplacement)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `trusted replacement relaunches`() {
|
||||
let launched = Data([10])
|
||||
let replacement = Data([11])
|
||||
|
||||
let action = ApplicationRelocator.replacementAction(
|
||||
launchedCodeDirectoryHash: launched,
|
||||
installedCodeDirectoryHash: replacement,
|
||||
sameBundleIdentifier: true,
|
||||
trusted: true
|
||||
)
|
||||
|
||||
#expect(action == .relaunch)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `unauthenticated replacement marker cannot bypass duplicate launch rejection`() {
|
||||
let forgedHandoff = [
|
||||
"OPENCLAW_REPLACEMENT_SOURCE_BUNDLE": "/Applications/OpenClaw.app",
|
||||
"OPENCLAW_REPLACEMENT_PARENT_PID": "1",
|
||||
"OPENCLAW_REPLACEMENT_CODE_HASH": "ZmFrZQ==",
|
||||
"OPENCLAW_REPLACEMENT_READY_FD": "19",
|
||||
]
|
||||
#expect(ApplicationRelocator.hasReplacementHandoffMetadata(environment: forgedHandoff))
|
||||
#expect(!ApplicationRelocator.acceptReplacementHandoff(environment: forgedHandoff))
|
||||
#expect(!ApplicationRelocator.acceptReplacementHandoff(environment: [:]))
|
||||
#expect(!ApplicationRelocator.acceptReplacementHandoff(environment: [
|
||||
"OPENCLAW_REPLACEMENT_SOURCE_BUNDLE": "relative/OpenClaw.app",
|
||||
]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `kernel team identifier skips csops token header`() {
|
||||
let teamBlob = [UInt8](repeating: 0, count: 8) + Array("example-team".utf8) + [0]
|
||||
|
||||
#expect(ApplicationRelocator.teamIdentifier(fromCSOpsToken: teamBlob) == "example-team")
|
||||
#expect(ApplicationRelocator.teamIdentifier(fromCSOpsToken: [0, 0, 0]) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replacement requirement preserves Developer ID certificate class`() {
|
||||
let requirement = ApplicationRelocator.developerIDRequirementString(
|
||||
bundleIdentifier: "ai.openclaw.mac",
|
||||
teamIdentifier: "Y5PE65HELJ"
|
||||
)
|
||||
|
||||
#expect(requirement?.contains("1.2.840.113635.100.6.2.6") == true)
|
||||
#expect(requirement?.contains("1.2.840.113635.100.6.1.13") == true)
|
||||
#expect(requirement?.contains("subject.OU] = \"Y5PE65HELJ\"") == true)
|
||||
#expect(ApplicationRelocator.developerIDRequirementString(
|
||||
bundleIdentifier: "ai.openclaw.mac\" or true",
|
||||
teamIdentifier: "Y5PE65HELJ"
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `bundle file reference stays bound after canonical path replacement`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ApplicationRelocatorFileReferenceTests-\(UUID().uuidString)")
|
||||
let canonicalBundle = root.appendingPathComponent("OpenClaw.app", isDirectory: true)
|
||||
let archivedBundle = root.appendingPathComponent("OpenClaw.previous.app", isDirectory: true)
|
||||
let executableRelativePath = "Contents/MacOS/OpenClaw"
|
||||
let canonicalExecutable = canonicalBundle.appendingPathComponent(executableRelativePath)
|
||||
try FileManager.default.createDirectory(
|
||||
at: canonicalExecutable.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
try Data("validated".utf8).write(to: canonicalExecutable)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: canonicalExecutable.path)
|
||||
let reference = try #require(ApplicationRelocator.bundleFileReference(
|
||||
bundleURL: canonicalBundle,
|
||||
executableURL: canonicalExecutable
|
||||
))
|
||||
|
||||
try FileManager.default.moveItem(at: canonicalBundle, to: archivedBundle)
|
||||
try FileManager.default.createDirectory(
|
||||
at: canonicalExecutable.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data("unvalidated".utf8).write(to: canonicalExecutable)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: canonicalExecutable.path)
|
||||
|
||||
#expect(try Data(contentsOf: reference.executableURL) == Data("validated".utf8))
|
||||
#expect(try Data(contentsOf: canonicalExecutable) == Data("unvalidated".utf8))
|
||||
#expect(ApplicationRelocator.bundleFileReference(
|
||||
bundleURL: canonicalBundle,
|
||||
executableURL: canonicalExecutable
|
||||
) != reference)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replacement handoff launches the bound executable without filesystem locks`() {
|
||||
let reference = ApplicationRelocator.BundleFileReference(
|
||||
deviceIdentifier: 42,
|
||||
fileIdentifier: 99,
|
||||
executableRelativePath: "Contents/MacOS/OpenClaw"
|
||||
)
|
||||
|
||||
#expect(reference.bundleURL.path == "/.vol/42/99")
|
||||
#expect(reference.executableURL.path == "/.vol/42/99/Contents/MacOS/OpenClaw")
|
||||
}
|
||||
|
||||
@Test
|
||||
func `verified KeepAlive launchd supervisor retains relaunch ownership`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ApplicationRelocatorLaunchAgentTests-\(UUID().uuidString)")
|
||||
let home = root.appendingPathComponent("home")
|
||||
let executable = root.appendingPathComponent("OpenClaw.app/Contents/MacOS/OpenClaw")
|
||||
let serviceName = "ai.openclaw.mac.test-node"
|
||||
let launchAgentURL = home.appendingPathComponent("Library/LaunchAgents/\(serviceName).plist")
|
||||
try FileManager.default.createDirectory(
|
||||
at: launchAgentURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
try writeLaunchAgentPlist(
|
||||
at: launchAgentURL,
|
||||
label: serviceName,
|
||||
executable: executable.path,
|
||||
keepAlive: true
|
||||
)
|
||||
#expect(ApplicationRelocator.relaunchStrategy(
|
||||
xpcServiceName: serviceName,
|
||||
executableURL: executable,
|
||||
homeDirectory: home
|
||||
) == .externalSupervisor)
|
||||
|
||||
try writeLaunchAgentPlist(
|
||||
at: launchAgentURL,
|
||||
label: serviceName,
|
||||
executable: executable.path,
|
||||
keepAlive: false
|
||||
)
|
||||
#expect(ApplicationRelocator.relaunchStrategy(
|
||||
xpcServiceName: serviceName,
|
||||
executableURL: executable,
|
||||
homeDirectory: home
|
||||
) == .openAfterTermination)
|
||||
#expect(ApplicationRelocator.relaunchStrategy(
|
||||
xpcServiceName: "application.ai.openclaw.mac.123",
|
||||
executableURL: executable,
|
||||
homeDirectory: home
|
||||
) == .openAfterTermination)
|
||||
#expect(ApplicationRelocator.relaunchStrategy(
|
||||
xpcServiceName: nil,
|
||||
executableURL: executable,
|
||||
homeDirectory: home
|
||||
) == .openAfterTermination)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replacement metadata is read from disk instead of Bundle cache`() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ApplicationRelocatorTests-\(UUID().uuidString)")
|
||||
let bundleURL = root.appendingPathComponent("OpenClaw.app")
|
||||
let contentsURL = bundleURL.appendingPathComponent("Contents")
|
||||
try FileManager.default.createDirectory(
|
||||
at: contentsURL.appendingPathComponent("MacOS"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
try writeInfoPlist(
|
||||
at: contentsURL.appendingPathComponent("Info.plist"),
|
||||
bundleIdentifier: "ai.openclaw.mac",
|
||||
executable: "OpenClaw"
|
||||
)
|
||||
#expect(ApplicationRelocator.applicationOnDisk(at: bundleURL) == .init(
|
||||
bundleIdentifier: "ai.openclaw.mac",
|
||||
executableURL: contentsURL.appendingPathComponent("MacOS/OpenClaw")
|
||||
))
|
||||
|
||||
try writeInfoPlist(
|
||||
at: contentsURL.appendingPathComponent("Info.plist"),
|
||||
bundleIdentifier: "ai.openclaw.mac.replaced",
|
||||
executable: "OpenClawNext"
|
||||
)
|
||||
#expect(ApplicationRelocator.applicationOnDisk(at: bundleURL) == .init(
|
||||
bundleIdentifier: "ai.openclaw.mac.replaced",
|
||||
executableURL: contentsURL.appendingPathComponent("MacOS/OpenClawNext")
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `launch at login hydration does not persist the current bundle path`() {
|
||||
#expect(!AppState.shouldPersistLaunchAtLoginChange(
|
||||
isInitializing: false,
|
||||
isHydrating: true,
|
||||
isEnabling: true,
|
||||
bundleLocationAllowsPersistentIntegration: true))
|
||||
bundleLocationAllowsPersistentIntegration: true
|
||||
))
|
||||
#expect(!AppState.shouldPersistLaunchAtLoginChange(
|
||||
isInitializing: false,
|
||||
isHydrating: false,
|
||||
isEnabling: true,
|
||||
bundleLocationAllowsPersistentIntegration: false))
|
||||
bundleLocationAllowsPersistentIntegration: false
|
||||
))
|
||||
#expect(AppState.shouldPersistLaunchAtLoginChange(
|
||||
isInitializing: false,
|
||||
isHydrating: false,
|
||||
isEnabling: false,
|
||||
bundleLocationAllowsPersistentIntegration: false))
|
||||
bundleLocationAllowsPersistentIntegration: false
|
||||
))
|
||||
}
|
||||
|
||||
private func environment(
|
||||
path: String,
|
||||
candidates: [ApplicationRelocator.InstallCandidate] = [],
|
||||
readOnlyVolume: Bool = false,
|
||||
debugOrTesting: Bool = false) -> ApplicationRelocator.Environment
|
||||
{
|
||||
debugOrTesting: Bool = false
|
||||
) -> ApplicationRelocator.Environment {
|
||||
ApplicationRelocator.Environment(
|
||||
bundleURL: URL(fileURLWithPath: path),
|
||||
homeDirectory: self.home,
|
||||
currentIdentity: self.current,
|
||||
homeDirectory: home,
|
||||
currentIdentity: current,
|
||||
candidates: candidates,
|
||||
isReadOnlyVolume: readOnlyVolume,
|
||||
isDebugOrTesting: debugOrTesting)
|
||||
isDebugOrTesting: debugOrTesting
|
||||
)
|
||||
}
|
||||
|
||||
private func missing(_ url: URL, writable: Bool) -> ApplicationRelocator.InstallCandidate {
|
||||
@@ -184,19 +436,55 @@ struct ApplicationRelocatorTests {
|
||||
exists: false,
|
||||
isWritable: writable,
|
||||
isTrusted: false,
|
||||
identity: nil)
|
||||
identity: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func installed(
|
||||
_ url: URL,
|
||||
identity: ApplicationRelocator.ApplicationIdentity,
|
||||
trusted: Bool = true) -> ApplicationRelocator.InstallCandidate
|
||||
{
|
||||
trusted: Bool = true
|
||||
) -> ApplicationRelocator.InstallCandidate {
|
||||
ApplicationRelocator.InstallCandidate(
|
||||
url: url,
|
||||
exists: true,
|
||||
isWritable: true,
|
||||
isTrusted: trusted,
|
||||
identity: identity)
|
||||
identity: identity
|
||||
)
|
||||
}
|
||||
|
||||
private func writeInfoPlist(
|
||||
at url: URL,
|
||||
bundleIdentifier: String,
|
||||
executable: String
|
||||
) throws {
|
||||
let data = try PropertyListSerialization.data(
|
||||
fromPropertyList: [
|
||||
"CFBundleIdentifier": bundleIdentifier,
|
||||
"CFBundleExecutable": executable,
|
||||
],
|
||||
format: .xml,
|
||||
options: 0
|
||||
)
|
||||
try data.write(to: url, options: .atomic)
|
||||
}
|
||||
|
||||
private func writeLaunchAgentPlist(
|
||||
at url: URL,
|
||||
label: String,
|
||||
executable: String,
|
||||
keepAlive: Bool
|
||||
) throws {
|
||||
let data = try PropertyListSerialization.data(
|
||||
fromPropertyList: [
|
||||
"KeepAlive": keepAlive,
|
||||
"Label": label,
|
||||
"ProgramArguments": [executable, "--attach-only"],
|
||||
],
|
||||
format: .xml,
|
||||
options: 0
|
||||
)
|
||||
try data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user