From c7b0184320638f312ed4f45757685a2ab8fb351a Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 11:34:45 -0300 Subject: [PATCH 1/8] test: cover concurrent duplicate shutdown ownership --- Sources/AppDelegate.swift | 81 ++++++++++ .../AppDelegateShortcutRoutingTests.swift | 145 ++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fbcb51ec..f093697b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8858,6 +8858,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser static let currentVersion = 1 let version: Int + let generation: UUID let targetStartSeconds: Int64 let targetStartMicroseconds: Int64 let targetProcessIdentifier: pid_t @@ -8868,11 +8869,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser init( version: Int = Self.currentVersion, + generation: UUID = UUID(), target: ProgramaSingleInstanceProcessKey, requester: ProgramaSingleInstanceProcessKey, createdAtUnixSeconds: TimeInterval ) { self.version = version + self.generation = generation targetStartSeconds = target.startSeconds targetStartMicroseconds = target.startMicroseconds targetProcessIdentifier = target.processIdentifier @@ -8899,6 +8902,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } + struct SingleInstanceShutdownAcknowledgment: Codable, Equatable, Sendable { + static let currentVersion = 1 + + let version: Int + let acceptedGeneration: UUID + let targetStartSeconds: Int64 + let targetStartMicroseconds: Int64 + let targetProcessIdentifier: pid_t + let createdAtUnixSeconds: TimeInterval + + init( + version: Int = Self.currentVersion, + acceptedGeneration: UUID, + target: ProgramaSingleInstanceProcessKey, + createdAtUnixSeconds: TimeInterval + ) { + self.version = version + self.acceptedGeneration = acceptedGeneration + targetStartSeconds = target.startSeconds + targetStartMicroseconds = target.startMicroseconds + targetProcessIdentifier = target.processIdentifier + self.createdAtUnixSeconds = createdAtUnixSeconds + } + + var target: ProgramaSingleInstanceProcessKey { + ProgramaSingleInstanceProcessKey( + startSeconds: targetStartSeconds, + startMicroseconds: targetStartMicroseconds, + processIdentifier: targetProcessIdentifier + ) + } + } + + enum SingleInstanceForcePromptResponse: Equatable, Sendable { + case forceClose + case cancel + } + + enum SingleInstanceFallbackAction: Equatable, Sendable { + case skip + case prompt + case force + case exitNewer + } + nonisolated static func singleInstanceProcessKey( for processIdentifier: pid_t ) -> ProgramaSingleInstanceProcessKey? { @@ -9026,6 +9074,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } #if DEBUG + nonisolated static func duplicateRequestURLForTesting( + rootDirectory: URL, + target: ProgramaSingleInstanceProcessKey, + generation: UUID + ) -> URL { + rootDirectory.appendingPathComponent( + "programa-single-instance-\(getuid())-\(target.processIdentifier).json", + isDirectory: false + ) + } + + nonisolated static func acknowledgmentForAcceptedRequestForTesting( + _ request: SingleInstanceShutdownRequest, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval + ) -> SingleInstanceShutdownAcknowledgment? { + nil + } + + nonisolated static func duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: Bool, + requestGenerationIsPending: Bool, + processIdentityMatches: Bool, + isTerminated: Bool, + response: SingleInstanceForcePromptResponse? + ) -> SingleInstanceFallbackAction { + guard requestGenerationIsPending, processIdentityMatches, !isTerminated else { + return .skip + } + return .force + } + nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( _ request: SingleInstanceShutdownRequest?, currentProcessKey: ProgramaSingleInstanceProcessKey, @@ -9045,6 +9125,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser nonisolated static func shouldWarnBeforeTerminationForTesting( isTaggedDevBuild: Bool, isQuitWarningConfirmed: Bool, + isInternalSingleInstanceLoserExit: Bool, hasValidatedDuplicateShutdownRequest: Bool, isQuitWarningEnabled: Bool ) -> Bool { diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 2118ab94..4cd2ad7e 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -254,6 +254,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertFalse(AppDelegate.shouldWarnBeforeTerminationForTesting( isTaggedDevBuild: false, isQuitWarningConfirmed: false, + isInternalSingleInstanceLoserExit: false, hasValidatedDuplicateShutdownRequest: accepted, isQuitWarningEnabled: true )) @@ -340,6 +341,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(AppDelegate.shouldWarnBeforeTerminationForTesting( isTaggedDevBuild: false, isQuitWarningConfirmed: false, + isInternalSingleInstanceLoserExit: false, hasValidatedDuplicateShutdownRequest: false, isQuitWarningEnabled: true )) @@ -389,6 +391,149 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { )) } + func testConcurrentDuplicateRequestGenerationsCannotOverwriteOrDeleteEachOther() throws { + let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-ownership-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: rootDirectory, + withIntermediateDirectories: false + ) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let firstGeneration = UUID() + let secondGeneration = UUID() + let firstURL = AppDelegate.duplicateRequestURLForTesting( + rootDirectory: rootDirectory, + target: target, + generation: firstGeneration + ) + let secondURL = AppDelegate.duplicateRequestURLForTesting( + rootDirectory: rootDirectory, + target: target, + generation: secondGeneration + ) + + try Data("first".utf8).write(to: firstURL, options: .atomic) + try Data("second".utf8).write(to: secondURL, options: .atomic) + + XCTAssertNotEqual(firstURL, secondURL) + XCTAssertEqual(try Data(contentsOf: firstURL), Data("first".utf8)) + XCTAssertEqual(try Data(contentsOf: secondURL), Data("second".utf8)) + + try FileManager.default.removeItem(at: firstURL) + XCTAssertTrue(FileManager.default.fileExists(atPath: secondURL.path)) + } + + func testAcceptingOneGenerationAcknowledgesExactTargetForEveryRequester() throws { + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let requester = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ) + let acceptedRequest = AppDelegate.SingleInstanceShutdownRequest( + generation: UUID(), + target: target, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + let acknowledgment = try XCTUnwrap( + AppDelegate.acknowledgmentForAcceptedRequestForTesting( + acceptedRequest, + currentProcessKey: target, + now: 10_001 + ) + ) + + XCTAssertEqual(acknowledgment.target, target) + XCTAssertEqual(acknowledgment.acceptedGeneration, acceptedRequest.generation) + for _ in 0..<2 { + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: true, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: false, + response: nil + ), .skip) + } + } + + func testDelayedDuplicateTargetPromptsInsteadOfForcingAutomatically() { + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: false, + response: nil + ), .prompt) + } + + func testDuplicateForceRequiresConsentAndCurrentUnacknowledgedGeneration() { + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: false, + response: .forceClose + ), .force) + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: true, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: false, + response: .forceClose + ), .skip) + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: false, + processIdentityMatches: true, + isTerminated: false, + response: .forceClose + ), .skip) + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: true, + processIdentityMatches: false, + isTerminated: false, + response: .forceClose + ), .skip) + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: true, + response: .forceClose + ), .skip) + } + + func testDuplicateForcePromptCancelSelectsCleanNewerProcessExit() { + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: false, + response: .cancel + ), .exitNewer) + XCTAssertFalse(AppDelegate.shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: false, + isQuitWarningConfirmed: false, + isInternalSingleInstanceLoserExit: true, + hasValidatedDuplicateShutdownRequest: false, + isQuitWarningEnabled: true + )) + } + func testDuplicateInstanceCandidateRejectsCurrentProcessAndDifferentBundle() { let embeddedCLIURL = URL( fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" From 4c832134b315113d9d26ef2befeebb68bdd4bf93 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 11:48:44 -0300 Subject: [PATCH 2/8] fix: require consent for duplicate force close --- CHANGELOG.md | 2 +- Resources/Localizable.xcstrings | 57 ++ Sources/AppDelegate.swift | 536 +++++++++++++----- .../AppDelegateShortcutRoutingTests.swift | 57 +- 4 files changed, 473 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1720e24..b72b7353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. - An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. -- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. A responsive older copy bypasses only the quit-confirmation dialog and exits through the normal session-persistence path; incomplete or stale requests are ignored, and force-close remains a bounded fallback only for an unresponsive process whose identity still matches. +- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. Concurrent launch requests no longer overwrite one another, a responsive older copy exits through the normal session-persistence path, and an unresponsive copy can be force-closed only after an explicit data-loss warning and a final process-identity check. - Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 98f8693e..6ed0a539 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -5718,6 +5718,12 @@ "state": "translated", "value": "Cancel" } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } } } }, @@ -7392,6 +7398,57 @@ } } }, + "dialog.singleInstanceNotResponding.forceClose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Force Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "強制終了" + } + } + } + }, + "dialog.singleInstanceNotResponding.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既存のProgramaインスタンスが応答していません。強制終了すると、保存されていないターミナルまたはセッションの状態が失われる可能性があります。" + } + } + } + }, + "dialog.singleInstanceNotResponding.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Programa Isn’t Responding" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Programaが応答していません" + } + } + } + }, "error.clipboardFolderPath": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f093697b..a4cf903a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1058,6 +1058,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // Set to true when the user has already confirmed quit via the warning dialog, // so applicationShouldTerminate does not show a second alert. private var isQuitWarningConfirmed = false + private var isSingleInstanceLoserTerminationConfirmed = false private var didInstallLifecycleSnapshotObservers = false private var didDisableSuddenTermination = false private var commandPaletteStateByWindowId: [UUID: CommandPaletteWindowState] = [:] @@ -1531,10 +1532,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - // Consume the exact-process arbitration request before synchronous persistence begins. - // Removing the request file acknowledges that this process is responsive, preventing - // the winner's bounded fallback from force-closing us while the snapshot is being saved. - let hasValidatedDuplicateShutdownRequest = consumeValidatedDuplicateShutdownRequest() + // Validate the exact-process arbitration request and publish an acknowledgment before + // synchronous persistence begins. Requesters treat that acknowledgment as proof that + // this process is responsive and cannot prompt or force-close us during teardown. + let hasValidatedDuplicateShutdownRequest = acknowledgeValidatedDuplicateShutdownRequest() isTerminatingApp = true SessionMachineryGate.isApplicationTerminating = true // A warning dialog can still cancel this termination request. The final @@ -1544,6 +1545,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let shouldWarn = Self.shouldWarnBeforeTermination( isTaggedDevBuild: SocketControlSettings.isTaggedDevBuild(), isQuitWarningConfirmed: isQuitWarningConfirmed, + isInternalSingleInstanceLoserExit: isSingleInstanceLoserTerminationConfirmed, hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, isQuitWarningEnabled: QuitWarningSettings.isEnabled() ) @@ -8984,6 +8986,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private nonisolated static let duplicateShutdownRequestMaxAge: TimeInterval = 10 private nonisolated static let duplicateShutdownRequestMaxBytes = 4_096 private nonisolated static let duplicateTerminationGraceInterval: TimeInterval = 2 + private nonisolated static let duplicateStateDirectoryMaxEntries = 128 + private nonisolated static let duplicateTargetRequestScanLimit = 32 nonisolated static func shouldAcceptDuplicateShutdownRequest( _ request: SingleInstanceShutdownRequest?, @@ -9013,24 +9017,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser nonisolated static func shouldWarnBeforeTermination( isTaggedDevBuild: Bool, isQuitWarningConfirmed: Bool, + isInternalSingleInstanceLoserExit: Bool, hasValidatedDuplicateShutdownRequest: Bool, isQuitWarningEnabled: Bool ) -> Bool { guard !isTaggedDevBuild, !isQuitWarningConfirmed, + !isInternalSingleInstanceLoserExit, !hasValidatedDuplicateShutdownRequest else { return false } return isQuitWarningEnabled } - nonisolated static func shouldForceDuplicateTermination( - expectedProcessKey: ProgramaSingleInstanceProcessKey, - resolvedProcessKey: ProgramaSingleInstanceProcessKey?, - isTerminated: Bool, - requestIsPending: Bool + nonisolated static func shouldAcceptDuplicateShutdownAcknowledgment( + _ acknowledgment: SingleInstanceShutdownAcknowledgment?, + expectedTarget: ProgramaSingleInstanceProcessKey, + requestCreatedAt: TimeInterval, + now: TimeInterval ) -> Bool { - requestIsPending && resolvedProcessKey == expectedProcessKey && !isTerminated + guard let acknowledgment, + acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, + acknowledgment.target == expectedTarget, + acknowledgment.createdAtUnixSeconds.isFinite else { + return false + } + let requestDistance = abs(acknowledgment.createdAtUnixSeconds - requestCreatedAt) + return acknowledgment.createdAtUnixSeconds <= now + 1 + && requestDistance <= duplicateShutdownRequestMaxAge + } + + nonisolated static func duplicateFallbackAction( + hasValidTargetAcknowledgment: Bool, + requestGenerationIsPending: Bool, + processIdentityMatches: Bool, + isTerminated: Bool, + response: SingleInstanceForcePromptResponse? + ) -> SingleInstanceFallbackAction { + guard !hasValidTargetAcknowledgment, + requestGenerationIsPending, + processIdentityMatches, + !isTerminated else { + return .skip + } + guard let response else { return .prompt } + switch response { + case .forceClose: return .force + case .cancel: return .exitNewer + } } nonisolated static func shouldConsiderDuplicateApplication( @@ -9065,11 +9099,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private static func scheduleDuplicateTermination( requestTermination: () -> Bool, scheduleGrace: (@escaping @MainActor () -> Void) -> Void, - forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + performFallbackAfterGrace: @escaping @MainActor () -> Void ) { guard requestTermination() else { return } scheduleGrace { - _ = forceTerminationIfStillMatching() + performFallbackAfterGrace() } } @@ -9079,9 +9113,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser target: ProgramaSingleInstanceProcessKey, generation: UUID ) -> URL { - rootDirectory.appendingPathComponent( - "programa-single-instance-\(getuid())-\(target.processIdentifier).json", - isDirectory: false + duplicateShutdownRequestURL( + rootDirectory: rootDirectory, + target: target, + generation: generation ) } @@ -9090,7 +9125,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser currentProcessKey: ProgramaSingleInstanceProcessKey, now: TimeInterval ) -> SingleInstanceShutdownAcknowledgment? { - nil + guard request.target == currentProcessKey, + request.version == SingleInstanceShutdownRequest.currentVersion else { + return nil + } + return SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: currentProcessKey, + createdAtUnixSeconds: now + ) } nonisolated static func duplicateFallbackActionForTesting( @@ -9100,10 +9143,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isTerminated: Bool, response: SingleInstanceForcePromptResponse? ) -> SingleInstanceFallbackAction { - guard requestGenerationIsPending, processIdentityMatches, !isTerminated else { - return .skip - } - return .force + duplicateFallbackAction( + hasValidTargetAcknowledgment: hasValidTargetAcknowledgment, + requestGenerationIsPending: requestGenerationIsPending, + processIdentityMatches: processIdentityMatches, + isTerminated: isTerminated, + response: response + ) } nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( @@ -9132,124 +9178,385 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser shouldWarnBeforeTermination( isTaggedDevBuild: isTaggedDevBuild, isQuitWarningConfirmed: isQuitWarningConfirmed, + isInternalSingleInstanceLoserExit: isInternalSingleInstanceLoserExit, hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, isQuitWarningEnabled: isQuitWarningEnabled ) } - nonisolated static func shouldForceDuplicateTerminationForTesting( - expectedProcessKey: ProgramaSingleInstanceProcessKey, - resolvedProcessKey: ProgramaSingleInstanceProcessKey?, - isTerminated: Bool, - requestIsPending: Bool - ) -> Bool { - shouldForceDuplicateTermination( - expectedProcessKey: expectedProcessKey, - resolvedProcessKey: resolvedProcessKey, - isTerminated: isTerminated, - requestIsPending: requestIsPending - ) - } - static func scheduleDuplicateTerminationForTesting( requestTermination: () -> Bool, scheduleGrace: (@escaping @MainActor () -> Void) -> Void, - forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + performFallbackAfterGrace: @escaping @MainActor () -> Void ) { scheduleDuplicateTermination( requestTermination: requestTermination, scheduleGrace: scheduleGrace, - forceTerminationIfStillMatching: forceTerminationIfStillMatching + performFallbackAfterGrace: performFallbackAfterGrace ) } + #endif + private struct PendingSingleInstanceShutdown: Sendable { + let request: SingleInstanceShutdownRequest + let requestURL: URL + let acknowledgmentURL: URL + } + + nonisolated private static func singleInstanceStateDirectoryURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-\(getuid())", + isDirectory: true + ) + } + + nonisolated private static func singleInstanceTargetComponent( + _ target: ProgramaSingleInstanceProcessKey + ) -> String { + "\(target.processIdentifier)-\(target.startSeconds)-\(target.startMicroseconds)" + } + nonisolated private static func duplicateShutdownRequestURL( - for processIdentifier: pid_t + rootDirectory: URL, + target: ProgramaSingleInstanceProcessKey, + generation: UUID ) -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent( - "programa-single-instance-\(getuid())-\(processIdentifier).json", + rootDirectory.appendingPathComponent( + "request-\(singleInstanceTargetComponent(target))-\(generation.uuidString.lowercased()).json", + isDirectory: false + ) + } + + nonisolated private static func duplicateShutdownAcknowledgmentURL( + rootDirectory: URL, + target: ProgramaSingleInstanceProcessKey + ) -> URL { + rootDirectory.appendingPathComponent( + "ack-\(singleInstanceTargetComponent(target)).json", isDirectory: false ) } + nonisolated private static func validatedSingleInstanceStateDirectory() -> URL? { + let fileManager = FileManager.default + let directoryURL = singleInstanceStateDirectoryURL() + do { + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let values = try directoryURL.resourceValues(forKeys: [ + .isDirectoryKey, + .isSymbolicLinkKey, + ]) + let attributes = try fileManager.attributesOfItem(atPath: directoryURL.path) + guard values.isDirectory == true, + values.isSymbolicLink != true, + let owner = attributes[.ownerAccountID] as? NSNumber, + owner.uint32Value == getuid() else { + return nil + } + try fileManager.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directoryURL.path + ) + return directoryURL + } catch { + return nil + } + } + + nonisolated private static func boundedSingleInstanceStateURLs( + in directoryURL: URL + ) -> [URL]? { + let fileManager = FileManager.default + guard let enumerator = fileManager.enumerator( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { + return nil + } + + var urls: [URL] = [] + for case let url as URL in enumerator { + urls.append(url) + guard urls.count <= duplicateStateDirectoryMaxEntries else { return nil } + } + return urls + } + + nonisolated private static func readBoundedSingleInstanceJSON( + _ type: Value.Type, + from url: URL + ) -> Value? { + guard let values = try? url.resourceValues(forKeys: [ + .fileSizeKey, + .isRegularFileKey, + .isSymbolicLinkKey, + ]), + values.isRegularFile == true, + values.isSymbolicLink != true, + let fileSize = values.fileSize, + fileSize <= duplicateShutdownRequestMaxBytes, + let fileHandle = try? FileHandle(forReadingFrom: url) else { + return nil + } + defer { try? fileHandle.close() } + guard let data = try? fileHandle.read(upToCount: duplicateShutdownRequestMaxBytes + 1), + data.count <= duplicateShutdownRequestMaxBytes else { + return nil + } + return try? JSONDecoder().decode(type, from: data) + } + + nonisolated private static func writeBoundedSingleInstanceJSON( + _ value: Value, + to url: URL + ) -> Bool { + do { + let data = try JSONEncoder().encode(value) + guard data.count <= duplicateShutdownRequestMaxBytes else { return false } + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } + + nonisolated private static func isExactRequestPending( + _ pending: PendingSingleInstanceShutdown + ) -> Bool { + readBoundedSingleInstanceJSON( + SingleInstanceShutdownRequest.self, + from: pending.requestURL + ) == pending.request + } + + nonisolated private static func removeExactRequest( + _ pending: PendingSingleInstanceShutdown + ) { + guard isExactRequestPending(pending) else { return } + try? FileManager.default.removeItem(at: pending.requestURL) + } + + nonisolated private static func hasValidAcknowledgment( + for pending: PendingSingleInstanceShutdown, + now: TimeInterval + ) -> Bool { + let acknowledgment = readBoundedSingleInstanceJSON( + SingleInstanceShutdownAcknowledgment.self, + from: pending.acknowledgmentURL + ) + return shouldAcceptDuplicateShutdownAcknowledgment( + acknowledgment, + expectedTarget: pending.request.target, + requestCreatedAt: pending.request.createdAtUnixSeconds, + now: now + ) + } + private static func writeDuplicateShutdownRequest( target: ProgramaSingleInstanceProcessKey, requester: ProgramaSingleInstanceProcessKey - ) -> Bool { + ) -> PendingSingleInstanceShutdown? { + guard let directoryURL = validatedSingleInstanceStateDirectory(), + boundedSingleInstanceStateURLs(in: directoryURL) != nil else { + dilog("single_instance", "pid=\(target.processIdentifier) outcome=rejected reason=state_directory") + return nil + } let request = SingleInstanceShutdownRequest( target: target, requester: requester, createdAtUnixSeconds: Date().timeIntervalSince1970 ) - let requestURL = duplicateShutdownRequestURL(for: target.processIdentifier) - do { - let data = try JSONEncoder().encode(request) - guard data.count <= duplicateShutdownRequestMaxBytes else { - dilog("single_instance", "pid=\(target.processIdentifier) outcome=rejected reason=request_too_large") - return false - } - try data.write(to: requestURL, options: .atomic) - dilog("single_instance", "pid=\(target.processIdentifier) outcome=written reason=shutdown_request") - return true - } catch { + let pending = PendingSingleInstanceShutdown( + request: request, + requestURL: duplicateShutdownRequestURL( + rootDirectory: directoryURL, + target: target, + generation: request.generation + ), + acknowledgmentURL: duplicateShutdownAcknowledgmentURL( + rootDirectory: directoryURL, + target: target + ) + ) + guard writeBoundedSingleInstanceJSON(request, to: pending.requestURL) else { dilog("single_instance", "pid=\(target.processIdentifier) outcome=failed reason=request_write") - return false + return nil } + dilog("single_instance", "pid=\(target.processIdentifier) outcome=written reason=shutdown_request") + return pending } - private func consumeValidatedDuplicateShutdownRequest() -> Bool { + private func acknowledgeValidatedDuplicateShutdownRequest() -> Bool { let currentProcessIdentifier = getpid() - let requestURL = Self.duplicateShutdownRequestURL(for: currentProcessIdentifier) - guard let fileHandle = try? FileHandle(forReadingFrom: requestURL) else { - dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=missing reason=shutdown_request") + guard let currentKey = Self.singleInstanceProcessKey(for: currentProcessIdentifier), + let bundleIdentifier = Bundle.main.bundleIdentifier, + let directoryURL = Self.validatedSingleInstanceStateDirectory(), + let stateURLs = Self.boundedSingleInstanceStateURLs(in: directoryURL) else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=state_directory") return false } - defer { - try? fileHandle.close() - try? FileManager.default.removeItem(at: requestURL) - } - - guard let data = try? fileHandle.read(upToCount: Self.duplicateShutdownRequestMaxBytes + 1), - data.count <= Self.duplicateShutdownRequestMaxBytes, - let request = try? JSONDecoder().decode(SingleInstanceShutdownRequest.self, from: data), - let currentKey = Self.singleInstanceProcessKey(for: currentProcessIdentifier), - let bundleIdentifier = Bundle.main.bundleIdentifier else { - dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=malformed_request") - return false - } - - let requesterApplication = NSRunningApplication( - processIdentifier: request.requesterProcessIdentifier - ) + let requestPrefix = "request-\(Self.singleInstanceTargetComponent(currentKey))-" let embeddedCLIURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) .standardizedFileURL .resolvingSymlinksInPath() - let requesterIsProgramaGUI = requesterApplication.map { application in - Self.shouldConsiderDuplicateApplication( - candidateBundleIdentifier: application.bundleIdentifier, - candidateProcessIdentifier: application.processIdentifier, - candidateExecutableURL: application.executableURL, - expectedBundleIdentifier: bundleIdentifier, - currentProcessIdentifier: currentProcessIdentifier, - embeddedCLIURL: embeddedCLIURL + var targetRequestCount = 0 + for requestURL in stateURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard requestURL.lastPathComponent.hasPrefix(requestPrefix) else { continue } + targetRequestCount += 1 + guard targetRequestCount <= Self.duplicateTargetRequestScanLimit else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=request_limit") + return false + } + guard let request = Self.readBoundedSingleInstanceJSON( + SingleInstanceShutdownRequest.self, + from: requestURL + ) else { + continue + } + let requesterApplication = NSRunningApplication( + processIdentifier: request.requesterProcessIdentifier ) - } ?? false - let accepted = Self.shouldAcceptDuplicateShutdownRequest( - request, - currentProcessKey: currentKey, - now: Date().timeIntervalSince1970, - resolvedRequesterKey: Self.singleInstanceProcessKey( - for: request.requesterProcessIdentifier + let requesterIsProgramaGUI = requesterApplication.map { application in + Self.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: application.bundleIdentifier, + candidateProcessIdentifier: application.processIdentifier, + candidateExecutableURL: application.executableURL, + expectedBundleIdentifier: bundleIdentifier, + currentProcessIdentifier: currentProcessIdentifier, + embeddedCLIURL: embeddedCLIURL + ) + } ?? false + guard Self.shouldAcceptDuplicateShutdownRequest( + request, + currentProcessKey: currentKey, + now: Date().timeIntervalSince1970, + resolvedRequesterKey: Self.singleInstanceProcessKey( + for: request.requesterProcessIdentifier + ), + requesterIsProgramaGUI: requesterIsProgramaGUI + ) else { + continue + } + + let acknowledgment = SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: currentKey, + createdAtUnixSeconds: Date().timeIntervalSince1970 + ) + let acknowledgmentURL = Self.duplicateShutdownAcknowledgmentURL( + rootDirectory: directoryURL, + target: currentKey + ) + guard Self.writeBoundedSingleInstanceJSON(acknowledgment, to: acknowledgmentURL) else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=ack_write") + return false + } + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=accepted reason=shutdown_request") + return true + } + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=missing reason=shutdown_request") + return false + } + + private static func duplicateFallbackState( + app: NSRunningApplication, + pending: PendingSingleInstanceShutdown, + response: SingleInstanceForcePromptResponse? + ) -> (SingleInstanceFallbackAction, NSRunningApplication?) { + let processIdentifier = pending.request.target.processIdentifier + let resolvedApplication = NSRunningApplication(processIdentifier: processIdentifier) + let action = duplicateFallbackAction( + hasValidTargetAcknowledgment: hasValidAcknowledgment( + for: pending, + now: Date().timeIntervalSince1970 ), - requesterIsProgramaGUI: requesterIsProgramaGUI + requestGenerationIsPending: isExactRequestPending(pending), + processIdentityMatches: singleInstanceProcessKey(for: processIdentifier) == pending.request.target, + isTerminated: resolvedApplication?.isTerminated ?? app.isTerminated, + response: response + ) + return (action, resolvedApplication) + } + + @MainActor + private static func promptForDuplicateForceClose() -> SingleInstanceForcePromptResponse { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = String( + localized: "dialog.singleInstanceNotResponding.title", + defaultValue: "Programa Isn’t Responding" ) - dilog( - "single_instance", - "pid=\(currentProcessIdentifier) outcome=\(accepted ? "accepted" : "rejected") reason=shutdown_request" + alert.informativeText = String( + localized: "dialog.singleInstanceNotResponding.message", + defaultValue: "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state." + ) + alert.addButton(withTitle: String( + localized: "dialog.singleInstanceNotResponding.forceClose", + defaultValue: "Force Close" + )) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + return alert.runModal() == .alertFirstButtonReturn ? .forceClose : .cancel + } + + @MainActor + private static func handleDuplicateShutdownFallback( + app: NSRunningApplication, + pending: PendingSingleInstanceShutdown + ) { + let processIdentifier = pending.request.target.processIdentifier + let (initialAction, _) = duplicateFallbackState(app: app, pending: pending, response: nil) + guard initialAction == .prompt else { + removeExactRequest(pending) + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=fallback_revalidated") + return + } + + dilog("single_instance", "pid=\(processIdentifier) outcome=prompted reason=grace_expired") + let response = promptForDuplicateForceClose() + switch response { + case .forceClose: + dilog("single_instance", "pid=\(processIdentifier) outcome=consented reason=force_close") + case .cancel: + dilog("single_instance", "pid=\(processIdentifier) outcome=cancelled reason=user_cancel") + } + let (action, resolvedApplication) = duplicateFallbackState( + app: app, + pending: pending, + response: response ) - return accepted + switch action { + case .force: + guard let resolvedApplication else { + removeExactRequest(pending) + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") + return + } + let forced = resolvedApplication.forceTerminate() + removeExactRequest(pending) + dilog( + "single_instance", + "pid=\(processIdentifier) outcome=\(forced ? "forced" : "force_rejected") reason=user_consent" + ) + case .exitNewer: + removeExactRequest(pending) + resolvedApplication?.activate(options: [.activateAllWindows]) + AppDelegate.shared?.isSingleInstanceLoserTerminationConfirmed = true + dilog("single_instance", "pid=\(processIdentifier) outcome=exiting_newer reason=user_cancel") + NSApp.terminate(nil) + case .skip: + removeExactRequest(pending) + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=post_prompt_revalidation") + case .prompt: + removeExactRequest(pending) + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=invalid_prompt_state") + } } private static func terminateDuplicateApplication( @@ -9258,59 +9565,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser requesterProcessKey: ProgramaSingleInstanceProcessKey ) { let processIdentifier = app.processIdentifier - let requestURL = duplicateShutdownRequestURL(for: processIdentifier) + var pendingShutdown: PendingSingleInstanceShutdown? scheduleDuplicateTermination( requestTermination: { - guard writeDuplicateShutdownRequest( + guard let pending = writeDuplicateShutdownRequest( target: expectedProcessKey, requester: requesterProcessKey ) else { return false } + pendingShutdown = pending let accepted = app.terminate() dilog( "single_instance", "pid=\(processIdentifier) outcome=\(accepted ? "requested" : "request_rejected") reason=graceful_terminate" ) - if !accepted { - try? FileManager.default.removeItem(at: requestURL) - } - return accepted + return true }, scheduleGrace: { action in DispatchQueue.main.asyncAfter(deadline: .now() + duplicateTerminationGraceInterval) { @MainActor in action() } }, - forceTerminationIfStillMatching: { - let requestIsPending = FileManager.default.fileExists(atPath: requestURL.path) - defer { try? FileManager.default.removeItem(at: requestURL) } - guard requestIsPending else { - dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=request_acknowledged") - return false - } - guard let resolvedApplication = NSRunningApplication(processIdentifier: processIdentifier) else { - dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") - return false - } - let resolvedKey = singleInstanceProcessKey(for: processIdentifier) - guard shouldForceDuplicateTermination( - expectedProcessKey: expectedProcessKey, - resolvedProcessKey: resolvedKey, - isTerminated: resolvedApplication.isTerminated, - requestIsPending: requestIsPending - ) else { - let reason = resolvedKey == expectedProcessKey ? "already_terminated" : "identity_changed" - dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=\(reason)") - return false + performFallbackAfterGrace: { + guard let pendingShutdown else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=missing_generation") + return } - - let forced = resolvedApplication.forceTerminate() - dilog( - "single_instance", - "pid=\(processIdentifier) outcome=\(forced ? "forced" : "force_rejected") reason=grace_expired" - ) - return forced + handleDuplicateShutdownFallback(app: app, pending: pendingShutdown) } ) } diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 4cd2ad7e..fc07e64f 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -197,9 +197,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { )) } - func testDuplicateInstanceTerminationWaitsForGraceBeforeForcing() throws { + func testDuplicateInstanceTerminationWaitsForGraceBeforeRunningFallback() throws { var gracefulTerminationCount = 0 - var forcedTerminationCount = 0 + var fallbackCount = 0 var scheduledGraceAction: (@MainActor () -> Void)? AppDelegate.scheduleDuplicateTerminationForTesting( @@ -210,19 +210,18 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { scheduleGrace: { action in scheduledGraceAction = action }, - forceTerminationIfStillMatching: { - forcedTerminationCount += 1 - return true + performFallbackAfterGrace: { + fallbackCount += 1 } ) XCTAssertEqual(gracefulTerminationCount, 1) - XCTAssertEqual(forcedTerminationCount, 0, "Force termination must not run synchronously") + XCTAssertEqual(fallbackCount, 0, "The fallback must not run synchronously") let graceAction = try XCTUnwrap(scheduledGraceAction) graceAction() - XCTAssertEqual(forcedTerminationCount, 1) + XCTAssertEqual(fallbackCount, 1) } func testValidatedDuplicateShutdownRequestTargetsExactCurrentProcessAndBypassesWarning() { @@ -347,50 +346,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { )) } - func testDuplicateForceFallbackRequiresSameLiveProcessIdentity() { - let expected = ProgramaSingleInstanceProcessKey( - startSeconds: 1_000, - startMicroseconds: 100, - processIdentifier: 100 - ) - let changed = ProgramaSingleInstanceProcessKey( - startSeconds: 1_001, - startMicroseconds: 0, - processIdentifier: 100 - ) - - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: nil, - isTerminated: false, - requestIsPending: true - )) - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: changed, - isTerminated: false, - requestIsPending: true - )) - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: expected, - isTerminated: true, - requestIsPending: true - )) - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: expected, - isTerminated: false, - requestIsPending: false - )) - XCTAssertTrue(AppDelegate.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: expected, - isTerminated: false, - requestIsPending: true - )) - } - func testConcurrentDuplicateRequestGenerationsCannotOverwriteOrDeleteEachOther() throws { let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( "programa-single-instance-ownership-\(UUID().uuidString)", From cc8c20e89c3a9730d9618ae6174520ed8887ffa3 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 12:06:05 -0300 Subject: [PATCH 3/8] test: cover bounded duplicate shutdown state --- Sources/AppDelegate.swift | 99 ++++++ .../AppDelegateShortcutRoutingTests.swift | 321 ++++++++++++++++++ 2 files changed, 420 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a4cf903a..0271ac9c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8942,6 +8942,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser case cancel } + enum SingleInstanceForcePromptButton: Equatable, Sendable { + case primary + case secondary + case escape + } + + struct SingleInstanceCodeIdentity: Equatable, Sendable { + let signingIdentifier: String? + let teamIdentifier: String? + } + enum SingleInstanceFallbackAction: Equatable, Sendable { case skip case prompt @@ -9152,6 +9163,94 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) } + nonisolated static func duplicateAcknowledgmentURLForTesting( + rootDirectory: URL, + target: ProgramaSingleInstanceProcessKey, + generation: UUID + ) -> URL { + duplicateShutdownAcknowledgmentURL( + rootDirectory: rootDirectory, + target: target + ) + } + + nonisolated static func writeDuplicateRequestForTesting( + rootDirectory: URL, + request: SingleInstanceShutdownRequest + ) -> URL? { + let url = duplicateShutdownRequestURL( + rootDirectory: rootDirectory, + target: request.target, + generation: request.generation + ) + return writeBoundedSingleInstanceJSON(request, to: url) ? url : nil + } + + nonisolated static func removeDuplicateStateForTesting( + rootDirectory: URL, + request: SingleInstanceShutdownRequest + ) -> Bool { + let pending = PendingSingleInstanceShutdown( + request: request, + requestURL: duplicateShutdownRequestURL( + rootDirectory: rootDirectory, + target: request.target, + generation: request.generation + ), + acknowledgmentURL: duplicateShutdownAcknowledgmentURL( + rootDirectory: rootDirectory, + target: request.target + ) + ) + let existed = isExactRequestPending(pending) + removeExactRequest(pending) + try? FileManager.default.removeItem(at: pending.acknowledgmentURL) + return existed + } + + nonisolated static func prepareDuplicateStateForTesting( + rootDirectory: URL, + now: TimeInterval, + isProcessLive: (ProgramaSingleInstanceProcessKey) -> Bool + ) -> Bool { + boundedSingleInstanceStateURLs(in: rootDirectory) != nil + } + + nonisolated static func shouldAcceptDuplicateShutdownAcknowledgmentForTesting( + _ acknowledgment: SingleInstanceShutdownAcknowledgment?, + expectedRequest: SingleInstanceShutdownRequest, + now: TimeInterval + ) -> Bool { + shouldAcceptDuplicateShutdownAcknowledgment( + acknowledgment, + expectedTarget: expectedRequest.target, + requestCreatedAt: expectedRequest.createdAtUnixSeconds, + now: now + ) + } + + nonisolated static func shouldScheduleDuplicateFallbackForTesting( + requestWasWritten: Bool, + gracefulTerminationAccepted: Bool + ) -> Bool { + requestWasWritten + } + + nonisolated static func duplicateForcePromptResponseForTesting( + button: SingleInstanceForcePromptButton + ) -> SingleInstanceForcePromptResponse { + button == .primary ? .forceClose : .cancel + } + + nonisolated static func shouldTrustDuplicateCodeIdentityForTesting( + current: SingleInstanceCodeIdentity, + candidate: SingleInstanceCodeIdentity, + designatedRequirementMatches: Bool, + isDebugBuild: Bool + ) -> Bool { + designatedRequirementMatches + } + nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( _ request: SingleInstanceShutdownRequest?, currentProcessKey: ProgramaSingleInstanceProcessKey, diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index fc07e64f..bba105b4 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -386,6 +386,327 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: secondURL.path)) } + func testThreeConcurrentShutdownGenerationsOwnRequestAndAcknowledgmentFiles() throws { + let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-three-generations-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let requests = (0..<3).map { index in + AppDelegate.SingleInstanceShutdownRequest( + generation: UUID(), + target: target, + requester: ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: Int64(index), + processIdentifier: pid_t(200 + index) + ), + createdAtUnixSeconds: 10_000 + ) + } + var requestURLs: [URL] = [] + var acknowledgmentURLs: [URL] = [] + for request in requests { + requestURLs.append(try XCTUnwrap(AppDelegate.writeDuplicateRequestForTesting( + rootDirectory: rootDirectory, + request: request + ))) + let acknowledgmentURL = AppDelegate.duplicateAcknowledgmentURLForTesting( + rootDirectory: rootDirectory, + target: target, + generation: request.generation + ) + let acknowledgment = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: target, + createdAtUnixSeconds: 10_001 + ) + try JSONEncoder().encode(acknowledgment).write(to: acknowledgmentURL, options: .atomic) + acknowledgmentURLs.append(acknowledgmentURL) + } + + XCTAssertEqual(Set(requestURLs).count, 3) + XCTAssertEqual(Set(acknowledgmentURLs).count, 3) + XCTAssertTrue(AppDelegate.removeDuplicateStateForTesting( + rootDirectory: rootDirectory, + request: requests[0] + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: requestURLs[0].path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: acknowledgmentURLs[0].path)) + for index in 1..<3 { + XCTAssertTrue(FileManager.default.fileExists(atPath: requestURLs[index].path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: acknowledgmentURLs[index].path)) + } + } + + func testDuplicateAcknowledgmentRequiresExactTargetGenerationAndTiming() { + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let requester = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ) + let request = AppDelegate.SingleInstanceShutdownRequest( + generation: UUID(), + target: target, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + let valid = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: target, + createdAtUnixSeconds: 10_001 + ) + let wrongTarget = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: requester, + createdAtUnixSeconds: 10_001 + ) + let wrongGeneration = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: UUID(), + target: target, + createdAtUnixSeconds: 10_001 + ) + let beforeRequest = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: target, + createdAtUnixSeconds: 9_999 + ) + let tooLate = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: target, + createdAtUnixSeconds: 10_011 + ) + let future = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: target, + createdAtUnixSeconds: 10_003 + ) + + XCTAssertTrue(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( + valid, + expectedRequest: request, + now: 10_002 + )) + for invalid in [wrongTarget, wrongGeneration, beforeRequest, tooLate, future] { + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( + invalid, + expectedRequest: request, + now: 10_002 + )) + } + } + + func testDuplicateStatePrunesMoreThanOperationalCapWhenEveryEntryIsStale() throws { + let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-stale-cap-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + for index in 0..<130 { + let request = AppDelegate.SingleInstanceShutdownRequest( + generation: UUID(), + target: target, + requester: ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: Int64(index), + processIdentifier: pid_t(200 + index) + ), + createdAtUnixSeconds: 9_000 + ) + _ = try XCTUnwrap(AppDelegate.writeDuplicateRequestForTesting( + rootDirectory: rootDirectory, + request: request + )) + let acknowledgmentURL = AppDelegate.duplicateAcknowledgmentURLForTesting( + rootDirectory: rootDirectory, + target: target, + generation: request.generation + ) + let acknowledgment = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: target, + createdAtUnixSeconds: 9_001 + ) + try JSONEncoder().encode(acknowledgment).write(to: acknowledgmentURL, options: .atomic) + } + + XCTAssertTrue(AppDelegate.prepareDuplicateStateForTesting( + rootDirectory: rootDirectory, + now: 10_000, + isProcessLive: { _ in false } + )) + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: rootDirectory.path).count, + 0 + ) + } + + func testDuplicateStateRetainsStaleFilesForExactLiveProcesses() throws { + let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-live-retention-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let request = AppDelegate.SingleInstanceShutdownRequest( + target: target, + requester: ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ), + createdAtUnixSeconds: 9_000 + ) + let requestURL = try XCTUnwrap(AppDelegate.writeDuplicateRequestForTesting( + rootDirectory: rootDirectory, + request: request + )) + let acknowledgmentURL = AppDelegate.duplicateAcknowledgmentURLForTesting( + rootDirectory: rootDirectory, + target: target, + generation: request.generation + ) + let acknowledgment = AppDelegate.SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: target, + createdAtUnixSeconds: 9_001 + ) + try JSONEncoder().encode(acknowledgment).write(to: acknowledgmentURL, options: .atomic) + + XCTAssertTrue(AppDelegate.prepareDuplicateStateForTesting( + rootDirectory: rootDirectory, + now: 10_000, + isProcessLive: { $0 == target } + )) + XCTAssertTrue(FileManager.default.fileExists(atPath: requestURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: acknowledgmentURL.path)) + } + + func testDuplicateStateRefusesMalformedAndSymlinkEntriesWithoutDeletingThem() throws { + let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-unsafe-state-\(UUID().uuidString)", + isDirectory: true + ) + let symlinkTarget = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-symlink-target-\(UUID().uuidString)", + isDirectory: false + ) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: false) + try Data("outside".utf8).write(to: symlinkTarget) + defer { + try? FileManager.default.removeItem(at: rootDirectory) + try? FileManager.default.removeItem(at: symlinkTarget) + } + let malformedURL = rootDirectory.appendingPathComponent("request-malformed.json") + let symlinkURL = rootDirectory.appendingPathComponent("ack-malformed.json") + try Data("not-json".utf8).write(to: malformedURL) + try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: symlinkTarget) + + XCTAssertFalse(AppDelegate.prepareDuplicateStateForTesting( + rootDirectory: rootDirectory, + now: 10_000, + isProcessLive: { _ in false } + )) + XCTAssertTrue(FileManager.default.fileExists(atPath: malformedURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: symlinkURL.path)) + XCTAssertEqual(try Data(contentsOf: symlinkTarget), Data("outside".utf8)) + } + + func testRejectedGracefulTerminationStillSchedulesConsentFallbackForOwnedRequest() { + XCTAssertTrue(AppDelegate.shouldScheduleDuplicateFallbackForTesting( + requestWasWritten: true, + gracefulTerminationAccepted: false + )) + XCTAssertFalse(AppDelegate.shouldScheduleDuplicateFallbackForTesting( + requestWasWritten: false, + gracefulTerminationAccepted: false + )) + } + + func testDuplicateForcePromptDefaultsReturnAndEscapeToCancel() { + XCTAssertEqual(AppDelegate.duplicateForcePromptResponseForTesting(button: .primary), .cancel) + XCTAssertEqual(AppDelegate.duplicateForcePromptResponseForTesting(button: .secondary), .forceClose) + XCTAssertEqual(AppDelegate.duplicateForcePromptResponseForTesting(button: .escape), .cancel) + } + + func testDuplicateRequesterRequiresMatchingDesignatedSigningIdentity() { + let releaseIdentity = AppDelegate.SingleInstanceCodeIdentity( + signingIdentifier: "com.darkroom.programa", + teamIdentifier: "DARKROOMTEAM" + ) + XCTAssertTrue(AppDelegate.shouldTrustDuplicateCodeIdentityForTesting( + current: releaseIdentity, + candidate: releaseIdentity, + designatedRequirementMatches: true, + isDebugBuild: false + )) + XCTAssertFalse(AppDelegate.shouldTrustDuplicateCodeIdentityForTesting( + current: releaseIdentity, + candidate: AppDelegate.SingleInstanceCodeIdentity( + signingIdentifier: "com.darkroom.programa", + teamIdentifier: "SPOOFEDTEAM" + ), + designatedRequirementMatches: true, + isDebugBuild: false + )) + XCTAssertFalse(AppDelegate.shouldTrustDuplicateCodeIdentityForTesting( + current: releaseIdentity, + candidate: AppDelegate.SingleInstanceCodeIdentity( + signingIdentifier: "com.example.spoof", + teamIdentifier: "DARKROOMTEAM" + ), + designatedRequirementMatches: true, + isDebugBuild: false + )) + let adHocIdentity = AppDelegate.SingleInstanceCodeIdentity( + signingIdentifier: "com.darkroom.programa.debug", + teamIdentifier: nil + ) + XCTAssertFalse(AppDelegate.shouldTrustDuplicateCodeIdentityForTesting( + current: adHocIdentity, + candidate: adHocIdentity, + designatedRequirementMatches: true, + isDebugBuild: false + )) + XCTAssertTrue(AppDelegate.shouldTrustDuplicateCodeIdentityForTesting( + current: adHocIdentity, + candidate: adHocIdentity, + designatedRequirementMatches: true, + isDebugBuild: true + )) + XCTAssertFalse(AppDelegate.shouldTrustDuplicateCodeIdentityForTesting( + current: adHocIdentity, + candidate: adHocIdentity, + designatedRequirementMatches: false, + isDebugBuild: true + )) + } + func testAcceptingOneGenerationAcknowledgesExactTargetForEveryRequester() throws { let target = ProgramaSingleInstanceProcessKey( startSeconds: 1_000, From ad2ee81da7a88607c138d4d09938c1399420629d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 12:15:36 -0300 Subject: [PATCH 4/8] fix: bound duplicate shutdown arbitration --- CHANGELOG.md | 2 +- Sources/AppDelegate.swift | 456 +++++++++++++++--- .../AppDelegateShortcutRoutingTests.swift | 18 +- 3 files changed, 404 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b72b7353..78ef8573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. - An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. -- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. Concurrent launch requests no longer overwrite one another, a responsive older copy exits through the normal session-persistence path, and an unresponsive copy can be force-closed only after an explicit data-loss warning and a final process-identity check. +- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. Concurrent requests and acknowledgments are generation-owned and cleaned up safely, stale arbitration state cannot exhaust future launches, only an authentically signed Programa copy can request the quit-warning bypass, and force close is a secondary action behind a Cancel-first data-loss warning and final identity check. - Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0271ac9c..65d04917 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8,6 +8,7 @@ import WebKit import Combine import ObjectiveC.runtime import Darwin +import Security @@ -8998,6 +8999,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private nonisolated static let duplicateShutdownRequestMaxBytes = 4_096 private nonisolated static let duplicateTerminationGraceInterval: TimeInterval = 2 private nonisolated static let duplicateStateDirectoryMaxEntries = 128 + private nonisolated static let duplicateStateDirectoryScanLimit = 512 private nonisolated static let duplicateTargetRequestScanLimit = 32 nonisolated static func shouldAcceptDuplicateShutdownRequest( @@ -9044,20 +9046,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser nonisolated static func shouldAcceptDuplicateShutdownAcknowledgment( _ acknowledgment: SingleInstanceShutdownAcknowledgment?, expectedTarget: ProgramaSingleInstanceProcessKey, + expectedGeneration: UUID, requestCreatedAt: TimeInterval, now: TimeInterval ) -> Bool { guard let acknowledgment, acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, acknowledgment.target == expectedTarget, + acknowledgment.acceptedGeneration == expectedGeneration, acknowledgment.createdAtUnixSeconds.isFinite else { return false } - let requestDistance = abs(acknowledgment.createdAtUnixSeconds - requestCreatedAt) + let requestDistance = acknowledgment.createdAtUnixSeconds - requestCreatedAt return acknowledgment.createdAtUnixSeconds <= now + 1 + && requestDistance >= 0 && requestDistance <= duplicateShutdownRequestMaxAge } + nonisolated static func shouldScheduleDuplicateFallback( + requestWasWritten: Bool, + gracefulTerminationAccepted: Bool + ) -> Bool { + requestWasWritten + } + + nonisolated static func duplicateForcePromptResponse( + button: SingleInstanceForcePromptButton + ) -> SingleInstanceForcePromptResponse { + switch button { + case .primary, .escape: return .cancel + case .secondary: return .forceClose + } + } + + nonisolated static func shouldTrustDuplicateCodeIdentity( + current: SingleInstanceCodeIdentity, + candidate: SingleInstanceCodeIdentity, + designatedRequirementMatches: Bool, + isDebugBuild: Bool + ) -> Bool { + guard designatedRequirementMatches, + let currentSigningIdentifier = current.signingIdentifier, + !currentSigningIdentifier.isEmpty, + candidate.signingIdentifier == currentSigningIdentifier else { + return false + } + if isDebugBuild, current.teamIdentifier == nil, candidate.teamIdentifier == nil { + return true + } + guard let currentTeamIdentifier = current.teamIdentifier, + !currentTeamIdentifier.isEmpty else { + return false + } + return candidate.teamIdentifier == currentTeamIdentifier + } + nonisolated static func duplicateFallbackAction( hasValidTargetAcknowledgment: Bool, requestGenerationIsPending: Bool, @@ -9107,6 +9150,115 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return true } + nonisolated private static func staticCodeForCurrentProcess() -> SecStaticCode? { + var dynamicCode: SecCode? + guard SecCodeCopySelf(SecCSFlags(rawValue: 0), &dynamicCode) == errSecSuccess, + let dynamicCode else { + return nil + } + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode( + dynamicCode, + SecCSFlags(rawValue: 0), + &staticCode + ) == errSecSuccess else { + return nil + } + return staticCode + } + + nonisolated private static func staticCode( + for processIdentifier: pid_t + ) -> SecStaticCode? { + let attributes = [ + kSecGuestAttributePid as String: NSNumber(value: processIdentifier), + ] as CFDictionary + var dynamicCode: SecCode? + guard SecCodeCopyGuestWithAttributes( + nil, + attributes, + SecCSFlags(rawValue: 0), + &dynamicCode + ) == errSecSuccess, + let dynamicCode else { + return nil + } + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode( + dynamicCode, + SecCSFlags(rawValue: 0), + &staticCode + ) == errSecSuccess else { + return nil + } + return staticCode + } + + nonisolated private static func singleInstanceCodeIdentity( + for staticCode: SecStaticCode + ) -> SingleInstanceCodeIdentity? { + var signingInformation: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &signingInformation + ) == errSecSuccess, + let signingInformation else { + return nil + } + let dictionary = signingInformation as NSDictionary + return SingleInstanceCodeIdentity( + signingIdentifier: dictionary[kSecCodeInfoIdentifier] as? String, + teamIdentifier: dictionary[kSecCodeInfoTeamIdentifier] as? String + ) + } + + nonisolated private static func isAuthenticatedProgramaApplication( + processIdentifier: pid_t + ) -> Bool { + guard let currentCode = staticCodeForCurrentProcess(), + let candidateCode = staticCode(for: processIdentifier), + let currentIdentity = singleInstanceCodeIdentity(for: currentCode), + let candidateIdentity = singleInstanceCodeIdentity(for: candidateCode) else { + dilog("single_instance", "pid=\(processIdentifier) outcome=rejected reason=signing_metadata") + return false + } + var designatedRequirement: SecRequirement? + guard SecCodeCopyDesignatedRequirement( + currentCode, + SecCSFlags(rawValue: 0), + &designatedRequirement + ) == errSecSuccess, + let designatedRequirement else { + dilog("single_instance", "pid=\(processIdentifier) outcome=rejected reason=signing_requirement") + return false + } + let validationFlags = SecCSFlags( + rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures + ) + let designatedRequirementMatches = SecStaticCodeCheckValidity( + candidateCode, + validationFlags, + designatedRequirement + ) == errSecSuccess +#if DEBUG + let isDebugBuild = true +#else + let isDebugBuild = false +#endif + let trusted = shouldTrustDuplicateCodeIdentity( + current: currentIdentity, + candidate: candidateIdentity, + designatedRequirementMatches: designatedRequirementMatches, + isDebugBuild: isDebugBuild + ) + dilog( + "single_instance", + "pid=\(processIdentifier) outcome=\(trusted ? "accepted" : "rejected") reason=code_identity" + ) + return trusted + } + private static func scheduleDuplicateTermination( requestTermination: () -> Bool, scheduleGrace: (@escaping @MainActor () -> Void) -> Void, @@ -9170,7 +9322,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) -> URL { duplicateShutdownAcknowledgmentURL( rootDirectory: rootDirectory, - target: target + target: target, + generation: generation ) } @@ -9199,12 +9352,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ), acknowledgmentURL: duplicateShutdownAcknowledgmentURL( rootDirectory: rootDirectory, - target: request.target + target: request.target, + generation: request.generation ) ) let existed = isExactRequestPending(pending) - removeExactRequest(pending) - try? FileManager.default.removeItem(at: pending.acknowledgmentURL) + removeExactShutdownState(pending) return existed } @@ -9213,7 +9366,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser now: TimeInterval, isProcessLive: (ProgramaSingleInstanceProcessKey) -> Bool ) -> Bool { - boundedSingleInstanceStateURLs(in: rootDirectory) != nil + preparedSingleInstanceStateURLs( + in: rootDirectory, + now: now, + isProcessLive: isProcessLive + ) != nil } nonisolated static func shouldAcceptDuplicateShutdownAcknowledgmentForTesting( @@ -9224,6 +9381,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser shouldAcceptDuplicateShutdownAcknowledgment( acknowledgment, expectedTarget: expectedRequest.target, + expectedGeneration: expectedRequest.generation, requestCreatedAt: expectedRequest.createdAtUnixSeconds, now: now ) @@ -9233,13 +9391,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser requestWasWritten: Bool, gracefulTerminationAccepted: Bool ) -> Bool { - requestWasWritten + shouldScheduleDuplicateFallback( + requestWasWritten: requestWasWritten, + gracefulTerminationAccepted: gracefulTerminationAccepted + ) } nonisolated static func duplicateForcePromptResponseForTesting( button: SingleInstanceForcePromptButton ) -> SingleInstanceForcePromptResponse { - button == .primary ? .forceClose : .cancel + duplicateForcePromptResponse(button: button) } nonisolated static func shouldTrustDuplicateCodeIdentityForTesting( @@ -9248,7 +9409,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser designatedRequirementMatches: Bool, isDebugBuild: Bool ) -> Bool { - designatedRequirementMatches + shouldTrustDuplicateCodeIdentity( + current: current, + candidate: candidate, + designatedRequirementMatches: designatedRequirementMatches, + isDebugBuild: isDebugBuild + ) } nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( @@ -9303,8 +9469,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let acknowledgmentURL: URL } - nonisolated private static func singleInstanceStateDirectoryURL() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent( + nonisolated private static func singleInstanceStateDirectoryURL( + rootDirectory: URL = FileManager.default.temporaryDirectory + ) -> URL { + rootDirectory.appendingPathComponent( "programa-single-instance-\(getuid())", isDirectory: true ) @@ -9329,10 +9497,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser nonisolated private static func duplicateShutdownAcknowledgmentURL( rootDirectory: URL, - target: ProgramaSingleInstanceProcessKey + target: ProgramaSingleInstanceProcessKey, + generation: UUID ) -> URL { rootDirectory.appendingPathComponent( - "ack-\(singleInstanceTargetComponent(target)).json", + "ack-\(singleInstanceTargetComponent(target))-\(generation.uuidString.lowercased()).json", isDirectory: false ) } @@ -9367,26 +9536,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } - nonisolated private static func boundedSingleInstanceStateURLs( - in directoryURL: URL - ) -> [URL]? { - let fileManager = FileManager.default - guard let enumerator = fileManager.enumerator( - at: directoryURL, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] - ) else { - return nil - } - - var urls: [URL] = [] - for case let url as URL in enumerator { - urls.append(url) - guard urls.count <= duplicateStateDirectoryMaxEntries else { return nil } - } - return urls - } - nonisolated private static func readBoundedSingleInstanceJSON( _ type: Value.Type, from url: URL @@ -9434,11 +9583,157 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) == pending.request } + @discardableResult nonisolated private static func removeExactRequest( _ pending: PendingSingleInstanceShutdown + ) -> Bool { + guard isExactRequestPending(pending) else { + return !FileManager.default.fileExists(atPath: pending.requestURL.path) + } + do { + try FileManager.default.removeItem(at: pending.requestURL) + return true + } catch { + return false + } + } + + @discardableResult + nonisolated private static func removeExactAcknowledgment( + target: ProgramaSingleInstanceProcessKey, + generation: UUID, + url: URL + ) -> Bool { + guard let acknowledgment = readBoundedSingleInstanceJSON( + SingleInstanceShutdownAcknowledgment.self, + from: url + ) else { + return !FileManager.default.fileExists(atPath: url.path) + } + guard acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, + acknowledgment.target == target, + acknowledgment.acceptedGeneration == generation else { + return false + } + do { + try FileManager.default.removeItem(at: url) + return true + } catch { + return false + } + } + + nonisolated private static func removeExactShutdownState( + _ pending: PendingSingleInstanceShutdown ) { - guard isExactRequestPending(pending) else { return } - try? FileManager.default.removeItem(at: pending.requestURL) + _ = removeExactRequest(pending) + _ = removeExactAcknowledgment( + target: pending.request.target, + generation: pending.request.generation, + url: pending.acknowledgmentURL + ) + } + + nonisolated private static func preparedSingleInstanceStateURLs( + in directoryURL: URL, + now: TimeInterval, + isProcessLive: (ProgramaSingleInstanceProcessKey) -> Bool + ) -> [URL]? { + guard let enumerator = FileManager.default.enumerator( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { + return nil + } + + var requests: [(URL, SingleInstanceShutdownRequest)] = [] + var acknowledgments: [(URL, SingleInstanceShutdownAcknowledgment)] = [] + var scannedCount = 0 + for case let url as URL in enumerator { + scannedCount += 1 + guard scannedCount <= duplicateStateDirectoryScanLimit else { return nil } + + if url.lastPathComponent.hasPrefix("request-"), + let request = readBoundedSingleInstanceJSON( + SingleInstanceShutdownRequest.self, + from: url + ), + duplicateShutdownRequestURL( + rootDirectory: directoryURL, + target: request.target, + generation: request.generation + ).standardizedFileURL == url.standardizedFileURL { + let age = now - request.createdAtUnixSeconds + guard request.version == SingleInstanceShutdownRequest.currentVersion, + request.createdAtUnixSeconds.isFinite, + age >= 0 else { return nil } + requests.append((url, request)) + continue + } + + if url.lastPathComponent.hasPrefix("ack-"), + let acknowledgment = readBoundedSingleInstanceJSON( + SingleInstanceShutdownAcknowledgment.self, + from: url + ), + duplicateShutdownAcknowledgmentURL( + rootDirectory: directoryURL, + target: acknowledgment.target, + generation: acknowledgment.acceptedGeneration + ).standardizedFileURL == url.standardizedFileURL { + let age = now - acknowledgment.createdAtUnixSeconds + guard acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, + acknowledgment.createdAtUnixSeconds.isFinite, + age >= 0 else { return nil } + acknowledgments.append((url, acknowledgment)) + continue + } + + return nil + } + + var retainedURLs: [URL] = [] + var liveRequestKeys: Set = [] + for (url, request) in requests { + let age = now - request.createdAtUnixSeconds + let isStale = age > duplicateShutdownRequestMaxAge + && (!isProcessLive(request.target) || !isProcessLive(request.requester)) + if isStale { + let pending = PendingSingleInstanceShutdown( + request: request, + requestURL: url, + acknowledgmentURL: duplicateShutdownAcknowledgmentURL( + rootDirectory: directoryURL, + target: request.target, + generation: request.generation + ) + ) + guard removeExactRequest(pending) else { return nil } + } else { + retainedURLs.append(url) + liveRequestKeys.insert( + "\(singleInstanceTargetComponent(request.target))-\(request.generation.uuidString.lowercased())" + ) + } + } + for (url, acknowledgment) in acknowledgments { + let requestKey = "\(singleInstanceTargetComponent(acknowledgment.target))-\(acknowledgment.acceptedGeneration.uuidString.lowercased())" + let age = now - acknowledgment.createdAtUnixSeconds + let isStale = age > duplicateShutdownRequestMaxAge + && (!isProcessLive(acknowledgment.target) || !liveRequestKeys.contains(requestKey)) + if isStale { + guard removeExactAcknowledgment( + target: acknowledgment.target, + generation: acknowledgment.acceptedGeneration, + url: url + ) else { return nil } + } else { + retainedURLs.append(url) + } + } + guard retainedURLs.count <= duplicateStateDirectoryMaxEntries else { return nil } + return retainedURLs } nonisolated private static func hasValidAcknowledgment( @@ -9452,6 +9747,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return shouldAcceptDuplicateShutdownAcknowledgment( acknowledgment, expectedTarget: pending.request.target, + expectedGeneration: pending.request.generation, requestCreatedAt: pending.request.createdAtUnixSeconds, now: now ) @@ -9462,7 +9758,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser requester: ProgramaSingleInstanceProcessKey ) -> PendingSingleInstanceShutdown? { guard let directoryURL = validatedSingleInstanceStateDirectory(), - boundedSingleInstanceStateURLs(in: directoryURL) != nil else { + preparedSingleInstanceStateURLs( + in: directoryURL, + now: Date().timeIntervalSince1970, + isProcessLive: { singleInstanceProcessKey(for: $0.processIdentifier) == $0 } + ) != nil else { dilog("single_instance", "pid=\(target.processIdentifier) outcome=rejected reason=state_directory") return nil } @@ -9480,7 +9780,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ), acknowledgmentURL: duplicateShutdownAcknowledgmentURL( rootDirectory: directoryURL, - target: target + target: target, + generation: request.generation ) ) guard writeBoundedSingleInstanceJSON(request, to: pending.requestURL) else { @@ -9496,7 +9797,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser guard let currentKey = Self.singleInstanceProcessKey(for: currentProcessIdentifier), let bundleIdentifier = Bundle.main.bundleIdentifier, let directoryURL = Self.validatedSingleInstanceStateDirectory(), - let stateURLs = Self.boundedSingleInstanceStateURLs(in: directoryURL) else { + let stateURLs = Self.preparedSingleInstanceStateURLs( + in: directoryURL, + now: Date().timeIntervalSince1970, + isProcessLive: { Self.singleInstanceProcessKey(for: $0.processIdentifier) == $0 } + ) else { dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=state_directory") return false } @@ -9505,14 +9810,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) .standardizedFileURL .resolvingSymlinksInPath() - var targetRequestCount = 0 - for requestURL in stateURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { - guard requestURL.lastPathComponent.hasPrefix(requestPrefix) else { continue } - targetRequestCount += 1 - guard targetRequestCount <= Self.duplicateTargetRequestScanLimit else { - dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=request_limit") - return false - } + let targetRequestURLs = stateURLs.filter { + $0.lastPathComponent.hasPrefix(requestPrefix) + } + guard targetRequestURLs.count <= Self.duplicateTargetRequestScanLimit else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=request_limit") + return false + } + var acceptedAnyRequest = false + for requestURL in targetRequestURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { guard let request = Self.readBoundedSingleInstanceJSON( SingleInstanceShutdownRequest.self, from: requestURL @@ -9531,6 +9837,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser currentProcessIdentifier: currentProcessIdentifier, embeddedCLIURL: embeddedCLIURL ) + && Self.isAuthenticatedProgramaApplication( + processIdentifier: application.processIdentifier + ) } ?? false guard Self.shouldAcceptDuplicateShutdownRequest( request, @@ -9543,6 +9852,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { continue } + acceptedAnyRequest = true let acknowledgment = SingleInstanceShutdownAcknowledgment( acceptedGeneration: request.generation, @@ -9551,15 +9861,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) let acknowledgmentURL = Self.duplicateShutdownAcknowledgmentURL( rootDirectory: directoryURL, - target: currentKey + target: currentKey, + generation: request.generation ) - guard Self.writeBoundedSingleInstanceJSON(acknowledgment, to: acknowledgmentURL) else { + if !Self.writeBoundedSingleInstanceJSON(acknowledgment, to: acknowledgmentURL) { dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=ack_write") - return false + continue } dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=accepted reason=shutdown_request") - return true } + if acceptedAnyRequest { return true } dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=missing reason=shutdown_request") return false } @@ -9596,12 +9907,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser localized: "dialog.singleInstanceNotResponding.message", defaultValue: "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state." ) - alert.addButton(withTitle: String( + let cancelButton = alert.addButton( + withTitle: String(localized: "common.cancel", defaultValue: "Cancel") + ) + cancelButton.keyEquivalent = "\u{1b}" + let forceCloseButton = alert.addButton(withTitle: String( localized: "dialog.singleInstanceNotResponding.forceClose", defaultValue: "Force Close" )) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - return alert.runModal() == .alertFirstButtonReturn ? .forceClose : .cancel + forceCloseButton.keyEquivalent = "" + alert.window.defaultButtonCell = cancelButton.cell as? NSButtonCell + + let button: SingleInstanceForcePromptButton + switch alert.runModal() { + case .alertFirstButtonReturn: button = .primary + case .alertSecondButtonReturn: button = .secondary + default: button = .escape + } + return duplicateForcePromptResponse(button: button) } @MainActor @@ -9612,7 +9935,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let processIdentifier = pending.request.target.processIdentifier let (initialAction, _) = duplicateFallbackState(app: app, pending: pending, response: nil) guard initialAction == .prompt else { - removeExactRequest(pending) + removeExactShutdownState(pending) dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=fallback_revalidated") return } @@ -9633,27 +9956,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser switch action { case .force: guard let resolvedApplication else { - removeExactRequest(pending) + removeExactShutdownState(pending) dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") return } let forced = resolvedApplication.forceTerminate() - removeExactRequest(pending) + removeExactShutdownState(pending) dilog( "single_instance", "pid=\(processIdentifier) outcome=\(forced ? "forced" : "force_rejected") reason=user_consent" ) case .exitNewer: - removeExactRequest(pending) + removeExactShutdownState(pending) resolvedApplication?.activate(options: [.activateAllWindows]) AppDelegate.shared?.isSingleInstanceLoserTerminationConfirmed = true dilog("single_instance", "pid=\(processIdentifier) outcome=exiting_newer reason=user_cancel") NSApp.terminate(nil) case .skip: - removeExactRequest(pending) + removeExactShutdownState(pending) dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=post_prompt_revalidation") case .prompt: - removeExactRequest(pending) + removeExactShutdownState(pending) dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=invalid_prompt_state") } } @@ -9679,7 +10002,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser "single_instance", "pid=\(processIdentifier) outcome=\(accepted ? "requested" : "request_rejected") reason=graceful_terminate" ) - return true + return shouldScheduleDuplicateFallback( + requestWasWritten: true, + gracefulTerminationAccepted: accepted + ) }, scheduleGrace: { action in DispatchQueue.main.asyncAfter(deadline: .now() + duplicateTerminationGraceInterval) { @MainActor in @@ -9716,6 +10042,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { continue } + guard Self.isAuthenticatedProgramaApplication( + processIdentifier: app.processIdentifier + ) else { + continue + } guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") @@ -9756,6 +10087,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { return } + guard Self.isAuthenticatedProgramaApplication( + processIdentifier: app.processIdentifier + ) else { + return + } guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index bba105b4..6c5df2c9 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -601,7 +601,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(AppDelegate.prepareDuplicateStateForTesting( rootDirectory: rootDirectory, now: 10_000, - isProcessLive: { $0 == target } + isProcessLive: { $0 == target || $0 == request.requester } )) XCTAssertTrue(FileManager.default.fileExists(atPath: requestURL.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: acknowledgmentURL.path)) @@ -707,7 +707,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { )) } - func testAcceptingOneGenerationAcknowledgesExactTargetForEveryRequester() throws { + func testAcceptingOneGenerationAcknowledgesItsExactTargetAndGeneration() throws { let target = ProgramaSingleInstanceProcessKey( startSeconds: 1_000, startMicroseconds: 100, @@ -734,15 +734,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(acknowledgment.target, target) XCTAssertEqual(acknowledgment.acceptedGeneration, acceptedRequest.generation) - for _ in 0..<2 { - XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( - hasValidTargetAcknowledgment: true, - requestGenerationIsPending: true, - processIdentityMatches: true, - isTerminated: false, - response: nil - ), .skip) - } + XCTAssertTrue(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( + acknowledgment, + expectedRequest: acceptedRequest, + now: 10_002 + )) } func testDelayedDuplicateTargetPromptsInsteadOfForcingAutomatically() { From 9bada4d1de85e32633e704fc494c15ee299db0dd Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 12:33:43 -0300 Subject: [PATCH 5/8] test: cover final single-instance safety gates --- Sources/AppDelegate.swift | 109 +++++++++ .../AppDelegateShortcutRoutingTests.swift | 210 +++++++++++++++--- 2 files changed, 284 insertions(+), 35 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 65d04917..5ad395df 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8954,6 +8954,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let teamIdentifier: String? } + struct SingleInstanceTerminationPersistencePolicy: Equatable, Sendable { + let persistPreTerminationSnapshot: Bool + let persistCleanShutdownSnapshot: Bool + let performProcessLocalTeardown: Bool + } + enum SingleInstanceFallbackAction: Equatable, Sendable { case skip case prompt @@ -9101,6 +9107,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return candidate.teamIdentifier == currentTeamIdentifier } + nonisolated static func singleInstanceTerminationPersistencePolicy( + isDiscardedDuplicate: Bool + ) -> SingleInstanceTerminationPersistencePolicy { + SingleInstanceTerminationPersistencePolicy( + persistPreTerminationSnapshot: true, + persistCleanShutdownSnapshot: true, + performProcessLocalTeardown: true + ) + } + + nonisolated static func shouldTrustDuplicateRunningCode( + expectedProcessKey: ProgramaSingleInstanceProcessKey, + resolvedProcessKeyBeforeValidation: ProgramaSingleInstanceProcessKey?, + resolvedProcessKeyAfterValidation: ProgramaSingleInstanceProcessKey?, + currentIdentity: SingleInstanceCodeIdentity, + candidateIdentity: SingleInstanceCodeIdentity, + dynamicRequirementMatches: Bool, + isDebugBuild: Bool + ) -> Bool { + shouldTrustDuplicateCodeIdentity( + current: currentIdentity, + candidate: candidateIdentity, + designatedRequirementMatches: dynamicRequirementMatches, + isDebugBuild: isDebugBuild + ) + } + nonisolated static func duplicateFallbackAction( hasValidTargetAcknowledgment: Bool, requestGenerationIsPending: Bool, @@ -9417,6 +9450,82 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) } + nonisolated static func shouldTrustDuplicateRunningCodeForTesting( + expectedProcessKey: ProgramaSingleInstanceProcessKey, + resolvedProcessKeyBeforeValidation: ProgramaSingleInstanceProcessKey?, + resolvedProcessKeyAfterValidation: ProgramaSingleInstanceProcessKey?, + currentIdentity: SingleInstanceCodeIdentity, + candidateIdentity: SingleInstanceCodeIdentity, + dynamicRequirementMatches: Bool, + isDebugBuild: Bool + ) -> Bool { + shouldTrustDuplicateRunningCode( + expectedProcessKey: expectedProcessKey, + resolvedProcessKeyBeforeValidation: resolvedProcessKeyBeforeValidation, + resolvedProcessKeyAfterValidation: resolvedProcessKeyAfterValidation, + currentIdentity: currentIdentity, + candidateIdentity: candidateIdentity, + dynamicRequirementMatches: dynamicRequirementMatches, + isDebugBuild: isDebugBuild + ) + } + + nonisolated static func singleInstanceTerminationPersistencePolicyForTesting( + isDiscardedDuplicate: Bool + ) -> SingleInstanceTerminationPersistencePolicy { + singleInstanceTerminationPersistencePolicy(isDiscardedDuplicate: isDiscardedDuplicate) + } + + nonisolated static var duplicateTerminationGraceIntervalForTesting: TimeInterval { + duplicateTerminationGraceInterval + } + + nonisolated static func publishDuplicateAcknowledgmentForTesting( + rootDirectory: URL, + request: SingleInstanceShutdownRequest, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + allowWrite: Bool = true + ) -> Bool { + guard let acknowledgment = acknowledgmentForAcceptedRequestForTesting( + request, + currentProcessKey: currentProcessKey, + now: now + ) else { + return false + } + let url = duplicateShutdownAcknowledgmentURL( + rootDirectory: rootDirectory, + target: currentProcessKey, + generation: request.generation + ) + if allowWrite { + _ = writeBoundedSingleInstanceJSON(acknowledgment, to: url) + } + return true + } + + nonisolated static func hasValidDuplicateAcknowledgmentForTesting( + rootDirectory: URL, + request: SingleInstanceShutdownRequest, + now: TimeInterval + ) -> Bool { + let pending = PendingSingleInstanceShutdown( + request: request, + requestURL: duplicateShutdownRequestURL( + rootDirectory: rootDirectory, + target: request.target, + generation: request.generation + ), + acknowledgmentURL: duplicateShutdownAcknowledgmentURL( + rootDirectory: rootDirectory, + target: request.target, + generation: request.generation + ) + ) + return isExactRequestPending(pending) && hasValidAcknowledgment(for: pending, now: now) + } + nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( _ request: SingleInstanceShutdownRequest?, currentProcessKey: ProgramaSingleInstanceProcessKey, diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 6c5df2c9..301466db 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -386,7 +386,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: secondURL.path)) } - func testThreeConcurrentShutdownGenerationsOwnRequestAndAcknowledgmentFiles() throws { + func testThreeConcurrentShutdownGenerationsOwnRequestFilesAndShareTargetAcknowledgment() throws { let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( "programa-single-instance-three-generations-\(UUID().uuidString)", isDirectory: true @@ -433,20 +433,20 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } XCTAssertEqual(Set(requestURLs).count, 3) - XCTAssertEqual(Set(acknowledgmentURLs).count, 3) + XCTAssertEqual(Set(acknowledgmentURLs).count, 1) XCTAssertTrue(AppDelegate.removeDuplicateStateForTesting( rootDirectory: rootDirectory, request: requests[0] )) XCTAssertFalse(FileManager.default.fileExists(atPath: requestURLs[0].path)) - XCTAssertFalse(FileManager.default.fileExists(atPath: acknowledgmentURLs[0].path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: acknowledgmentURLs[0].path)) for index in 1..<3 { XCTAssertTrue(FileManager.default.fileExists(atPath: requestURLs[index].path)) XCTAssertTrue(FileManager.default.fileExists(atPath: acknowledgmentURLs[index].path)) } } - func testDuplicateAcknowledgmentRequiresExactTargetGenerationAndTiming() { + func testDuplicateAcknowledgmentRequiresExactTargetAndTimingButNotGeneration() { let target = ProgramaSingleInstanceProcessKey( startSeconds: 1_000, startMicroseconds: 100, @@ -491,7 +491,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let future = AppDelegate.SingleInstanceShutdownAcknowledgment( acceptedGeneration: request.generation, target: target, - createdAtUnixSeconds: 10_003 + createdAtUnixSeconds: 10_004 ) XCTAssertTrue(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( @@ -499,7 +499,14 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { expectedRequest: request, now: 10_002 )) - for invalid in [wrongTarget, wrongGeneration, beforeRequest, tooLate, future] { + for accepted in [wrongGeneration, beforeRequest] { + XCTAssertTrue(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( + accepted, + expectedRequest: request, + now: 10_002 + )) + } + for invalid in [wrongTarget, tooLate, future] { XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( invalid, expectedRequest: request, @@ -508,7 +515,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } - func testDuplicateStatePrunesMoreThanOperationalCapWhenEveryEntryIsStale() throws { + func testDuplicateStateRecoversFromMoreThanScanLimitRecognizedStaleEntries() throws { let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( "programa-single-instance-stale-cap-\(UUID().uuidString)", isDirectory: true @@ -521,7 +528,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { startMicroseconds: 100, processIdentifier: 100 ) - for index in 0..<130 { + for index in 0..<520 { let request = AppDelegate.SingleInstanceShutdownRequest( generation: UUID(), target: target, @@ -536,17 +543,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { rootDirectory: rootDirectory, request: request )) - let acknowledgmentURL = AppDelegate.duplicateAcknowledgmentURLForTesting( - rootDirectory: rootDirectory, - target: target, - generation: request.generation - ) - let acknowledgment = AppDelegate.SingleInstanceShutdownAcknowledgment( - acceptedGeneration: request.generation, - target: target, - createdAtUnixSeconds: 9_001 - ) - try JSONEncoder().encode(acknowledgment).write(to: acknowledgmentURL, options: .atomic) } XCTAssertTrue(AppDelegate.prepareDuplicateStateForTesting( @@ -707,37 +703,150 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { )) } - func testAcceptingOneGenerationAcknowledgesItsExactTargetAndGeneration() throws { - let target = ProgramaSingleInstanceProcessKey( + func testDuplicateRequesterRequiresDynamicCodeValidationForUnchangedProcessIdentity() { + let expected = ProgramaSingleInstanceProcessKey( startSeconds: 1_000, startMicroseconds: 100, processIdentifier: 100 ) - let requester = ProgramaSingleInstanceProcessKey( + let replaced = ProgramaSingleInstanceProcessKey( startSeconds: 1_001, startMicroseconds: 0, - processIdentifier: 200 + processIdentifier: 100 + ) + let identity = AppDelegate.SingleInstanceCodeIdentity( + signingIdentifier: "com.darkroom.programa", + teamIdentifier: "DARKROOMTEAM" + ) + + XCTAssertFalse(AppDelegate.shouldTrustDuplicateRunningCodeForTesting( + expectedProcessKey: expected, + resolvedProcessKeyBeforeValidation: expected, + resolvedProcessKeyAfterValidation: replaced, + currentIdentity: identity, + candidateIdentity: identity, + dynamicRequirementMatches: true, + isDebugBuild: false + ), "A PID whose kernel start key changes during validation must fail closed") + XCTAssertFalse(AppDelegate.shouldTrustDuplicateRunningCodeForTesting( + expectedProcessKey: expected, + resolvedProcessKeyBeforeValidation: expected, + resolvedProcessKeyAfterValidation: expected, + currentIdentity: identity, + candidateIdentity: identity, + dynamicRequirementMatches: false, + isDebugBuild: false + ), "Matching static signing metadata must not replace dynamic running-code validation") + } + + func testAcceptingOneGenerationAcknowledgesExactTargetForEveryRequester() throws { + let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-shared-ack-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: rootDirectory) } + + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 ) let acceptedRequest = AppDelegate.SingleInstanceShutdownRequest( generation: UUID(), target: target, - requester: requester, + requester: ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ), createdAtUnixSeconds: 10_000 ) - let acknowledgment = try XCTUnwrap( - AppDelegate.acknowledgmentForAcceptedRequestForTesting( - acceptedRequest, - currentProcessKey: target, - now: 10_001 + _ = try XCTUnwrap(AppDelegate.writeDuplicateRequestForTesting( + rootDirectory: rootDirectory, + request: acceptedRequest + )) + XCTAssertTrue(AppDelegate.publishDuplicateAcknowledgmentForTesting( + rootDirectory: rootDirectory, + request: acceptedRequest, + currentProcessKey: target, + now: 10_001 + )) + + let lateRequests = (0..<2).map { index in + AppDelegate.SingleInstanceShutdownRequest( + generation: UUID(), + target: target, + requester: ProgramaSingleInstanceProcessKey( + startSeconds: 1_002, + startMicroseconds: Int64(index), + processIdentifier: pid_t(300 + index) + ), + createdAtUnixSeconds: 10_002 + ) + } + for request in lateRequests { + _ = try XCTUnwrap(AppDelegate.writeDuplicateRequestForTesting( + rootDirectory: rootDirectory, + request: request + )) + } + + for request in [acceptedRequest] + lateRequests { + XCTAssertTrue( + AppDelegate.hasValidDuplicateAcknowledgmentForTesting( + rootDirectory: rootDirectory, + request: request, + now: 10_003 + ), + "The target's durable responsive state must suppress fallback for existing and later request generations" ) + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: true, + requestGenerationIsPending: true, + processIdentityMatches: true, + isTerminated: false, + response: nil + ), .skip) + } + } + + func testDuplicateTargetFailsClosedWhenAcknowledgmentPublicationFails() throws { + let rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-ack-failure-\(UUID().uuidString)", + isDirectory: true ) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: rootDirectory) } - XCTAssertEqual(acknowledgment.target, target) - XCTAssertEqual(acknowledgment.acceptedGeneration, acceptedRequest.generation) - XCTAssertTrue(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( - acknowledgment, - expectedRequest: acceptedRequest, - now: 10_002 + let target = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let request = AppDelegate.SingleInstanceShutdownRequest( + target: target, + requester: ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ), + createdAtUnixSeconds: 10_000 + ) + + XCTAssertFalse(AppDelegate.publishDuplicateAcknowledgmentForTesting( + rootDirectory: rootDirectory, + request: request, + currentProcessKey: target, + now: 10_001, + allowWrite: false + )) + XCTAssertTrue(AppDelegate.shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: false, + isQuitWarningConfirmed: false, + isInternalSingleInstanceLoserExit: false, + hasValidatedDuplicateShutdownRequest: false, + isQuitWarningEnabled: true )) } @@ -751,6 +860,15 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ), .prompt) } + func testDuplicateFallbackGraceLeavesRoomBeforeRequestExpiry() { + XCTAssertEqual( + AppDelegate.duplicateTerminationGraceIntervalForTesting, + 8, + accuracy: 0.25 + ) + XCTAssertLessThan(AppDelegate.duplicateTerminationGraceIntervalForTesting, 10) + } + func testDuplicateForceRequiresConsentAndCurrentUnacknowledgedGeneration() { XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( hasValidTargetAcknowledgment: false, @@ -804,6 +922,28 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { hasValidatedDuplicateShutdownRequest: false, isQuitWarningEnabled: true )) + + let policy = AppDelegate.singleInstanceTerminationPersistencePolicyForTesting( + isDiscardedDuplicate: true + ) + XCTAssertFalse(policy.persistPreTerminationSnapshot) + XCTAssertFalse(policy.persistCleanShutdownSnapshot) + XCTAssertTrue( + policy.performProcessLocalTeardown, + "Discarding the empty newer process must still run its local teardown" + ) + + XCTAssertEqual( + AppDelegate.singleInstanceTerminationPersistencePolicyForTesting( + isDiscardedDuplicate: false + ), + AppDelegate.SingleInstanceTerminationPersistencePolicy( + persistPreTerminationSnapshot: true, + persistCleanShutdownSnapshot: true, + performProcessLocalTeardown: true + ), + "Ordinary and update-driven termination must keep normal persistence and teardown" + ) } func testDuplicateInstanceCandidateRejectsCurrentProcessAndDifferentBundle() { From 05e565d9ab7216fe0d7fd12af187aba19ec895aa Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 27 Aug 2026 12:41:15 -0300 Subject: [PATCH 6/8] fix: close final single-instance safety gaps --- CHANGELOG.md | 2 +- Sources/AppDelegate.swift | 431 ++++++++++++++++++++------------------ 2 files changed, 232 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d495d2c..5ce48749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. - An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. -- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. Concurrent requests and acknowledgments are generation-owned and cleaned up safely, stale arbitration state cannot exhaust future launches, only an authentically signed Programa copy can request the quit-warning bypass, and force close is a secondary action behind a Cancel-first data-loss warning and final identity check. +- Concurrent Programa launches now use exact process identities and a durable per-target responsive state so overlapping contenders cannot force-close a healthy instance. Requests keep generation-owned cleanup, recognized stale state is pruned in bounded batches, live requesters are authenticated from running code, and an unresponsive instance can only be force-closed through a Cancel-first data-loss warning. - Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5ad395df..8e5919fc 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1539,9 +1539,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let hasValidatedDuplicateShutdownRequest = acknowledgeValidatedDuplicateShutdownRequest() isTerminatingApp = true SessionMachineryGate.isApplicationTerminating = true + let terminationPolicy = Self.singleInstanceTerminationPersistencePolicy( + isDiscardedDuplicate: isSingleInstanceLoserTerminationConfirmed + ) // A warning dialog can still cancel this termination request. The final // `applicationWillTerminate` callback is the only point that records a clean exit. - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + if terminationPolicy.persistPreTerminationSnapshot { + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } let shouldWarn = Self.shouldWarnBeforeTermination( isTaggedDevBuild: SocketControlSettings.isTaggedDevBuild(), @@ -1551,7 +1556,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isQuitWarningEnabled: QuitWarningSettings.isEnabled() ) guard shouldWarn else { - let reason = hasValidatedDuplicateShutdownRequest ? "duplicate_request" : "warning_bypassed" + let reason = hasValidatedDuplicateShutdownRequest + ? "duplicate_request" + : (isSingleInstanceLoserTerminationConfirmed ? "discarded_duplicate" : "warning_bypassed") dilog("single_instance", "pid=\(getpid()) outcome=terminate_now reason=\(reason)") return .terminateNow } @@ -1594,7 +1601,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isAwaitingPowerOffTermination = false isTerminatingApp = true SessionMachineryGate.isApplicationTerminating = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false, cleanShutdown: true) + let terminationPolicy = Self.singleInstanceTerminationPersistencePolicy( + isDiscardedDuplicate: isSingleInstanceLoserTerminationConfirmed + ) + if terminationPolicy.persistCleanShutdownSnapshot { + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false, cleanShutdown: true) + } + guard terminationPolicy.performProcessLocalTeardown else { return } // Finalize any terminal closes still sitting in their undo grace period so a staged close // doesn't quietly leak instead of tearing down cleanly on quit. for context in mainWindowContexts.values { @@ -9003,9 +9016,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private nonisolated static let duplicateShutdownRequestMaxAge: TimeInterval = 10 private nonisolated static let duplicateShutdownRequestMaxBytes = 4_096 - private nonisolated static let duplicateTerminationGraceInterval: TimeInterval = 2 + private nonisolated static let duplicateTerminationGraceInterval: TimeInterval = 8 private nonisolated static let duplicateStateDirectoryMaxEntries = 128 private nonisolated static let duplicateStateDirectoryScanLimit = 512 + private nonisolated static let duplicateStateDirectoryMaxPrunePasses = 8 private nonisolated static let duplicateTargetRequestScanLimit = 32 nonisolated static func shouldAcceptDuplicateShutdownRequest( @@ -9052,20 +9066,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser nonisolated static func shouldAcceptDuplicateShutdownAcknowledgment( _ acknowledgment: SingleInstanceShutdownAcknowledgment?, expectedTarget: ProgramaSingleInstanceProcessKey, - expectedGeneration: UUID, requestCreatedAt: TimeInterval, now: TimeInterval ) -> Bool { guard let acknowledgment, acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, acknowledgment.target == expectedTarget, - acknowledgment.acceptedGeneration == expectedGeneration, acknowledgment.createdAtUnixSeconds.isFinite else { return false } - let requestDistance = acknowledgment.createdAtUnixSeconds - requestCreatedAt + let requestDistance = abs(acknowledgment.createdAtUnixSeconds - requestCreatedAt) return acknowledgment.createdAtUnixSeconds <= now + 1 - && requestDistance >= 0 && requestDistance <= duplicateShutdownRequestMaxAge } @@ -9111,8 +9122,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isDiscardedDuplicate: Bool ) -> SingleInstanceTerminationPersistencePolicy { SingleInstanceTerminationPersistencePolicy( - persistPreTerminationSnapshot: true, - persistCleanShutdownSnapshot: true, + persistPreTerminationSnapshot: !isDiscardedDuplicate, + persistCleanShutdownSnapshot: !isDiscardedDuplicate, performProcessLocalTeardown: true ) } @@ -9126,7 +9137,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser dynamicRequirementMatches: Bool, isDebugBuild: Bool ) -> Bool { - shouldTrustDuplicateCodeIdentity( + guard resolvedProcessKeyBeforeValidation == expectedProcessKey, + resolvedProcessKeyAfterValidation == expectedProcessKey else { + return false + } + return shouldTrustDuplicateCodeIdentity( current: currentIdentity, candidate: candidateIdentity, designatedRequirementMatches: dynamicRequirementMatches, @@ -9183,26 +9198,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return true } - nonisolated private static func staticCodeForCurrentProcess() -> SecStaticCode? { + nonisolated private static func dynamicCodeForCurrentProcess() -> SecCode? { var dynamicCode: SecCode? guard SecCodeCopySelf(SecCSFlags(rawValue: 0), &dynamicCode) == errSecSuccess, let dynamicCode else { return nil } - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode( - dynamicCode, - SecCSFlags(rawValue: 0), - &staticCode - ) == errSecSuccess else { - return nil - } - return staticCode + return dynamicCode } - nonisolated private static func staticCode( + nonisolated private static func dynamicCode( for processIdentifier: pid_t - ) -> SecStaticCode? { + ) -> SecCode? { let attributes = [ kSecGuestAttributePid as String: NSNumber(value: processIdentifier), ] as CFDictionary @@ -9216,15 +9223,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let dynamicCode else { return nil } - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode( - dynamicCode, - SecCSFlags(rawValue: 0), - &staticCode - ) == errSecSuccess else { - return nil - } - return staticCode + return dynamicCode } nonisolated private static func singleInstanceCodeIdentity( @@ -9246,19 +9245,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) } + nonisolated private static func singleInstanceCodeIdentity( + for dynamicCode: SecCode + ) -> SingleInstanceCodeIdentity? { + // Security.framework documents SecCodeCopySigningInformation as valid for dynamic + // SecCode objects, but Swift imports its parameter as SecStaticCode. Both are CF + // code-object references; this bridge preserves the dynamic object rather than + // resolving a mutable on-disk static-code origin. + let signingInformationCode = unsafeBitCast(dynamicCode, to: SecStaticCode.self) + return singleInstanceCodeIdentity(for: signingInformationCode) + } + nonisolated private static func isAuthenticatedProgramaApplication( - processIdentifier: pid_t + expectedProcessKey: ProgramaSingleInstanceProcessKey ) -> Bool { - guard let currentCode = staticCodeForCurrentProcess(), - let candidateCode = staticCode(for: processIdentifier), + let processIdentifier = expectedProcessKey.processIdentifier + let processKeyBeforeValidation = singleInstanceProcessKey(for: processIdentifier) + guard let currentCode = dynamicCodeForCurrentProcess(), + processKeyBeforeValidation == expectedProcessKey, + let candidateCode = dynamicCode(for: processIdentifier), let currentIdentity = singleInstanceCodeIdentity(for: currentCode), let candidateIdentity = singleInstanceCodeIdentity(for: candidateCode) else { dilog("single_instance", "pid=\(processIdentifier) outcome=rejected reason=signing_metadata") return false } + let currentRequirementCode = unsafeBitCast(currentCode, to: SecStaticCode.self) var designatedRequirement: SecRequirement? guard SecCodeCopyDesignatedRequirement( - currentCode, + currentRequirementCode, SecCSFlags(rawValue: 0), &designatedRequirement ) == errSecSuccess, @@ -9266,23 +9280,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser dilog("single_instance", "pid=\(processIdentifier) outcome=rejected reason=signing_requirement") return false } - let validationFlags = SecCSFlags( - rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures - ) - let designatedRequirementMatches = SecStaticCodeCheckValidity( + let validationFlags = SecCSFlags(rawValue: 0) + let designatedRequirementMatches = SecCodeCheckValidity( candidateCode, validationFlags, designatedRequirement ) == errSecSuccess + let processKeyAfterValidation = singleInstanceProcessKey(for: processIdentifier) #if DEBUG let isDebugBuild = true #else let isDebugBuild = false #endif - let trusted = shouldTrustDuplicateCodeIdentity( - current: currentIdentity, - candidate: candidateIdentity, - designatedRequirementMatches: designatedRequirementMatches, + let trusted = shouldTrustDuplicateRunningCode( + expectedProcessKey: expectedProcessKey, + resolvedProcessKeyBeforeValidation: processKeyBeforeValidation, + resolvedProcessKeyAfterValidation: processKeyAfterValidation, + currentIdentity: currentIdentity, + candidateIdentity: candidateIdentity, + dynamicRequirementMatches: designatedRequirementMatches, isDebugBuild: isDebugBuild ) dilog( @@ -9303,6 +9319,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } + nonisolated private static func acknowledgmentForAcceptedRequest( + _ request: SingleInstanceShutdownRequest, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval + ) -> SingleInstanceShutdownAcknowledgment? { + guard request.target == currentProcessKey, + request.version == SingleInstanceShutdownRequest.currentVersion else { + return nil + } + return SingleInstanceShutdownAcknowledgment( + acceptedGeneration: request.generation, + target: currentProcessKey, + createdAtUnixSeconds: now + ) + } + #if DEBUG nonisolated static func duplicateRequestURLForTesting( rootDirectory: URL, @@ -9321,14 +9353,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser currentProcessKey: ProgramaSingleInstanceProcessKey, now: TimeInterval ) -> SingleInstanceShutdownAcknowledgment? { - guard request.target == currentProcessKey, - request.version == SingleInstanceShutdownRequest.currentVersion else { - return nil - } - return SingleInstanceShutdownAcknowledgment( - acceptedGeneration: request.generation, - target: currentProcessKey, - createdAtUnixSeconds: now + acknowledgmentForAcceptedRequest( + request, + currentProcessKey: currentProcessKey, + now: now ) } @@ -9353,10 +9381,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser target: ProgramaSingleInstanceProcessKey, generation: UUID ) -> URL { - duplicateShutdownAcknowledgmentURL( + _ = generation + return duplicateShutdownAcknowledgmentURL( rootDirectory: rootDirectory, - target: target, - generation: generation + target: target ) } @@ -9385,8 +9413,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ), acknowledgmentURL: duplicateShutdownAcknowledgmentURL( rootDirectory: rootDirectory, - target: request.target, - generation: request.generation + target: request.target ) ) let existed = isExactRequestPending(pending) @@ -9414,7 +9441,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser shouldAcceptDuplicateShutdownAcknowledgment( acknowledgment, expectedTarget: expectedRequest.target, - expectedGeneration: expectedRequest.generation, requestCreatedAt: expectedRequest.createdAtUnixSeconds, now: now ) @@ -9487,22 +9513,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser now: TimeInterval, allowWrite: Bool = true ) -> Bool { - guard let acknowledgment = acknowledgmentForAcceptedRequestForTesting( - request, - currentProcessKey: currentProcessKey, - now: now - ) else { - return false - } - let url = duplicateShutdownAcknowledgmentURL( + publishDuplicateAcknowledgment( rootDirectory: rootDirectory, - target: currentProcessKey, - generation: request.generation + request: request, + currentProcessKey: currentProcessKey, + now: now, + write: { acknowledgment, url in + allowWrite && writeBoundedSingleInstanceJSON(acknowledgment, to: url) + } ) - if allowWrite { - _ = writeBoundedSingleInstanceJSON(acknowledgment, to: url) - } - return true } nonisolated static func hasValidDuplicateAcknowledgmentForTesting( @@ -9519,8 +9538,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ), acknowledgmentURL: duplicateShutdownAcknowledgmentURL( rootDirectory: rootDirectory, - target: request.target, - generation: request.generation + target: request.target ) ) return isExactRequestPending(pending) && hasValidAcknowledgment(for: pending, now: now) @@ -9606,15 +9624,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser nonisolated private static func duplicateShutdownAcknowledgmentURL( rootDirectory: URL, - target: ProgramaSingleInstanceProcessKey, - generation: UUID + target: ProgramaSingleInstanceProcessKey ) -> URL { rootDirectory.appendingPathComponent( - "ack-\(singleInstanceTargetComponent(target))-\(generation.uuidString.lowercased()).json", + "ack-\(singleInstanceTargetComponent(target)).json", isDirectory: false ) } + nonisolated private static func publishDuplicateAcknowledgment( + rootDirectory: URL, + request: SingleInstanceShutdownRequest, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + write: (SingleInstanceShutdownAcknowledgment, URL) -> Bool + ) -> Bool { + guard let acknowledgment = acknowledgmentForAcceptedRequest( + request, + currentProcessKey: currentProcessKey, + now: now + ) else { + return false + } + let url = duplicateShutdownAcknowledgmentURL( + rootDirectory: rootDirectory, + target: currentProcessKey + ) + return write(acknowledgment, url) + } + nonisolated private static func validatedSingleInstanceStateDirectory() -> URL? { let fileManager = FileManager.default let directoryURL = singleInstanceStateDirectoryURL() @@ -9710,7 +9748,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser @discardableResult nonisolated private static func removeExactAcknowledgment( target: ProgramaSingleInstanceProcessKey, - generation: UUID, url: URL ) -> Bool { guard let acknowledgment = readBoundedSingleInstanceJSON( @@ -9720,8 +9757,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return !FileManager.default.fileExists(atPath: url.path) } guard acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, - acknowledgment.target == target, - acknowledgment.acceptedGeneration == generation else { + acknowledgment.target == target else { return false } do { @@ -9736,11 +9772,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser _ pending: PendingSingleInstanceShutdown ) { _ = removeExactRequest(pending) - _ = removeExactAcknowledgment( - target: pending.request.target, - generation: pending.request.generation, - url: pending.acknowledgmentURL - ) } nonisolated private static func preparedSingleInstanceStateURLs( @@ -9748,101 +9779,108 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser now: TimeInterval, isProcessLive: (ProgramaSingleInstanceProcessKey) -> Bool ) -> [URL]? { - guard let enumerator = FileManager.default.enumerator( - at: directoryURL, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] - ) else { - return nil - } - - var requests: [(URL, SingleInstanceShutdownRequest)] = [] - var acknowledgments: [(URL, SingleInstanceShutdownAcknowledgment)] = [] - var scannedCount = 0 - for case let url as URL in enumerator { - scannedCount += 1 - guard scannedCount <= duplicateStateDirectoryScanLimit else { return nil } - - if url.lastPathComponent.hasPrefix("request-"), - let request = readBoundedSingleInstanceJSON( - SingleInstanceShutdownRequest.self, - from: url - ), - duplicateShutdownRequestURL( - rootDirectory: directoryURL, - target: request.target, - generation: request.generation - ).standardizedFileURL == url.standardizedFileURL { - let age = now - request.createdAtUnixSeconds - guard request.version == SingleInstanceShutdownRequest.currentVersion, - request.createdAtUnixSeconds.isFinite, - age >= 0 else { return nil } - requests.append((url, request)) - continue + for _ in 0..= 0 else { return nil } - acknowledgments.append((url, acknowledgment)) - continue - } + var requests: [(URL, SingleInstanceShutdownRequest)] = [] + var acknowledgments: [(URL, SingleInstanceShutdownAcknowledgment)] = [] + var scannedCount = 0 + var exceededScanLimit = false + for case let url as URL in enumerator { + guard scannedCount < duplicateStateDirectoryScanLimit else { + exceededScanLimit = true + break + } + scannedCount += 1 + + if url.lastPathComponent.hasPrefix("request-"), + let request = readBoundedSingleInstanceJSON( + SingleInstanceShutdownRequest.self, + from: url + ), + duplicateShutdownRequestURL( + rootDirectory: directoryURL, + target: request.target, + generation: request.generation + ).standardizedFileURL == url.standardizedFileURL { + let age = now - request.createdAtUnixSeconds + guard request.version == SingleInstanceShutdownRequest.currentVersion, + request.createdAtUnixSeconds.isFinite, + age >= 0 else { return nil } + requests.append((url, request)) + continue + } - return nil - } + if url.lastPathComponent.hasPrefix("ack-"), + let acknowledgment = readBoundedSingleInstanceJSON( + SingleInstanceShutdownAcknowledgment.self, + from: url + ), + duplicateShutdownAcknowledgmentURL( + rootDirectory: directoryURL, + target: acknowledgment.target + ).standardizedFileURL == url.standardizedFileURL { + let age = now - acknowledgment.createdAtUnixSeconds + guard acknowledgment.version == SingleInstanceShutdownAcknowledgment.currentVersion, + acknowledgment.createdAtUnixSeconds.isFinite, + age >= 0 else { return nil } + acknowledgments.append((url, acknowledgment)) + continue + } + + return nil + } - var retainedURLs: [URL] = [] - var liveRequestKeys: Set = [] - for (url, request) in requests { - let age = now - request.createdAtUnixSeconds - let isStale = age > duplicateShutdownRequestMaxAge - && (!isProcessLive(request.target) || !isProcessLive(request.requester)) - if isStale { - let pending = PendingSingleInstanceShutdown( - request: request, - requestURL: url, - acknowledgmentURL: duplicateShutdownAcknowledgmentURL( - rootDirectory: directoryURL, - target: request.target, - generation: request.generation + var retainedURLs: [URL] = [] + var removedCount = 0 + for (url, request) in requests { + let age = now - request.createdAtUnixSeconds + let isStale = age > duplicateShutdownRequestMaxAge + && (!isProcessLive(request.target) || !isProcessLive(request.requester)) + if isStale { + let pending = PendingSingleInstanceShutdown( + request: request, + requestURL: url, + acknowledgmentURL: duplicateShutdownAcknowledgmentURL( + rootDirectory: directoryURL, + target: request.target + ) ) - ) - guard removeExactRequest(pending) else { return nil } - } else { - retainedURLs.append(url) - liveRequestKeys.insert( - "\(singleInstanceTargetComponent(request.target))-\(request.generation.uuidString.lowercased())" - ) + guard removeExactRequest(pending) else { return nil } + removedCount += 1 + } else { + retainedURLs.append(url) + } } - } - for (url, acknowledgment) in acknowledgments { - let requestKey = "\(singleInstanceTargetComponent(acknowledgment.target))-\(acknowledgment.acceptedGeneration.uuidString.lowercased())" - let age = now - acknowledgment.createdAtUnixSeconds - let isStale = age > duplicateShutdownRequestMaxAge - && (!isProcessLive(acknowledgment.target) || !liveRequestKeys.contains(requestKey)) - if isStale { - guard removeExactAcknowledgment( - target: acknowledgment.target, - generation: acknowledgment.acceptedGeneration, - url: url - ) else { return nil } - } else { - retainedURLs.append(url) + for (url, acknowledgment) in acknowledgments { + let age = now - acknowledgment.createdAtUnixSeconds + let isStale = age > duplicateShutdownRequestMaxAge + && !isProcessLive(acknowledgment.target) + if isStale { + guard removeExactAcknowledgment( + target: acknowledgment.target, + url: url + ) else { return nil } + removedCount += 1 + } else { + retainedURLs.append(url) + } + } + + if exceededScanLimit { + guard removedCount > 0 else { return nil } + continue } + guard retainedURLs.count <= duplicateStateDirectoryMaxEntries else { return nil } + return retainedURLs } - guard retainedURLs.count <= duplicateStateDirectoryMaxEntries else { return nil } - return retainedURLs + return nil } nonisolated private static func hasValidAcknowledgment( @@ -9856,7 +9894,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return shouldAcceptDuplicateShutdownAcknowledgment( acknowledgment, expectedTarget: pending.request.target, - expectedGeneration: pending.request.generation, requestCreatedAt: pending.request.createdAtUnixSeconds, now: now ) @@ -9889,8 +9926,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ), acknowledgmentURL: duplicateShutdownAcknowledgmentURL( rootDirectory: directoryURL, - target: target, - generation: request.generation + target: target ) ) guard writeBoundedSingleInstanceJSON(request, to: pending.requestURL) else { @@ -9926,7 +9962,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=request_limit") return false } - var acceptedAnyRequest = false for requestURL in targetRequestURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { guard let request = Self.readBoundedSingleInstanceJSON( SingleInstanceShutdownRequest.self, @@ -9947,7 +9982,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser embeddedCLIURL: embeddedCLIURL ) && Self.isAuthenticatedProgramaApplication( - processIdentifier: application.processIdentifier + expectedProcessKey: request.requester ) } ?? false guard Self.shouldAcceptDuplicateShutdownRequest( @@ -9961,25 +9996,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { continue } - acceptedAnyRequest = true - - let acknowledgment = SingleInstanceShutdownAcknowledgment( - acceptedGeneration: request.generation, - target: currentKey, - createdAtUnixSeconds: Date().timeIntervalSince1970 - ) - let acknowledgmentURL = Self.duplicateShutdownAcknowledgmentURL( + let published = Self.publishDuplicateAcknowledgment( rootDirectory: directoryURL, - target: currentKey, - generation: request.generation + request: request, + currentProcessKey: currentKey, + now: Date().timeIntervalSince1970, + write: { acknowledgment, url in + Self.writeBoundedSingleInstanceJSON(acknowledgment, to: url) + } ) - if !Self.writeBoundedSingleInstanceJSON(acknowledgment, to: acknowledgmentURL) { + if !published { dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=ack_write") continue } dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=accepted reason=shutdown_request") + return true } - if acceptedAnyRequest { return true } dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=missing reason=shutdown_request") return false } @@ -10151,13 +10183,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { continue } - guard Self.isAuthenticatedProgramaApplication( - processIdentifier: app.processIdentifier - ) else { + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier) else { + continue + } + guard Self.isAuthenticatedProgramaApplication(expectedProcessKey: otherKey) else { continue } - guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), - Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + guard Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") continue } @@ -10196,14 +10228,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { return } - guard Self.isAuthenticatedProgramaApplication( - processIdentifier: app.processIdentifier - ) else { + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier) else { return } - - guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), - Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + guard Self.isAuthenticatedProgramaApplication(expectedProcessKey: otherKey) else { + return + } + guard Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") return } From cf914a07da924a8328fb79407539ea54cdf9b288 Mon Sep 17 00:00:00 2001 From: lsoengas Date: Tue, 11 Aug 2026 11:30:46 -0300 Subject: [PATCH 7/8] feat: load unpacked web extensions in the built-in browser (proof of concept) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser can now run Chrome-style Web Extensions (manifest v2/v3) through WebKit's WKWebExtension API (macOS 15.4+; on older macOS the browser simply has no extensions). Extensions are read from ~/.config/programa/extensions/ — one unpacked directory or .zip per extension — and their content scripts inject into main panels and popups alike via the shared configuration path. Deliberately narrow scope for this slice: - Every requested permission and host pattern is granted up front; the extensions directory is trusted input until a consent UI exists. Grant expiration dates must be distantFuture — the dictionary values are expirations, and a "now" timestamp silently expires the grant before first use. - Extension storage persists under a fixed controller identifier. - No toolbar/popup/options UI and no WKWebExtensionTab/Window adapters yet, so extension APIs that enumerate tabs see none. Those are the next slices toward running 1Password. Verified: a manifest v3 test extension's content script injects into https pages in both a browser panel and a window.open popup. (cherry picked from commit ead7d4f3b937a8e81d6588c0c98ed7da2649d3b6) --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/Panels/BrowserExtensionManager.swift | 107 +++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 7 ++ 3 files changed, 118 insertions(+) create mode 100644 Sources/Panels/BrowserExtensionManager.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5c9468f9..d81205bb 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -150,6 +150,7 @@ NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0012 /* BrowserPanelWebDelegates.swift */; }; NRBR0013 /* BrowserUserProxySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0014 /* BrowserUserProxySettings.swift */; }; NRBR0015 /* IMECompositionMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0016 /* IMECompositionMessageHandler.swift */; }; + NRBR0019 /* BrowserExtensionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0020 /* BrowserExtensionManager.swift */; }; NRBR0017 /* WebViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0018 /* WebViewRepresentable.swift */; }; A5FF0013 /* BrowserDataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0003 /* BrowserDataImport.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; @@ -578,6 +579,7 @@ NRBR0012 /* BrowserPanelWebDelegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelWebDelegates.swift; sourceTree = ""; }; NRBR0014 /* BrowserUserProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserUserProxySettings.swift; sourceTree = ""; }; NRBR0016 /* IMECompositionMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/IMECompositionMessageHandler.swift; sourceTree = ""; }; + NRBR0020 /* BrowserExtensionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserExtensionManager.swift; sourceTree = ""; }; NRBR0018 /* WebViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebViewRepresentable.swift; sourceTree = ""; }; A5FF0003 /* BrowserDataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDataImport.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; @@ -1089,6 +1091,7 @@ NRBR0012 /* BrowserPanelWebDelegates.swift */, NRBR0014 /* BrowserUserProxySettings.swift */, NRBR0016 /* IMECompositionMessageHandler.swift */, + NRBR0020 /* BrowserExtensionManager.swift */, NRBR0018 /* WebViewRepresentable.swift */, A5FF0003 /* BrowserDataImport.swift */, A500RG00 /* ReactGrab.swift */, @@ -1653,6 +1656,7 @@ NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */, NRBR0013 /* BrowserUserProxySettings.swift in Sources */, NRBR0015 /* IMECompositionMessageHandler.swift in Sources */, + NRBR0019 /* BrowserExtensionManager.swift in Sources */, NRBR0017 /* WebViewRepresentable.swift in Sources */, A5FF0013 /* BrowserDataImport.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, diff --git a/Sources/Panels/BrowserExtensionManager.swift b/Sources/Panels/BrowserExtensionManager.swift new file mode 100644 index 00000000..cdfa8251 --- /dev/null +++ b/Sources/Panels/BrowserExtensionManager.swift @@ -0,0 +1,107 @@ +import AppKit +import Bonsplit +import Foundation +import WebKit + +/// Loads unpacked Web Extensions (Chrome-style, manifest v2/v3) into the built-in browser +/// through WebKit's `WKWebExtension` API (macOS 15.4+; the app still runs on 14.0, where +/// this whole type is unavailable and the browser simply has no extensions). +/// +/// Proof-of-concept scope, deliberately narrow: +/// - Extensions are read from `~/.config/programa/extensions/`, one subdirectory (containing +/// `manifest.json`) or `.zip` archive per extension. No store, no install flow. +/// - Every permission and host match pattern the manifest requests is granted up front — +/// there is no consent UI yet, so this directory is trusted input by definition. +/// - No toolbar button, popup, or options UI, and no `WKWebExtensionTab`/`Window` adapters +/// yet: content scripts and background service workers run, but extension APIs that +/// enumerate tabs or windows see none. +@available(macOS 15.4, *) +@MainActor +final class BrowserExtensionManager { + static let shared = BrowserExtensionManager() + + /// Fixed so extension storage (`chrome.storage`, IndexedDB, service-worker state) + /// lands in the same WebKit container every launch. Changing it orphans that data. + private static let controllerIdentifier = UUID(uuidString: "8B1F4F62-3A6C-4E5D-9D5B-2F0C7A9E41D3")! + + static var extensionsDirectoryURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/programa/extensions", isDirectory: true) + } + + let controller: WKWebExtensionController + + private(set) var loadedExtensions: [WKWebExtension] = [] + private(set) var loadErrors: [(candidate: String, error: any Error)] = [] + private var didStartLoading = false + + private init() { + controller = WKWebExtensionController( + configuration: WKWebExtensionController.Configuration(identifier: Self.controllerIdentifier) + ) + } + + /// Idempotent kickoff; called from `BrowserPanel.configureWebViewConfiguration`, so the + /// first browser panel of the session triggers the scan. Loading is async, but contexts + /// loaded into the controller inject into already-attached web views, so a panel created + /// before loading finishes still gets its content scripts. + func loadInstalledExtensionsIfNeeded() { + guard !didStartLoading else { return } + didStartLoading = true + Task { await self.loadInstalledExtensions() } + } + + private func loadInstalledExtensions() async { + let directory = Self.extensionsDirectoryURL + let entries = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + )) ?? [] + + let candidates = entries.filter { url in + if (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { return true } + return url.pathExtension.lowercased() == "zip" + } + + guard !candidates.isEmpty else { + #if DEBUG + dlog("browser.extensions.scan dir=\(directory.path) none-found") + #endif + return + } + + for candidate in candidates.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + do { + let webExtension = try await WKWebExtension(resourceBaseURL: candidate) + let context = WKWebExtensionContext(for: webExtension) + grantAllRequestedPermissions(of: webExtension, to: context) + try controller.load(context) + loadedExtensions.append(webExtension) + #if DEBUG + dlog("browser.extensions.loaded name=\(webExtension.displayName ?? candidate.lastPathComponent) version=\(webExtension.displayVersion ?? "?") mv=\(Int(webExtension.manifestVersion)) background=\(webExtension.hasBackgroundContent) injected=\(webExtension.hasInjectedContent)") + dlog("browser.extensions.access requestedPatterns=\(webExtension.allRequestedMatchPatterns.count) grantedPatterns=\(context.currentPermissionMatchPatterns.count) allURLs=\(context.hasAccessToAllURLs) example=\(context.hasAccess(to: URL(string: "https://example.com/")!)) errors=\(webExtension.errors.count)") + #endif + } catch { + loadErrors.append((candidate: candidate.lastPathComponent, error: error)) + #if DEBUG + dlog("browser.extensions.loadFailed candidate=\(candidate.lastPathComponent) error=\(error.localizedDescription)") + #endif + } + } + } + + /// PoC-only trust model: everything the manifest asks for is granted. A consent UI + /// replaces this before extensions are exposed to users. + private func grantAllRequestedPermissions(of webExtension: WKWebExtension, to context: WKWebExtensionContext) { + // The dictionary values are EXPIRATION dates ("now" would expire immediately and + // the grant silently vanishes); distantFuture means never expires, per the header. + let never = Date.distantFuture + context.grantedPermissions = Dictionary( + uniqueKeysWithValues: webExtension.requestedPermissions.map { ($0, never) } + ) + context.grantedPermissionMatchPatterns = Dictionary( + uniqueKeysWithValues: webExtension.allRequestedMatchPatterns.map { ($0, never) } + ) + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index fdbb579f..dfb47c56 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -971,6 +971,13 @@ final class BrowserPanel: Panel, ObservableObject { // This reduces repeated consent/bot-challenge flows on sites like Google. configuration.websiteDataStore = websiteDataStore + // Web extension support (proof of concept — see BrowserExtensionManager). Popups + // inherit this through the same shared-configuration path as everything else here. + if #available(macOS 15.4, *) { + configuration.webExtensionController = BrowserExtensionManager.shared.controller + BrowserExtensionManager.shared.loadInstalledExtensionsIfNeeded() + } + // Enable developer extras (DevTools) configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") configuration.preferences.isElementFullscreenEnabled = true From 84b350cef8a102b98501f1d7739b782021f19a05 Mon Sep 17 00:00:00 2001 From: lsoengas Date: Tue, 11 Aug 2026 12:33:24 -0300 Subject: [PATCH 8/8] feat: expose browser panels to web extensions as tabs and windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extensions inject dynamically through tabs.query + scripting.executeScript (1Password's autofill works exactly this way), and both APIs resolve against WKWebExtensionTab/Window adapters. Without them an extension sees a browser with zero tabs and can inject into nothing. - BrowserExtensionTabAdapter wraps a BrowserPanel; a single BrowserExtensionWindowAdapter presents the app as one flat window whose tabs are all live browser panels across workspaces. Popup windows are not represented yet (their content scripts still run). - Lifecycle: register on bindWebView, unregister in BrowserPanel.close, activate via the applyTabSelection funnel (didFocusPane lands there too, so one hook covers pane focus and tab selection). - The controller delegate's openWindowsFor answer is what initially populates each context's openWindows/openTabs — without a delegate the world starts empty and tabs.query returns [] forever, regardless of didOpenTab notifications. Cost a debug cycle; documented. - Both WebKit protocols are fully @optional and a near-miss Swift signature is silently ignored; the "nearly matches" compiler warning and the tabs-poc runtime probe (PR #284) are the safety nets. The warning already caught one (indexInWindow wants Int, not UInt). Verified: a manifest v3 test extension's background worker enumerates all tabs and stamps every page via scripting.executeScript. 1Password still needs its action popup UI (extension sign-in) before it decorates fields — next slice. (cherry picked from commit 94f9f58e23e9ac609d8e775671c3972fbe8ee68d) --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/Panels/BrowserExtensionAdapters.swift | 119 ++++++++++++++++++ Sources/Panels/BrowserExtensionManager.swift | 67 ++++++++++ Sources/Panels/BrowserPanel.swift | 7 ++ Sources/Workspace+Bonsplit.swift | 7 ++ 5 files changed, 204 insertions(+) create mode 100644 Sources/Panels/BrowserExtensionAdapters.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d81205bb..63c9a495 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ NRBR0013 /* BrowserUserProxySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0014 /* BrowserUserProxySettings.swift */; }; NRBR0015 /* IMECompositionMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0016 /* IMECompositionMessageHandler.swift */; }; NRBR0019 /* BrowserExtensionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0020 /* BrowserExtensionManager.swift */; }; + NRBR0021 /* BrowserExtensionAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0022 /* BrowserExtensionAdapters.swift */; }; NRBR0017 /* WebViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0018 /* WebViewRepresentable.swift */; }; A5FF0013 /* BrowserDataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0003 /* BrowserDataImport.swift */; }; A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; @@ -580,6 +581,7 @@ NRBR0014 /* BrowserUserProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserUserProxySettings.swift; sourceTree = ""; }; NRBR0016 /* IMECompositionMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/IMECompositionMessageHandler.swift; sourceTree = ""; }; NRBR0020 /* BrowserExtensionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserExtensionManager.swift; sourceTree = ""; }; + NRBR0022 /* BrowserExtensionAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserExtensionAdapters.swift; sourceTree = ""; }; NRBR0018 /* WebViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebViewRepresentable.swift; sourceTree = ""; }; A5FF0003 /* BrowserDataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDataImport.swift; sourceTree = ""; }; A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = ""; }; @@ -1092,6 +1094,7 @@ NRBR0014 /* BrowserUserProxySettings.swift */, NRBR0016 /* IMECompositionMessageHandler.swift */, NRBR0020 /* BrowserExtensionManager.swift */, + NRBR0022 /* BrowserExtensionAdapters.swift */, NRBR0018 /* WebViewRepresentable.swift */, A5FF0003 /* BrowserDataImport.swift */, A500RG00 /* ReactGrab.swift */, @@ -1657,6 +1660,7 @@ NRBR0013 /* BrowserUserProxySettings.swift in Sources */, NRBR0015 /* IMECompositionMessageHandler.swift in Sources */, NRBR0019 /* BrowserExtensionManager.swift in Sources */, + NRBR0021 /* BrowserExtensionAdapters.swift in Sources */, NRBR0017 /* WebViewRepresentable.swift in Sources */, A5FF0013 /* BrowserDataImport.swift in Sources */, A500RG01 /* ReactGrab.swift in Sources */, diff --git a/Sources/Panels/BrowserExtensionAdapters.swift b/Sources/Panels/BrowserExtensionAdapters.swift new file mode 100644 index 00000000..de8b7773 --- /dev/null +++ b/Sources/Panels/BrowserExtensionAdapters.swift @@ -0,0 +1,119 @@ +import AppKit +import Bonsplit +import Foundation +import WebKit + +/// Adapters that expose Programa's browser panels to web extensions as windows and tabs. +/// +/// Extensions drive dynamic script injection through `tabs.query` + `scripting.executeScript` +/// (1Password's autofill works exactly this way), and both APIs resolve against the objects +/// registered here. Without these adapters an extension sees a browser with zero tabs and +/// can inject nothing. +/// +/// Mapping (deliberately simple for this slice): +/// - One `WKWebExtensionWindow` representing the app — every live `BrowserPanel` across all +/// workspaces is a tab of it. Programa's real model (workspaces → panes → tabs mixing +/// terminals and browsers) has no clean analog in the extension world; a single flat +/// window is honest enough for tab targeting, which is what injection needs. +/// - The "active" tab is the browser panel the user most recently selected/focused. +/// - Popup windows (`BrowserPopupWindowController`) are not represented yet; their content +/// scripts still run (shared configuration), but tab-targeted APIs skip them. +/// +/// Both WebKit protocols are fully `@optional`. CAUTION: an implementation whose Swift +/// signature does not exactly match the protocol's is silently ignored — no compiler error, +/// the method just never gets called. Watch for "nearly matches optional requirement" +/// warnings when touching this file, and re-run the tabs-poc probe (see PR #284) after. +@available(macOS 15.4, *) +@MainActor +final class BrowserExtensionTabAdapter: NSObject, WKWebExtensionTab { + private(set) weak var panel: BrowserPanel? + + init(panel: BrowserPanel) { + self.panel = panel + super.init() + } + + nonisolated func webView(for context: WKWebExtensionContext) -> WKWebView? { + MainActor.assumeIsolated { panel?.webView } + } + + nonisolated func url(for context: WKWebExtensionContext) -> URL? { + MainActor.assumeIsolated { panel?.webView.url } + } + + nonisolated func title(for context: WKWebExtensionContext) -> String? { + MainActor.assumeIsolated { panel?.webView.title } + } + + nonisolated func isLoadingComplete(for context: WKWebExtensionContext) -> Bool { + MainActor.assumeIsolated { !(panel?.webView.isLoading ?? true) } + } + + nonisolated func window(for context: WKWebExtensionContext) -> (any WKWebExtensionWindow)? { + MainActor.assumeIsolated { BrowserExtensionManager.shared.windowAdapter } + } + + nonisolated func indexInWindow(for context: WKWebExtensionContext) -> Int { + MainActor.assumeIsolated { + BrowserExtensionManager.shared.tabAdapters.firstIndex(where: { $0 === self }) ?? 0 + } + } + + nonisolated func isSelected(for context: WKWebExtensionContext) -> Bool { + MainActor.assumeIsolated { BrowserExtensionManager.shared.activeTabAdapter === self } + } +} + +/// The single app-level window presented to extensions; tabs are all live browser panels. +@available(macOS 15.4, *) +@MainActor +final class BrowserExtensionWindowAdapter: NSObject, WKWebExtensionWindow { + + nonisolated func tabs(for context: WKWebExtensionContext) -> [any WKWebExtensionTab] { + MainActor.assumeIsolated { BrowserExtensionManager.shared.tabAdapters } + } + + nonisolated func activeTab(for context: WKWebExtensionContext) -> (any WKWebExtensionTab)? { + MainActor.assumeIsolated { BrowserExtensionManager.shared.activeTabAdapter } + } + + nonisolated func windowType(for context: WKWebExtensionContext) -> WKWebExtension.WindowType { + .normal + } + + nonisolated func windowState(for context: WKWebExtensionContext) -> WKWebExtension.WindowState { + .normal + } + + nonisolated func isPrivate(for context: WKWebExtensionContext) -> Bool { + false + } + + nonisolated func frame(for context: WKWebExtensionContext) -> CGRect { + MainActor.assumeIsolated { NSApp.mainWindow?.frame ?? .zero } + } + + nonisolated func screenFrame(for context: WKWebExtensionContext) -> CGRect { + MainActor.assumeIsolated { NSApp.mainWindow?.screen?.frame ?? NSScreen.main?.frame ?? .zero } + } +} + +/// Answers the controller's world-state queries. The delegate's `openWindowsFor` reply is +/// what initially populates each extension context's `openWindows`/`openTabs` — the +/// incremental `didOpenTab`/`didActivateTab` notifications only build on that baseline. +@available(macOS 15.4, *) +final class BrowserExtensionControllerDelegate: NSObject, WKWebExtensionControllerDelegate { + func webExtensionController( + _ controller: WKWebExtensionController, + openWindowsFor extensionContext: WKWebExtensionContext + ) -> [any WKWebExtensionWindow] { + MainActor.assumeIsolated { [BrowserExtensionManager.shared.windowAdapter] } + } + + func webExtensionController( + _ controller: WKWebExtensionController, + focusedWindowFor extensionContext: WKWebExtensionContext + ) -> (any WKWebExtensionWindow)? { + MainActor.assumeIsolated { BrowserExtensionManager.shared.windowAdapter } + } +} diff --git a/Sources/Panels/BrowserExtensionManager.swift b/Sources/Panels/BrowserExtensionManager.swift index cdfa8251..1a7af54a 100644 --- a/Sources/Panels/BrowserExtensionManager.swift +++ b/Sources/Panels/BrowserExtensionManager.swift @@ -35,10 +35,77 @@ final class BrowserExtensionManager { private(set) var loadErrors: [(candidate: String, error: any Error)] = [] private var didStartLoading = false + // MARK: Tab/window registry (see BrowserExtensionAdapters.swift for the mapping) + + let windowAdapter = BrowserExtensionWindowAdapter() + private(set) var tabAdapters: [BrowserExtensionTabAdapter] = [] + private(set) var activeTabAdapter: BrowserExtensionTabAdapter? + private var announcedWindow = false + + private let controllerDelegate = BrowserExtensionControllerDelegate() + private init() { controller = WKWebExtensionController( configuration: WKWebExtensionController.Configuration(identifier: Self.controllerIdentifier) ) + // Without a delegate, each context's openWindows/openTabs start EMPTY — the + // delegate's openWindowsFor answer is the initial population, and the + // didOpenTab/didActivateTab notifications only update from that baseline. + // tabs.query returns [] forever otherwise. + controller.delegate = controllerDelegate + } + + func registerTab(for panel: BrowserPanel) { + guard tabAdapter(for: panel) == nil else { return } + if !announcedWindow { + announcedWindow = true + controller.didOpenWindow(windowAdapter) + controller.didFocusWindow(windowAdapter) + } + let adapter = BrowserExtensionTabAdapter(panel: panel) + tabAdapters.append(adapter) + controller.didOpenTab(adapter) + #if DEBUG + dlog("browser.extensions.tab.open panel=\(panel.id.uuidString.prefix(5)) total=\(tabAdapters.count)") + #endif + } + + func unregisterTab(for panel: BrowserPanel) { + guard let index = tabAdapters.firstIndex(where: { $0.panel === panel }) else { return } + let adapter = tabAdapters.remove(at: index) + if activeTabAdapter === adapter { activeTabAdapter = nil } + controller.didCloseTab(adapter, windowIsClosing: false) + #if DEBUG + dlog("browser.extensions.tab.close panel=\(panel.id.uuidString.prefix(5)) total=\(tabAdapters.count)") + #endif + } + + func noteTabActivated(_ panel: BrowserPanel) { + pruneDeadTabs() + // Also handles never-registered panels defensively; activation implies existence. + registerTab(for: panel) + guard let adapter = tabAdapter(for: panel) else { return } + guard activeTabAdapter !== adapter else { return } + let previous = activeTabAdapter + activeTabAdapter = adapter + controller.didActivateTab(adapter, previousActiveTab: previous) + controller.didSelectTabs([adapter]) + #if DEBUG + dlog("browser.extensions.tab.activate panel=\(panel.id.uuidString.prefix(5))") + #endif + } + + private func tabAdapter(for panel: BrowserPanel) -> BrowserExtensionTabAdapter? { + tabAdapters.first(where: { $0.panel === panel }) + } + + /// Drops adapters whose panel has been deallocated without an explicit unregister. + func pruneDeadTabs() { + for adapter in tabAdapters where adapter.panel == nil { + tabAdapters.removeAll { $0 === adapter } + if activeTabAdapter === adapter { activeTabAdapter = nil } + controller.didCloseTab(adapter, windowIsClosing: false) + } } /// Idempotent kickoff; called from `BrowserPanel.configureWebViewConfiguration`, so the diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dfb47c56..9fa34887 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1047,6 +1047,9 @@ final class BrowserPanel: Panel, ObservableObject { setupDesignModeMessageHandler(for: webView) setupIMECompositionTracking(for: webView) setupPasskeyHandoffTracking(for: webView) + if #available(macOS 15.4, *) { + BrowserExtensionManager.shared.registerTab(for: self) + } } private func configureNavigationDelegateCallbacks() { @@ -1848,6 +1851,10 @@ final class BrowserPanel: Panel, ObservableObject { unfocus() invalidateBrowserStateRestore(with: .cancelled) + if #available(macOS 15.4, *) { + BrowserExtensionManager.shared.unregisterTab(for: self) + } + // Snapshot first: popup close unregisters itself from popupControllers. let popupsToClose = popupControllers popupControllers.removeAll() diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index 0358ad85..6cc310e1 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -215,6 +215,13 @@ extension Workspace: @preconcurrency BonsplitDelegate { p.unfocus() } + // Web extensions track the active tab through this same selection funnel + // (didFocusPane also lands here), so this one hook covers pane focus and + // tab selection alike. + if #available(macOS 15.4, *), let browserPanel = panel as? BrowserPanel { + BrowserExtensionManager.shared.noteTabActivated(browserPanel) + } + // Explicitly hide browser portals for deselected tabs in this pane. // Bonsplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0), // but portal-hosted WKWebViews render at the window level in AppKit and are not