mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
2b0da0e193
* test(macos): add OPENCLAW_DEBUG_OPEN_MENU screenshot hook * feat(macos): add live execution approval queue * refactor(macos): replace menu injectors with owned status menu * fix(macos): keep unconfigured menu header calm * fix(macos): route status-item right-clicks through a local event monitor NSControl's send-action mask ignores right mouse buttons, so the previous sendAction(on: [.rightMouseUp]) wiring never fired and the menu was unreachable by pointer. A local monitor now owns pointer routing (left = dashboard, right = menu) — the same mechanism the shipped StatusItemMouseRouter used — and menuWillOpen gained the re-entrancy guard the old injector carried, since reconciling tracked rows can re-enter the callback without a close. * chore(i18n): refresh native inventory for status menu strings * chore(macos): remove menu-refactor dead code Periphery flagged the orphans the status-menu refactor left behind: the ExecApprovalQuickMode enum and AppState's entire quick-mode read/retry surface (its only consumer was the deleted menu picker; the Settings pane owns exec-approval policy UI), SessionMenuLabelView, TrackingAreaSupport, NodeMenuMultilineView, UpdateStatus.disabled, and two fixture-only initializers. StatusMenuController.stop() is now wired into applicationWillTerminate. The menu-highlight environment key moved from the deleted view file into MenuItemHighlightColors. * chore(macos): fix status-menu lint style and refresh i18n inventory * fix(macos): converge approval cards after losing a resolution race The status-menu queue and the modal prompter intentionally share the gateway approval event stream: the gateway resolves each approval exactly once, the resolved broadcast removes the card, and the modal stays the active presentation owner while the menu is the passive, ambient one. What was missing: when the menu's resolve loses the race (modal or another client answered first), the gateway rejection left a zombie card if the resolved event was dropped. Resolve failures now re-list from the authoritative queue. Regression test simulates the race at the socket boundary and fails pre-fix.
316 lines
11 KiB
Swift
316 lines
11 KiB
Swift
import Darwin
|
|
import Dispatch
|
|
import Foundation
|
|
import Observation
|
|
import Security
|
|
|
|
enum AppInstanceLockAcquisition {
|
|
case acquired(AppInstanceLock)
|
|
case busy
|
|
case failed(String)
|
|
}
|
|
|
|
final class AppInstanceLock {
|
|
/// Keep the descriptor open for the process lifetime. Never unlink the path:
|
|
/// another opener could then lock a different inode and admit a duplicate.
|
|
private let descriptor: Int32
|
|
|
|
private init(descriptor: Int32) {
|
|
self.descriptor = descriptor
|
|
}
|
|
|
|
static func acquire(url: URL, waitMilliseconds: Int = 0) -> AppInstanceLockAcquisition {
|
|
if let error = self.preparePrivateStateRoot(url.deletingLastPathComponent()) {
|
|
return .failed(error)
|
|
}
|
|
let descriptor = Darwin.open(url.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600)
|
|
guard descriptor >= 0 else { return .failed(String(cString: strerror(errno))) }
|
|
var status = stat()
|
|
guard fstat(descriptor, &status) == 0,
|
|
status.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
|
|
status.st_uid == geteuid()
|
|
else {
|
|
Darwin.close(descriptor)
|
|
return .failed("Instance lock is not a safe file owned by the current user.")
|
|
}
|
|
_ = fchmod(descriptor, 0o600)
|
|
let deadline = DispatchTime.now() + .milliseconds(max(0, waitMilliseconds))
|
|
while flock(descriptor, LOCK_EX | LOCK_NB) != 0 {
|
|
guard errno == EWOULDBLOCK, DispatchTime.now() < deadline else {
|
|
let result: AppInstanceLockAcquisition = errno == EWOULDBLOCK
|
|
? .busy
|
|
: .failed(String(cString: strerror(errno)))
|
|
Darwin.close(descriptor)
|
|
return result
|
|
}
|
|
usleep(50000)
|
|
}
|
|
return .acquired(AppInstanceLock(descriptor: descriptor))
|
|
}
|
|
|
|
private static func preparePrivateStateRoot(_ root: URL) -> String? {
|
|
var status = stat()
|
|
if lstat(root.path, &status) != 0 {
|
|
guard errno == ENOENT else { return String(cString: strerror(errno)) }
|
|
guard mkdir(root.path, 0o700) == 0 else { return String(cString: strerror(errno)) }
|
|
guard lstat(root.path, &status) == 0 else { return String(cString: strerror(errno)) }
|
|
}
|
|
guard status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR),
|
|
status.st_uid == geteuid(),
|
|
status.st_mode & 0o777 == 0o700
|
|
else {
|
|
return "App profile state directory must be an owner-only 0700 directory."
|
|
}
|
|
return nil
|
|
}
|
|
|
|
deinit {
|
|
_ = flock(self.descriptor, LOCK_UN)
|
|
Darwin.close(self.descriptor)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
protocol UpdaterProviding: AnyObject {
|
|
var automaticallyChecksForUpdates: Bool { get set }
|
|
var automaticallyDownloadsUpdates: Bool { get set }
|
|
var isAvailable: Bool { get }
|
|
var updateStatus: UpdateStatus { get }
|
|
func start()
|
|
func startAfterResolvingGatewayUpdateChannel()
|
|
func checkForUpdates(_ sender: Any?)
|
|
}
|
|
|
|
extension UpdaterProviding {
|
|
func start() {}
|
|
|
|
func startAfterResolvingGatewayUpdateChannel() {
|
|
self.start()
|
|
}
|
|
}
|
|
|
|
/// No-op updater used for debug/dev runs to suppress Sparkle dialogs.
|
|
final class DisabledUpdaterController: UpdaterProviding {
|
|
var automaticallyChecksForUpdates: Bool = false
|
|
var automaticallyDownloadsUpdates: Bool = false
|
|
let isAvailable: Bool = false
|
|
let updateStatus = UpdateStatus()
|
|
func checkForUpdates(_: Any?) {}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class UpdateStatus {
|
|
var isUpdateReady: Bool
|
|
|
|
init(isUpdateReady: Bool = false) {
|
|
self.isUpdateReady = isUpdateReady
|
|
}
|
|
}
|
|
|
|
#if canImport(Sparkle)
|
|
import Sparkle
|
|
|
|
@MainActor
|
|
final class SparkleUpdaterController: NSObject, UpdaterProviding {
|
|
private lazy var controller = SPUStandardUpdaterController(
|
|
startingUpdater: false,
|
|
updaterDelegate: self,
|
|
userDriverDelegate: nil)
|
|
let updateStatus = UpdateStatus()
|
|
private var started = false
|
|
private var gatewayUpdateChannel: String?
|
|
private var resolvingGatewayUpdateChannel = false
|
|
private let gatewayUpdateChannelResolver: @MainActor @Sendable () async throws -> String?
|
|
private let onStart: (() -> Void)?
|
|
|
|
init(
|
|
savedAutoUpdate: Bool,
|
|
gatewayUpdateChannelResolver: (@MainActor @Sendable () async throws -> String?)? = nil,
|
|
onStart: (() -> Void)? = nil)
|
|
{
|
|
self.gatewayUpdateChannelResolver = gatewayUpdateChannelResolver ?? {
|
|
struct UpdateStatusResponse: Decodable {
|
|
let effectiveChannel: String?
|
|
}
|
|
guard let data = try? await GatewayConnection.shared.requestRaw(
|
|
method: "update.status",
|
|
timeoutMs: 5000),
|
|
let response = try? JSONDecoder().decode(UpdateStatusResponse.self, from: data)
|
|
else { return nil }
|
|
return OpenClawConfigFile.normalizedGatewayUpdateChannel(response.effectiveChannel)
|
|
}
|
|
self.onStart = onStart
|
|
super.init()
|
|
let updater = self.controller.updater
|
|
updater.automaticallyChecksForUpdates = savedAutoUpdate
|
|
updater.automaticallyDownloadsUpdates = savedAutoUpdate
|
|
}
|
|
|
|
func start() {
|
|
guard !self.started else { return }
|
|
self.started = true
|
|
if let onStart = self.onStart {
|
|
onStart()
|
|
} else {
|
|
self.controller.startUpdater()
|
|
}
|
|
}
|
|
|
|
func startAfterResolvingGatewayUpdateChannel() {
|
|
guard !self.started, !self.resolvingGatewayUpdateChannel else { return }
|
|
self.resolvingGatewayUpdateChannel = true
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
defer { self.resolvingGatewayUpdateChannel = false }
|
|
self.gatewayUpdateChannel = try? await self.gatewayUpdateChannelResolver()
|
|
// Older or unreachable Gateways cannot report effectiveChannel. Preserve
|
|
// their existing stable Sparkle behavior instead of disabling updates.
|
|
self.start()
|
|
}
|
|
}
|
|
|
|
var automaticallyChecksForUpdates: Bool {
|
|
get { self.controller.updater.automaticallyChecksForUpdates }
|
|
set { self.controller.updater.automaticallyChecksForUpdates = newValue }
|
|
}
|
|
|
|
var automaticallyDownloadsUpdates: Bool {
|
|
get { self.controller.updater.automaticallyDownloadsUpdates }
|
|
set { self.controller.updater.automaticallyDownloadsUpdates = newValue }
|
|
}
|
|
|
|
var isAvailable: Bool {
|
|
self.started
|
|
}
|
|
|
|
func checkForUpdates(_ sender: Any?) {
|
|
guard self.started else { return }
|
|
self.controller.checkForUpdates(sender)
|
|
}
|
|
|
|
func updater(_: SPUUpdater, didDownloadUpdate _: SUAppcastItem) {
|
|
self.updateStatus.isUpdateReady = true
|
|
}
|
|
|
|
func updater(_: SPUUpdater, failedToDownloadUpdate _: SUAppcastItem, error _: Error) {
|
|
self.updateStatus.isUpdateReady = false
|
|
}
|
|
|
|
func userDidCancelDownload(_: SPUUpdater) {
|
|
self.updateStatus.isUpdateReady = false
|
|
}
|
|
|
|
// periphery:ignore - Sparkle invokes this optional Objective-C delegate callback dynamically.
|
|
func updater(
|
|
_: SPUUpdater,
|
|
userDidMakeChoice choice: SPUUserUpdateChoice,
|
|
forUpdate _: SUAppcastItem,
|
|
state: SPUUserUpdateState)
|
|
{
|
|
switch choice {
|
|
case .install, .skip:
|
|
self.updateStatus.isUpdateReady = false
|
|
case .dismiss:
|
|
self.updateStatus.isUpdateReady = (state.stage == .downloaded)
|
|
@unknown default:
|
|
self.updateStatus.isUpdateReady = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func allowedSparkleChannels(forGatewayUpdateChannel channel: String?) -> Set<String> {
|
|
switch channel {
|
|
case "beta", "dev":
|
|
["beta"]
|
|
case "extended-stable":
|
|
["extended-stable"]
|
|
default:
|
|
[]
|
|
}
|
|
}
|
|
|
|
func isSparkleUpdateAllowed(itemChannel: String?, forGatewayUpdateChannel channel: String?) -> Bool {
|
|
channel != "extended-stable" || itemChannel == "extended-stable"
|
|
}
|
|
|
|
extension SparkleUpdaterController: SPUUpdaterDelegate {
|
|
func allowedChannels(for _: SPUUpdater) -> Set<String> {
|
|
allowedSparkleChannels(
|
|
forGatewayUpdateChannel: self.gatewayUpdateChannel ?? OpenClawConfigFile.gatewayUpdateChannel())
|
|
}
|
|
|
|
func bestValidUpdate(in appcast: SUAppcast, for _: SPUUpdater) -> SUAppcastItem? {
|
|
guard self.gatewayUpdateChannel ?? OpenClawConfigFile.gatewayUpdateChannel() == "extended-stable" else {
|
|
return nil
|
|
}
|
|
let comparator = SUStandardVersionComparator.default
|
|
let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
|
|
// Sparkle always admits the default channel. Filter it here so an
|
|
// extended-stable Gateway is never prompted to leave its release train.
|
|
let eligibleItems = appcast.items.filter {
|
|
guard isSparkleUpdateAllowed(
|
|
itemChannel: $0.channel,
|
|
forGatewayUpdateChannel: "extended-stable")
|
|
else { return false }
|
|
guard let currentVersion else { return true }
|
|
return comparator.compareVersion(
|
|
$0.versionString,
|
|
toVersion: currentVersion) == .orderedDescending
|
|
}
|
|
return eligibleItems.max { left, right in
|
|
comparator.compareVersion(left.versionString, toVersion: right.versionString) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
func updater(_: SPUUpdater, willInstallUpdate item: SUAppcastItem) {
|
|
guard let currentVersion = GatewayEnvironment.appVersionString() else { return }
|
|
PostAppUpdateReceiptStore.record(
|
|
fromVersion: currentVersion,
|
|
toVersion: item.displayVersionString)
|
|
}
|
|
}
|
|
|
|
private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
|
|
var staticCode: SecStaticCode?
|
|
guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
|
|
let code = staticCode
|
|
else { return false }
|
|
|
|
var infoCF: CFDictionary?
|
|
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess,
|
|
let info = infoCF as? [String: Any],
|
|
let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
|
|
let leaf = certs.first
|
|
else {
|
|
return false
|
|
}
|
|
|
|
if let summary = SecCertificateCopySubjectSummary(leaf) as String? {
|
|
return summary.hasPrefix("Developer ID Application:")
|
|
}
|
|
return false
|
|
}
|
|
|
|
@MainActor
|
|
func makeUpdaterController() -> UpdaterProviding {
|
|
guard AppProfile.current.validationError == nil, !AppProfile.current.isActive else {
|
|
return DisabledUpdaterController()
|
|
}
|
|
let bundleURL = Bundle.main.bundleURL
|
|
let isBundledApp = bundleURL.pathExtension == "app"
|
|
guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() }
|
|
|
|
let defaults = AppDefaults.standard
|
|
let autoUpdateKey = "autoUpdateEnabled"
|
|
// Default to true; honor the user's last choice otherwise.
|
|
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
|
|
return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
|
|
}
|
|
#else
|
|
@MainActor
|
|
func makeUpdaterController() -> UpdaterProviding {
|
|
DisabledUpdaterController()
|
|
}
|
|
#endif
|