diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac71d83..40b8ac62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. - Escape now cancels in-progress terminal input for Japanese, Chinese, and Korean keyboards even while the command palette is still opening or has just closed. - Cmd+D now confirms only the close alert in the window that received the shortcut, so another window's alert can no longer close the wrong tab or steal a split command. -- 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 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/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5c9468f9..63c9a495 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -150,6 +150,8 @@ 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 */; }; + 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 */; }; @@ -578,6 +580,8 @@ 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 = ""; }; + 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 = ""; }; @@ -1089,6 +1093,8 @@ NRBR0012 /* BrowserPanelWebDelegates.swift */, NRBR0014 /* BrowserUserProxySettings.swift */, NRBR0016 /* IMECompositionMessageHandler.swift */, + NRBR0020 /* BrowserExtensionManager.swift */, + NRBR0022 /* BrowserExtensionAdapters.swift */, NRBR0018 /* WebViewRepresentable.swift */, A5FF0003 /* BrowserDataImport.swift */, A500RG00 /* ReactGrab.swift */, @@ -1653,6 +1659,8 @@ NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */, 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/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d22a19ec..3921f6c5 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 c0c942b1..42738c86 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8,6 +8,7 @@ import WebKit import Combine import ObjectiveC.runtime import Darwin +import Security @@ -1058,6 +1059,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,24 +1533,32 @@ 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 + 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(), isQuitWarningConfirmed: isQuitWarningConfirmed, + isInternalSingleInstanceLoserExit: isSingleInstanceLoserTerminationConfirmed, hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, 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 } @@ -1591,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 { @@ -8907,6 +8923,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 @@ -8917,11 +8934,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 @@ -8948,6 +8967,68 @@ 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 SingleInstanceForcePromptButton: Equatable, Sendable { + case primary + case secondary + case escape + } + + struct SingleInstanceCodeIdentity: Equatable, Sendable { + let signingIdentifier: String? + let teamIdentifier: String? + } + + struct SingleInstanceTerminationPersistencePolicy: Equatable, Sendable { + let persistPreTerminationSnapshot: Bool + let persistCleanShutdownSnapshot: Bool + let performProcessLocalTeardown: Bool + } + + enum SingleInstanceFallbackAction: Equatable, Sendable { + case skip + case prompt + case force + case exitNewer + } + nonisolated static func singleInstanceProcessKey( for processIdentifier: pid_t ) -> ProgramaSingleInstanceProcessKey? { @@ -8984,7 +9065,11 @@ 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( _ request: SingleInstanceShutdownRequest?, @@ -9014,24 +9099,123 @@ 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( + nonisolated static func shouldAcceptDuplicateShutdownAcknowledgment( + _ acknowledgment: SingleInstanceShutdownAcknowledgment?, + expectedTarget: ProgramaSingleInstanceProcessKey, + requestCreatedAt: TimeInterval, + now: TimeInterval + ) -> Bool { + 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 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 singleInstanceTerminationPersistencePolicy( + isDiscardedDuplicate: Bool + ) -> SingleInstanceTerminationPersistencePolicy { + SingleInstanceTerminationPersistencePolicy( + persistPreTerminationSnapshot: !isDiscardedDuplicate, + persistCleanShutdownSnapshot: !isDiscardedDuplicate, + performProcessLocalTeardown: true + ) + } + + nonisolated static func shouldTrustDuplicateRunningCode( expectedProcessKey: ProgramaSingleInstanceProcessKey, - resolvedProcessKey: ProgramaSingleInstanceProcessKey?, - isTerminated: Bool, - requestIsPending: Bool + resolvedProcessKeyBeforeValidation: ProgramaSingleInstanceProcessKey?, + resolvedProcessKeyAfterValidation: ProgramaSingleInstanceProcessKey?, + currentIdentity: SingleInstanceCodeIdentity, + candidateIdentity: SingleInstanceCodeIdentity, + dynamicRequirementMatches: Bool, + isDebugBuild: Bool ) -> Bool { - requestIsPending && resolvedProcessKey == expectedProcessKey && !isTerminated + guard resolvedProcessKeyBeforeValidation == expectedProcessKey, + resolvedProcessKeyAfterValidation == expectedProcessKey else { + return false + } + return shouldTrustDuplicateCodeIdentity( + current: currentIdentity, + candidate: candidateIdentity, + designatedRequirementMatches: dynamicRequirementMatches, + isDebugBuild: isDebugBuild + ) + } + + 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( @@ -9063,18 +9247,352 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return true } + nonisolated private static func dynamicCodeForCurrentProcess() -> SecCode? { + var dynamicCode: SecCode? + guard SecCodeCopySelf(SecCSFlags(rawValue: 0), &dynamicCode) == errSecSuccess, + let dynamicCode else { + return nil + } + return dynamicCode + } + + nonisolated private static func dynamicCode( + for processIdentifier: pid_t + ) -> SecCode? { + 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 + } + return dynamicCode + } + + 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 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( + expectedProcessKey: ProgramaSingleInstanceProcessKey + ) -> Bool { + 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( + currentRequirementCode, + SecCSFlags(rawValue: 0), + &designatedRequirement + ) == errSecSuccess, + let designatedRequirement else { + dilog("single_instance", "pid=\(processIdentifier) outcome=rejected reason=signing_requirement") + return false + } + 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 = shouldTrustDuplicateRunningCode( + expectedProcessKey: expectedProcessKey, + resolvedProcessKeyBeforeValidation: processKeyBeforeValidation, + resolvedProcessKeyAfterValidation: processKeyAfterValidation, + currentIdentity: currentIdentity, + candidateIdentity: candidateIdentity, + dynamicRequirementMatches: 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, - forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + performFallbackAfterGrace: @escaping @MainActor () -> Void ) { guard requestTermination() else { return } scheduleGrace { - _ = forceTerminationIfStillMatching() + performFallbackAfterGrace() } } + 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, + target: ProgramaSingleInstanceProcessKey, + generation: UUID + ) -> URL { + duplicateShutdownRequestURL( + rootDirectory: rootDirectory, + target: target, + generation: generation + ) + } + + nonisolated static func acknowledgmentForAcceptedRequestForTesting( + _ request: SingleInstanceShutdownRequest, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval + ) -> SingleInstanceShutdownAcknowledgment? { + acknowledgmentForAcceptedRequest( + request, + currentProcessKey: currentProcessKey, + now: now + ) + } + + nonisolated static func duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: Bool, + requestGenerationIsPending: Bool, + processIdentityMatches: Bool, + isTerminated: Bool, + response: SingleInstanceForcePromptResponse? + ) -> SingleInstanceFallbackAction { + duplicateFallbackAction( + hasValidTargetAcknowledgment: hasValidTargetAcknowledgment, + requestGenerationIsPending: requestGenerationIsPending, + processIdentityMatches: processIdentityMatches, + isTerminated: isTerminated, + response: response + ) + } + + nonisolated static func duplicateAcknowledgmentURLForTesting( + rootDirectory: URL, + target: ProgramaSingleInstanceProcessKey, + generation: UUID + ) -> URL { + _ = generation + return 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) + removeExactShutdownState(pending) + return existed + } + + nonisolated static func prepareDuplicateStateForTesting( + rootDirectory: URL, + now: TimeInterval, + isProcessLive: (ProgramaSingleInstanceProcessKey) -> Bool + ) -> Bool { + preparedSingleInstanceStateURLs( + in: rootDirectory, + now: now, + isProcessLive: isProcessLive + ) != 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 { + shouldScheduleDuplicateFallback( + requestWasWritten: requestWasWritten, + gracefulTerminationAccepted: gracefulTerminationAccepted + ) + } + + nonisolated static func duplicateForcePromptResponseForTesting( + button: SingleInstanceForcePromptButton + ) -> SingleInstanceForcePromptResponse { + duplicateForcePromptResponse(button: button) + } + + nonisolated static func shouldTrustDuplicateCodeIdentityForTesting( + current: SingleInstanceCodeIdentity, + candidate: SingleInstanceCodeIdentity, + designatedRequirementMatches: Bool, + isDebugBuild: Bool + ) -> Bool { + shouldTrustDuplicateCodeIdentity( + current: current, + candidate: candidate, + designatedRequirementMatches: designatedRequirementMatches, + isDebugBuild: isDebugBuild + ) + } + + 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 { + publishDuplicateAcknowledgment( + rootDirectory: rootDirectory, + request: request, + currentProcessKey: currentProcessKey, + now: now, + write: { acknowledgment, url in + allowWrite && writeBoundedSingleInstanceJSON(acknowledgment, to: url) + } + ) + } + + 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 + ) + ) + return isExactRequestPending(pending) && hasValidAcknowledgment(for: pending, now: now) + } + nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( _ request: SingleInstanceShutdownRequest?, currentProcessKey: ProgramaSingleInstanceProcessKey, @@ -9094,130 +9612,563 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser nonisolated static func shouldWarnBeforeTerminationForTesting( isTaggedDevBuild: Bool, isQuitWarningConfirmed: Bool, + isInternalSingleInstanceLoserExit: Bool, hasValidatedDuplicateShutdownRequest: Bool, isQuitWarningEnabled: Bool ) -> Bool { 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( + rootDirectory: URL = FileManager.default.temporaryDirectory + ) -> URL { + rootDirectory.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 ) } - private static func writeDuplicateShutdownRequest( - target: ProgramaSingleInstanceProcessKey, - requester: ProgramaSingleInstanceProcessKey + nonisolated private static func duplicateShutdownAcknowledgmentURL( + rootDirectory: URL, + target: ProgramaSingleInstanceProcessKey + ) -> URL { + rootDirectory.appendingPathComponent( + "ack-\(singleInstanceTargetComponent(target)).json", + isDirectory: false + ) + } + + nonisolated private static func publishDuplicateAcknowledgment( + rootDirectory: URL, + request: SingleInstanceShutdownRequest, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + write: (SingleInstanceShutdownAcknowledgment, URL) -> Bool ) -> Bool { - let request = SingleInstanceShutdownRequest( - target: target, - requester: requester, - createdAtUnixSeconds: Date().timeIntervalSince1970 + guard let acknowledgment = acknowledgmentForAcceptedRequest( + request, + currentProcessKey: currentProcessKey, + now: now + ) else { + return false + } + let url = duplicateShutdownAcknowledgmentURL( + rootDirectory: rootDirectory, + target: currentProcessKey ) - let requestURL = duplicateShutdownRequestURL(for: target.processIdentifier) + return write(acknowledgment, url) + } + + nonisolated private static func validatedSingleInstanceStateDirectory() -> URL? { + let fileManager = FileManager.default + let directoryURL = singleInstanceStateDirectoryURL() 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 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 data.write(to: requestURL, options: .atomic) - dilog("single_instance", "pid=\(target.processIdentifier) outcome=written reason=shutdown_request") + try fileManager.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directoryURL.path + ) + return directoryURL + } catch { + return nil + } + } + + 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 { - dilog("single_instance", "pid=\(target.processIdentifier) outcome=failed reason=request_write") return false } } - private func consumeValidatedDuplicateShutdownRequest() -> 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") - return false + nonisolated private static func isExactRequestPending( + _ pending: PendingSingleInstanceShutdown + ) -> Bool { + readBoundedSingleInstanceJSON( + SingleInstanceShutdownRequest.self, + from: pending.requestURL + ) == pending.request + } + + @discardableResult + nonisolated private static func removeExactRequest( + _ pending: PendingSingleInstanceShutdown + ) -> Bool { + guard isExactRequestPending(pending) else { + return !FileManager.default.fileExists(atPath: pending.requestURL.path) } - defer { - try? fileHandle.close() - try? FileManager.default.removeItem(at: requestURL) + do { + try FileManager.default.removeItem(at: pending.requestURL) + return true + } catch { + return false } + } - 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") + @discardableResult + nonisolated private static func removeExactAcknowledgment( + target: ProgramaSingleInstanceProcessKey, + 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 else { + return false + } + do { + try FileManager.default.removeItem(at: url) + return true + } catch { return false } + } + + nonisolated private static func removeExactShutdownState( + _ pending: PendingSingleInstanceShutdown + ) { + _ = removeExactRequest(pending) + } + + nonisolated private static func preparedSingleInstanceStateURLs( + in directoryURL: URL, + now: TimeInterval, + isProcessLive: (ProgramaSingleInstanceProcessKey) -> Bool + ) -> [URL]? { + for _ in 0..= 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 + ).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 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 } + removedCount += 1 + } 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 + } + return nil + } - let requesterApplication = NSRunningApplication( - processIdentifier: request.requesterProcessIdentifier + 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 + ) -> PendingSingleInstanceShutdown? { + guard let directoryURL = validatedSingleInstanceStateDirectory(), + 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 + } + let request = SingleInstanceShutdownRequest( + target: target, + requester: requester, + createdAtUnixSeconds: Date().timeIntervalSince1970 + ) + 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 nil + } + dilog("single_instance", "pid=\(target.processIdentifier) outcome=written reason=shutdown_request") + return pending + } + + private func acknowledgeValidatedDuplicateShutdownRequest() -> Bool { + let currentProcessIdentifier = getpid() + guard let currentKey = Self.singleInstanceProcessKey(for: currentProcessIdentifier), + let bundleIdentifier = Bundle.main.bundleIdentifier, + let directoryURL = Self.validatedSingleInstanceStateDirectory(), + 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 + } + 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 + 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 + } + for requestURL in targetRequestURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + 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 + ) + && Self.isAuthenticatedProgramaApplication( + expectedProcessKey: request.requester + ) + } ?? false + guard Self.shouldAcceptDuplicateShutdownRequest( + request, + currentProcessKey: currentKey, + now: Date().timeIntervalSince1970, + resolvedRequesterKey: Self.singleInstanceProcessKey( + for: request.requesterProcessIdentifier + ), + requesterIsProgramaGUI: requesterIsProgramaGUI + ) else { + continue + } + let published = Self.publishDuplicateAcknowledgment( + rootDirectory: directoryURL, + request: request, + currentProcessKey: currentKey, + now: Date().timeIntervalSince1970, + write: { acknowledgment, url in + Self.writeBoundedSingleInstanceJSON(acknowledgment, to: url) + } + ) + 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 + } + 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 ) - dilog( - "single_instance", - "pid=\(currentProcessIdentifier) outcome=\(accepted ? "accepted" : "rejected") reason=shutdown_request" + 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" + ) + 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." ) - return accepted + 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" + )) + 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 + 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 { + removeExactShutdownState(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 + ) + switch action { + case .force: + guard let resolvedApplication else { + removeExactShutdownState(pending) + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") + return + } + let forced = resolvedApplication.forceTerminate() + removeExactShutdownState(pending) + dilog( + "single_instance", + "pid=\(processIdentifier) outcome=\(forced ? "forced" : "force_rejected") reason=user_consent" + ) + case .exitNewer: + 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: + removeExactShutdownState(pending) + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=post_prompt_revalidation") + case .prompt: + removeExactShutdownState(pending) + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=invalid_prompt_state") + } } private static func terminateDuplicateApplication( @@ -9226,59 +10177,37 @@ 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 shouldScheduleDuplicateFallback( + requestWasWritten: true, + gracefulTerminationAccepted: accepted + ) }, 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) } ) } @@ -9303,8 +10232,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { continue } - guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), - Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier) else { + continue + } + guard Self.isAuthenticatedProgramaApplication(expectedProcessKey: otherKey) else { + continue + } + guard Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") continue } @@ -9343,9 +10277,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) else { return } - - guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), - Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier) else { + return + } + 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 } 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 new file mode 100644 index 00000000..1a7af54a --- /dev/null +++ b/Sources/Panels/BrowserExtensionManager.swift @@ -0,0 +1,174 @@ +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 + + // 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 + /// 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..9fa34887 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 @@ -1040,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() { @@ -1841,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 diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index fd4ba064..f608598e 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -204,9 +204,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( @@ -217,19 +217,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() { @@ -261,6 +260,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertFalse(AppDelegate.shouldWarnBeforeTerminationForTesting( isTaggedDevBuild: false, isQuitWarningConfirmed: false, + isInternalSingleInstanceLoserExit: false, hasValidatedDuplicateShutdownRequest: accepted, isQuitWarningEnabled: true )) @@ -347,53 +347,610 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(AppDelegate.shouldWarnBeforeTerminationForTesting( isTaggedDevBuild: false, isQuitWarningConfirmed: false, + isInternalSingleInstanceLoserExit: false, hasValidatedDuplicateShutdownRequest: false, isQuitWarningEnabled: true )) } - func testDuplicateForceFallbackRequiresSameLiveProcessIdentity() { + 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 testThreeConcurrentShutdownGenerationsOwnRequestFilesAndShareTargetAcknowledgment() 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, 1) + XCTAssertTrue(AppDelegate.removeDuplicateStateForTesting( + rootDirectory: rootDirectory, + request: requests[0] + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: requestURLs[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 testDuplicateAcknowledgmentRequiresExactTargetAndTimingButNotGeneration() { + 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_004 + ) + + XCTAssertTrue(AppDelegate.shouldAcceptDuplicateShutdownAcknowledgmentForTesting( + valid, + expectedRequest: request, + now: 10_002 + )) + 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, + now: 10_002 + )) + } + } + + func testDuplicateStateRecoversFromMoreThanScanLimitRecognizedStaleEntries() 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..<520 { + 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 + )) + } + + 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 || $0 == request.requester } + )) + 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 testDuplicateRequesterRequiresDynamicCodeValidationForUnchangedProcessIdentity() { let expected = ProgramaSingleInstanceProcessKey( startSeconds: 1_000, startMicroseconds: 100, processIdentifier: 100 ) - let changed = ProgramaSingleInstanceProcessKey( + let replaced = ProgramaSingleInstanceProcessKey( startSeconds: 1_001, startMicroseconds: 0, processIdentifier: 100 ) + let identity = AppDelegate.SingleInstanceCodeIdentity( + signingIdentifier: "com.darkroom.programa", + teamIdentifier: "DARKROOMTEAM" + ) - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + XCTAssertFalse(AppDelegate.shouldTrustDuplicateRunningCodeForTesting( expectedProcessKey: expected, - resolvedProcessKey: nil, - isTerminated: false, - requestIsPending: true - )) - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + 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, - resolvedProcessKey: changed, - isTerminated: false, - requestIsPending: true + 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: ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ), + createdAtUnixSeconds: 10_000 + ) + _ = try XCTUnwrap(AppDelegate.writeDuplicateRequestForTesting( + rootDirectory: rootDirectory, + request: acceptedRequest )) - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: expected, - isTerminated: true, - requestIsPending: true + XCTAssertTrue(AppDelegate.publishDuplicateAcknowledgmentForTesting( + rootDirectory: rootDirectory, + request: acceptedRequest, + currentProcessKey: target, + now: 10_001 )) - XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: expected, - isTerminated: false, - requestIsPending: false + + 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) } + + 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.shouldForceDuplicateTerminationForTesting( - expectedProcessKey: expected, - resolvedProcessKey: expected, + XCTAssertTrue(AppDelegate.shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: false, + isQuitWarningConfirmed: false, + isInternalSingleInstanceLoserExit: false, + hasValidatedDuplicateShutdownRequest: false, + isQuitWarningEnabled: true + )) + } + + func testDelayedDuplicateTargetPromptsInsteadOfForcingAutomatically() { + XCTAssertEqual(AppDelegate.duplicateFallbackActionForTesting( + hasValidTargetAcknowledgment: false, + requestGenerationIsPending: true, + processIdentityMatches: true, isTerminated: false, - requestIsPending: true + response: nil + ), .prompt) + } + + func testDuplicateFallbackGraceLeavesRoomBeforeRequestExpiry() { + XCTAssertEqual( + AppDelegate.duplicateTerminationGraceIntervalForTesting, + 8, + accuracy: 0.25 + ) + XCTAssertLessThan(AppDelegate.duplicateTerminationGraceIntervalForTesting, 10) + } + + 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 )) + + 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() {