From 5e081997110004ef5cd7d66cfc041c39a4dd6715 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Mon, 5 Oct 2026 20:25:31 +0800 Subject: [PATCH 1/5] Return focus to Ask Key after Touch ID --- .../ManagementAuthenticationProcess.swift | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift index 426a9bb..4309eee 100644 --- a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift +++ b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift @@ -289,6 +289,10 @@ enum ManagementAuthenticationSubprocess { if let data = try? JSONEncoder().encode(value) { try? FileHandle.standardOutput.write(contentsOf: data) } + // Let the app take focus back if it was in front before the prompt. + if let parent = NSRunningApplication(processIdentifier: getppid()) { + NSApp.yieldActivation(to: parent) + } NSApp.terminate(nil) } } @@ -336,6 +340,16 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { presentation: presentation ) else { return .failed } let process = Process() + let wasActive = ActivationFlag() + // Return focus to the app only when the prompt took it from the app, + // not when an approval was answered from another app. + defer { + DispatchQueue.main.async { + // NSApp is nil when the runner is exercised without an app. + guard wasActive.isSet, let app = NSApp as NSApplication? else { return } + app.activate() + } + } process.executableURL = executable process.arguments = ManagementAuthenticationSubprocess.arguments( language: presentation.language, @@ -357,8 +371,10 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { try process.run() let pid = process.processIdentifier DispatchQueue.main.async { + guard let app = NSApp as NSApplication?, app.isActive else { return } + wasActive.set() if let prompt = NSRunningApplication(processIdentifier: pid) { - NSApp.yieldActivation(to: prompt) + app.yieldActivation(to: prompt) } } try input.fileHandleForWriting.write(contentsOf: payloadData) @@ -404,3 +420,14 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { fcntl(fileDescriptor, F_SETNOSIGPIPE, 1) == 0 } } + +/// Records, from the main thread, whether the app was active when the +/// authentication prompt started. +private final class ActivationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + var isSet: Bool { lock.withLock { value } } + + func set() { lock.withLock { value = true } } +} From 4da26b70567d260e67c02a50d0cf793b8dfcd3ea Mon Sep 17 00:00:00 2001 From: sudoHG Date: Mon, 5 Oct 2026 20:35:00 +0800 Subject: [PATCH 2/5] Keep the Dock state steady while Touch ID is showing --- .../AskKeyAppKit/App/AppDelegate+WindowManagement.swift | 7 +++++-- .../AskKeyAppKit/ManagementAuthenticationProcess.swift | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift b/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift index 6ed5749..88f15f6 100644 --- a/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift +++ b/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift @@ -72,7 +72,9 @@ extension AppDelegate { } private func handleManagementDockEvent(_ event: ManagementDockPolicy.Event) { - guard !ManagementAuthenticationSubprocess.isActive else { return } + // A Touch ID prompt takes focus briefly; keep the Dock state as it was. + guard !ManagementAuthenticationSubprocess.isActive, + !ManagementAuthenticationRunner.isPromptShowing else { return } if isApplyingDockPolicy { scheduleManagementDockStateRefresh() return @@ -82,7 +84,8 @@ extension AppDelegate { } private func syncManagementWindowDockState() { - guard !ManagementAuthenticationSubprocess.isActive else { return } + guard !ManagementAuthenticationSubprocess.isActive, + !ManagementAuthenticationRunner.isPromptShowing else { return } guard let window = managementWindow else { handleManagementDockEvent(.managementWindowState( visible: false, diff --git a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift index 4309eee..37171c1 100644 --- a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift +++ b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift @@ -300,6 +300,11 @@ enum ManagementAuthenticationSubprocess { final class ManagementAuthenticationRunner: @unchecked Sendable { static let shared = ManagementAuthenticationRunner() + /// True while a Touch ID prompt process is running. The app ignores the + /// focus changes it causes, so the Dock icon does not flicker. + static var isPromptShowing: Bool { promptShowing.isSet } + private static let promptShowing = ActivationFlag() + private let queue = DispatchQueue(label: "com.sudohg.askkey.authentication") private let executableURL: URL? private let timeout: TimeInterval @@ -341,10 +346,12 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { ) else { return .failed } let process = Process() let wasActive = ActivationFlag() + Self.promptShowing.set() // Return focus to the app only when the prompt took it from the app, // not when an approval was answered from another app. defer { DispatchQueue.main.async { + Self.promptShowing.clear() // NSApp is nil when the runner is exercised without an app. guard wasActive.isSet, let app = NSApp as NSApplication? else { return } app.activate() @@ -430,4 +437,6 @@ private final class ActivationFlag: @unchecked Sendable { var isSet: Bool { lock.withLock { value } } func set() { lock.withLock { value = true } } + + func clear() { lock.withLock { value = false } } } From c76e942ac4a3c8f102b8b98725bacc0bb41713a0 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Mon, 5 Oct 2026 20:45:20 +0800 Subject: [PATCH 3/5] Fix focus, queue and timer issues found in the pre-release review - Hand focus to the Touch ID prompt before it can activate itself, and return it only if the user did not switch to another app meanwhile. - Count overlapping prompts and reconcile the Dock state afterwards. - Present the next approval when a retried one has expired. - Keep approval privacy and idle-lock timers running in common modes. - Ignore repeated Unlock clicks while Touch ID is showing. --- .../App/AppDelegate+Approval.swift | 9 +++- .../App/AppDelegate+WindowManagement.swift | 16 ++++++- .../ManagementAuthenticationProcess.swift | 47 +++++++++++++++---- Sources/AskKeyAppKit/SessionPolicy.swift | 5 +- Sources/AskKeyAppKit/VaultViewModel.swift | 5 ++ 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift b/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift index 3e61ba9..6ebf17e 100644 --- a/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift +++ b/Sources/AskKeyAppKit/App/AppDelegate+Approval.swift @@ -117,7 +117,10 @@ extension AppDelegate { ) } else if failed { self.vault.errorMessage = "Ask Key could not apply this decision. Open Pending requests to retry or reject it." - } else if !Vault.shared.approvalRequests.pendingRequests().isEmpty { + } + // The retried request may have expired meanwhile; never leave + // other requests waiting without a prompt. + if !self.presentingApproval, !Vault.shared.approvalRequests.pendingRequests().isEmpty { self.presentPendingApproval() } } @@ -229,7 +232,8 @@ extension AppDelegate { panel.contentViewController = hosting panel.setContentSize(hosting.view.fittingSize) panel.center() - privacyTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in + // Common modes keep the check running during menus, drags and modal pickers. + privacyTimer = Timer(timeInterval: 0.25, repeats: true) { _ in MainActor.assumeIsolated { let requestEnded = pending.map { (try? Vault.shared.approvalRequests.status(requestID: $0.requestID, capability: $0.capability)) != .pending @@ -240,6 +244,7 @@ extension AppDelegate { } } } + if let privacyTimer { RunLoop.main.add(privacyTimer, forMode: .common) } panel.level = .modalPanel panel.makeKeyAndOrderFront(nil) } diff --git a/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift b/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift index 88f15f6..dbdb7a7 100644 --- a/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift +++ b/Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift @@ -130,7 +130,8 @@ extension AppDelegate { } private func applyManagementDockPolicy() { - guard !ManagementAuthenticationSubprocess.isActive else { return } + guard !ManagementAuthenticationSubprocess.isActive, + !ManagementAuthenticationRunner.isPromptShowing else { return } guard !isApplyingDockPolicy else { scheduleManagementDockPolicyApplication() return @@ -196,6 +197,19 @@ extension AppDelegate { } } ) + // Events are ignored while Touch ID is showing; reconcile afterwards. + observers.append( + center.addObserver( + forName: .managementAuthenticationPromptDidEnd, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard !ManagementAuthenticationRunner.isPromptShowing else { return } + self?.syncManagementWindowDockState() + } + } + ) managementWindowLifecycleObservers = observers } } diff --git a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift index 37171c1..58b6361 100644 --- a/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift +++ b/Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift @@ -104,6 +104,9 @@ struct ManagementAuthenticationDescription: Codable, Equatable, Sendable { private struct ManagementAuthenticationResponse: Codable { let outcome: ManagementAuthenticationOutcome + /// Whether the prompt still had focus when it finished; false when the + /// user switched to another app meanwhile. + var promptWasActive: Bool? } private struct ManagementAuthenticationPayload: Codable { @@ -210,7 +213,9 @@ enum ManagementAuthenticationSubprocess { outcome = .failed } DispatchQueue.main.async { - writeAndTerminate(ManagementAuthenticationResponse(outcome: outcome)) + writeAndTerminate(ManagementAuthenticationResponse( + outcome: outcome, promptWasActive: NSApp.isActive + )) } } return true @@ -302,8 +307,8 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { /// True while a Touch ID prompt process is running. The app ignores the /// focus changes it causes, so the Dock icon does not flicker. - static var isPromptShowing: Bool { promptShowing.isSet } - private static let promptShowing = ActivationFlag() + static var isPromptShowing: Bool { promptCount.value > 0 } + private static let promptCount = PromptCounter() private let queue = DispatchQueue(label: "com.sudohg.askkey.authentication") private let executableURL: URL? @@ -346,14 +351,18 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { ) else { return .failed } let process = Process() let wasActive = ActivationFlag() - Self.promptShowing.set() + let userSwitchedAway = ActivationFlag() + Self.promptCount.increment() // Return focus to the app only when the prompt took it from the app, - // not when an approval was answered from another app. + // not when an approval was answered from another app, and not when + // the user moved to another app while the prompt was open. defer { DispatchQueue.main.async { - Self.promptShowing.clear() + Self.promptCount.decrement() + NotificationCenter.default.post(name: .managementAuthenticationPromptDidEnd, object: nil) // NSApp is nil when the runner is exercised without an app. - guard wasActive.isSet, let app = NSApp as NSApplication? else { return } + guard wasActive.isSet, !userSwitchedAway.isSet, + let app = NSApp as NSApplication? else { return } app.activate() } } @@ -377,13 +386,19 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { do { try process.run() let pid = process.processIdentifier + // Record focus and yield it before the prompt process receives its + // payload, so it cannot activate first. Bounded in case the main + // thread is busy. + let handedOver = DispatchSemaphore(value: 0) DispatchQueue.main.async { + defer { handedOver.signal() } guard let app = NSApp as NSApplication?, app.isActive else { return } wasActive.set() if let prompt = NSRunningApplication(processIdentifier: pid) { app.yieldActivation(to: prompt) } } + _ = handedOver.wait(timeout: .now() + 2) try input.fileHandleForWriting.write(contentsOf: payloadData) try input.fileHandleForWriting.close() } catch { @@ -404,6 +419,7 @@ final class ManagementAuthenticationRunner: @unchecked Sendable { ) else { return .failed } + if response.promptWasActive == false { userSwitchedAway.set() } return response.outcome } @@ -437,6 +453,21 @@ private final class ActivationFlag: @unchecked Sendable { var isSet: Bool { lock.withLock { value } } func set() { lock.withLock { value = true } } +} + +/// Counts running Touch ID prompts; incremented on the worker queue and +/// decremented on the main queue, so overlapping prompts never clear each other. +private final class PromptCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { lock.withLock { count } } + + func increment() { lock.withLock { count += 1 } } + + func decrement() { lock.withLock { count = max(0, count - 1) } } +} - func clear() { lock.withLock { value = false } } +extension Notification.Name { + static let managementAuthenticationPromptDidEnd = Notification.Name("ManagementAuthenticationPromptDidEnd") } diff --git a/Sources/AskKeyAppKit/SessionPolicy.swift b/Sources/AskKeyAppKit/SessionPolicy.swift index c9b43e4..2db60ee 100644 --- a/Sources/AskKeyAppKit/SessionPolicy.swift +++ b/Sources/AskKeyAppKit/SessionPolicy.swift @@ -13,7 +13,10 @@ final class SessionPolicy { init( scheduleTimer: @escaping ScheduleTimer = { timeout, callback in - Timer.scheduledTimer(withTimeInterval: timeout, repeats: false, block: callback) + // Common modes keep the idle lock running during menus and modal pickers. + let timer = Timer(timeInterval: timeout, repeats: false, block: callback) + RunLoop.main.add(timer, forMode: .common) + return timer }, enqueueExpiration: @escaping EnqueueExpiration = { callback in Task { @MainActor in callback() } diff --git a/Sources/AskKeyAppKit/VaultViewModel.swift b/Sources/AskKeyAppKit/VaultViewModel.swift index e8fbf2e..aa5c558 100644 --- a/Sources/AskKeyAppKit/VaultViewModel.swift +++ b/Sources/AskKeyAppKit/VaultViewModel.swift @@ -43,6 +43,7 @@ package final class VaultViewModel { package var isLocked = true var onboarding = AgentOnboardingCoordinator() var errorMessage: String? + @ObservationIgnored private var isUnlocking = false /// Set by the popover to hand the "new credential" action over to the manager /// window: a `MenuBarExtra(.window)` popover closes as soon as a sheet takes /// key focus, so the add form can't live there. The manager consumes and @@ -320,6 +321,10 @@ package final class VaultViewModel { } func unlockForManagement() async { + // A second click while Touch ID is showing must not queue another prompt. + guard !isUnlocking else { return } + isUnlocking = true + defer { isUnlocking = false } if hasManagementSession { renewManagementSession() renewSession() From 475f22bd301474ca293d8b48c3dcba106ee5d4a2 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Mon, 5 Oct 2026 20:59:35 +0800 Subject: [PATCH 4/5] Fix copy and import issues found in the pre-release review - Replacing a credential on import keeps its permission; the picker says so. - Approval Details mark the requesting Agent's name as self-declared. - Access records describe denied and failed requests as requests. - The welcome page's try-it step matches the saved permission. - Agent access no longer promises a lookup before every use. - The sample prompt skips expired credentials. --- .../App/ApprovalPromptContent.swift | 6 + .../Resources/Localizable.xcstrings | 103 ++++++++++++++---- .../AccessRecordPresentation.swift | 24 +++- .../Views/AgentOnboardingView.swift | 5 +- .../Credentials/FrozenFileImportPage.swift | 11 +- .../Onboarding/WelcomeStepsPresentation.swift | 10 +- .../Settings/FrozenSettingsContract.swift | 2 +- .../ApprovalPromptContentTests.swift | 8 +- .../CredentialEditorVisualTests.swift | 4 +- .../ScreenPresentationTests.swift | 2 +- Tests/AskKeyAppTests/WelcomeStepsTests.swift | 15 +++ 11 files changed, 152 insertions(+), 38 deletions(-) diff --git a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift index c1fcf49..dba8975 100644 --- a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift +++ b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift @@ -37,6 +37,12 @@ struct ApprovalPromptContent: Equatable { if let directory = display?.workingDirectory, !directory.isEmpty { rows.append(Row(label: appLocalized("Location"), value: directory, monospaced: false)) } + // The caller name in the title is declared by the agent itself. + rows.append(Row( + label: appLocalized("Requested by"), + value: appLocalizedFormat("%@ (self-declared, unverified)", caller), + monospaced: false + )) if let purpose = request.callerPurpose, !purpose.isEmpty { rows.append(Row( label: appLocalized("Stated purpose"), diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index 9be8497..967458c 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -1,6 +1,86 @@ { "sourceLanguage": "en", "strings": { + "Requested by": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Requested by" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请求方" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Requested by!!]" } } + } + }, + "Replacing keeps this credential's current permission. You can change it on the credential page.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Replacing keeps this credential's current permission. You can change it on the credential page." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换时保留这份凭证现有的权限,可在凭证页修改。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Replacing keeps this credential's current permission. You can change it on the credential page.!!]" } } + } + }, + "Ask your Agent to run one command with this credential. With Allow, it runs without a prompt and appears in Access records.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask your Agent to run one command with this credential. With Allow, it runs without a prompt and appears in Access records." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "让 Agent 用这份凭证运行一条命令。权限是“允许”,不会弹窗,可在访问记录里查看。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Ask your Agent to run one command with this credential. With Allow, it runs without a prompt and appears in Access records.!!]" } } + } + }, + "Agents cannot see a Hidden credential. Change it to Ask every time, then ask your Agent to run one command with it.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Agents cannot see a Hidden credential. Change it to Ask every time, then ask your Agent to run one command with it." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Agent 看不到“隐藏”的凭证。先改成“每次询问”,再让 Agent 用它运行一条命令。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Agents cannot see a Hidden credential. Change it to Ask every time, then ask your Agent to run one command with it.!!]" } } + } + }, + "%1$@ asked to use %2$@ to run %3$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ asked to use %2$@ to run %3$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 请求用 %2$@ 运行 %3$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to use %2$@ to run %3$@!!]" } } + } + }, + "%1$@ asked to use %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ asked to use %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 请求使用 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to use %2$@!!]" } } + } + }, + "%1$@ asked to create credential %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ asked to create credential %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 请求新建凭证 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to create credential %2$@!!]" } } + } + }, + "%1$@ asked to change credential %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ asked to change credential %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 请求修改凭证 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to change credential %2$@!!]" } } + } + }, + "%1$@ asked to delete credential %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ asked to delete credential %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 请求删除凭证 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to delete credential %2$@!!]" } } + } + }, + "Once connected, an Agent can look up which credentials Ask Key has and request them within the permissions you set.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Once connected, an Agent can look up which credentials Ask Key has and request them within the permissions you set." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "接入后,Agent 可以查看请旨里有哪些凭证,并在你设定的权限内申请使用。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Once connected, an Agent can look up which credentials Ask Key has and request them within the permissions you set.!!]" } } + } + }, "Claude Code": { "extractionState": "manual", "localizations": { @@ -15423,29 +15503,6 @@ } } }, - "Once connected, an Agent first looks up which credentials Ask Key has, then asks within the permissions you set.": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Once connected, an Agent first looks up which credentials Ask Key has, then asks within the permissions you set." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "接入后,Agent 会先查请旨里有哪些凭证,再按你给的权限申请。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Once connected, an Agent first looks up which credentials Ask Key has, then asks within the permissions you set.!!]" - } - } - } - }, "Report on GitHub Issues": { "extractionState": "extracted_with_value", "localizations": { diff --git a/Sources/AskKeyAppKit/Views/AccessRecords/AccessRecordPresentation.swift b/Sources/AskKeyAppKit/Views/AccessRecords/AccessRecordPresentation.swift index d9f8471..9a31922 100644 --- a/Sources/AskKeyAppKit/Views/AccessRecords/AccessRecordPresentation.swift +++ b/Sources/AskKeyAppKit/Views/AccessRecords/AccessRecordPresentation.swift @@ -77,24 +77,38 @@ struct AccessRecordPresentation: Equatable { let caller = event.callerHint ?? appLocalized("Local Caller") let name = credentialName(event.credentialID) let sentence: EmphasizedSentence + // Denied or failed requests did not happen; describe them as requests. + let happened = event.result == .allowed switch event.operation { case .catalog: sentence = .init(format: appLocalized("%@ browsed the credential list"), arguments: [caller]) case .runtimeRead: if let executable = event.executableBasename, !executable.isEmpty { sentence = .init( - format: appLocalized("%1$@ used %2$@ to run %3$@"), + format: happened ? appLocalized("%1$@ used %2$@ to run %3$@") : appLocalized("%1$@ asked to use %2$@ to run %3$@"), arguments: [caller, name, executable] ) } else { - sentence = .init(format: appLocalized("%1$@ used %2$@"), arguments: [caller, name]) + sentence = .init( + format: happened ? appLocalized("%1$@ used %2$@") : appLocalized("%1$@ asked to use %2$@"), + arguments: [caller, name] + ) } case .create: - sentence = .init(format: appLocalized("%1$@ created credential %2$@"), arguments: [caller, name]) + sentence = .init( + format: happened ? appLocalized("%1$@ created credential %2$@") : appLocalized("%1$@ asked to create credential %2$@"), + arguments: [caller, name] + ) case .modify: - sentence = .init(format: appLocalized("%1$@ changed credential %2$@"), arguments: [caller, name]) + sentence = .init( + format: happened ? appLocalized("%1$@ changed credential %2$@") : appLocalized("%1$@ asked to change credential %2$@"), + arguments: [caller, name] + ) case .delete: - sentence = .init(format: appLocalized("%1$@ deleted credential %2$@"), arguments: [caller, name]) + sentence = .init( + format: happened ? appLocalized("%1$@ deleted credential %2$@") : appLocalized("%1$@ asked to delete credential %2$@"), + arguments: [caller, name] + ) } let result = Self.result(event) return .init( diff --git a/Sources/AskKeyAppKit/Views/AgentOnboardingView.swift b/Sources/AskKeyAppKit/Views/AgentOnboardingView.swift index 1c4495c..7c8e1b5 100644 --- a/Sources/AskKeyAppKit/Views/AgentOnboardingView.swift +++ b/Sources/AskKeyAppKit/Views/AgentOnboardingView.swift @@ -375,7 +375,10 @@ struct AgentOnboardingView: View { /// A credential Agents can see, so the sample prompt works as written. private var sampleCredentialName: String? { vault.credentials - .filter { $0.deletedAt == nil && $0.permission != .hidden } + .filter { credential in + credential.deletedAt == nil && credential.permission != .hidden + && credential.expiresAt.map { $0 > Date() } != false + } .map(\.name) .sorted { $0.localizedStandardCompare($1) == .orderedAscending } .first diff --git a/Sources/AskKeyAppKit/Views/Credentials/FrozenFileImportPage.swift b/Sources/AskKeyAppKit/Views/Credentials/FrozenFileImportPage.swift index 81c5a17..238a469 100644 --- a/Sources/AskKeyAppKit/Views/Credentials/FrozenFileImportPage.swift +++ b/Sources/AskKeyAppKit/Views/Credentials/FrozenFileImportPage.swift @@ -105,7 +105,16 @@ struct FrozenFileImportPage: View { } } CredentialFormField(appLocalized("Agent Permission")) { - CredentialPermissionPicker(permission: $permission) + if let existingCredential { + // Replacing changes contents only; the permission stays. + CredentialPermissionPicker(permission: .constant(existingCredential.permission)) + .disabled(true) + Text(appLocalized("Replacing keeps this credential's current permission. You can change it on the credential page.")) + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.textSecondary) + } else { + CredentialPermissionPicker(permission: $permission) + } } CredentialFormField( importedFile == nil ? FrozenImportCopy.contentsHeader : appLocalized("Contents") diff --git a/Sources/AskKeyAppKit/Views/Onboarding/WelcomeStepsPresentation.swift b/Sources/AskKeyAppKit/Views/Onboarding/WelcomeStepsPresentation.swift index 0c9a0de..b9a6ede 100644 --- a/Sources/AskKeyAppKit/Views/Onboarding/WelcomeStepsPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Onboarding/WelcomeStepsPresentation.swift @@ -59,12 +59,20 @@ struct WelcomeStepsPresentation: Equatable { Step( number: 3, title: appLocalized("Try it once"), - message: appLocalized("Ask your Agent to run one command with this credential. Ask Key will ask you in a prompt."), + message: Self.trialMessage(savedCredential?.permission ?? .ask), state: .upcoming ), ] } + private static func trialMessage(_ permission: CredentialPermission) -> String { + switch permission { + case .ask: return appLocalized("Ask your Agent to run one command with this credential. Ask Key will ask you in a prompt.") + case .allowed: return appLocalized("Ask your Agent to run one command with this credential. With Allow, it runs without a prompt and appears in Access records.") + case .hidden: return appLocalized("Agents cannot see a Hidden credential. Change it to Ask every time, then ask your Agent to run one command with it.") + } + } + private static func permissionMessage(_ permission: CredentialPermission) -> String { switch permission { case .allowed: return appLocalized("Permission: Allow. You can change it in All credentials.") diff --git a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsContract.swift b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsContract.swift index fee4164..8e0b131 100644 --- a/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsContract.swift +++ b/Sources/AskKeyAppKit/Views/Settings/FrozenSettingsContract.swift @@ -7,7 +7,7 @@ enum FrozenSettingsContract { static var languageOptions: [String] { AppLanguage.publishedModes.map { appLocalized(AppLanguage.titleKey(for: $0)) } } - static var agentAccessSubtitle: String { appLocalized("Once connected, an Agent first looks up which credentials Ask Key has, then asks within the permissions you set.") } + static var agentAccessSubtitle: String { appLocalized("Once connected, an Agent can look up which credentials Ask Key has and request them within the permissions you set.") } static var emptyLibraryAction: String { appLocalized("Create First Credential") } } diff --git a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift index 601e89b..3fe0742 100644 --- a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift +++ b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift @@ -52,10 +52,11 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { ) XCTAssertEqual(content.title, "“Demo Agent” wants to use “demo-api”") XCTAssertEqual(content.commandSummary, "./deploy.sh --env staging") - XCTAssertEqual(content.rows.map(\.label), ["Delivers", "Location", "Stated purpose"]) + XCTAssertEqual(content.rows.map(\.label), ["Delivers", "Location", "Requested by", "Stated purpose"]) XCTAssertEqual(content.rows.map(\.value), [ "API_KEY, CERT_FILE (file)", "~/work/shop-api", + "Demo Agent (self-declared, unverified)", "Deploy staging (self-declared, unverified)", ]) } @@ -65,10 +66,11 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { credentialName: "demo-api" ) XCTAssertEqual(content.title, "“Demo Agent”想使用“demo-api”") // i18n-literal: Assert the Simplified Chinese approval copy. - XCTAssertEqual(content.rows.map(\.label), ["交付", "位置", "说明"]) // i18n-literal: Assert the Simplified Chinese approval copy. + XCTAssertEqual(content.rows.map(\.label), ["交付", "位置", "请求方", "说明"]) // i18n-literal: Assert the Simplified Chinese approval copy. XCTAssertEqual(content.rows.map(\.value), [ "API_KEY、API_ENDPOINT", // i18n-literal: Assert the Simplified Chinese approval copy. "~/work/shop-api", + "Demo Agent(自报,未核实)", // i18n-literal: Assert the Simplified Chinese approval copy. "Deploy staging(自报,未核实)", // i18n-literal: Assert the Simplified Chinese approval copy. ]) } @@ -130,7 +132,7 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { ) XCTAssertEqual(content.title, "“Demo Agent” wants to delete “demo-api”") XCTAssertNil(content.commandSummary) - XCTAssertEqual(content.rows.map(\.label), ["Stated purpose", "Destination"]) + XCTAssertEqual(content.rows.map(\.label), ["Requested by", "Stated purpose", "Destination"]) } } diff --git a/Tests/AskKeyAppTests/CredentialEditorVisualTests.swift b/Tests/AskKeyAppTests/CredentialEditorVisualTests.swift index fbff307..8887fb0 100644 --- a/Tests/AskKeyAppTests/CredentialEditorVisualTests.swift +++ b/Tests/AskKeyAppTests/CredentialEditorVisualTests.swift @@ -139,11 +139,11 @@ final class CredentialEditorVisualTests: WorkspaceVisualContractTestSupport { ) ) XCTAssertEqual(FrozenSettingsContract.languageOptions, ["跟随系统", "中文", "English"]) - XCTAssertEqual(FrozenSettingsContract.agentAccessSubtitle, "接入后,Agent 会先查请旨里有哪些凭证,再按你给的权限申请。") + XCTAssertEqual(FrozenSettingsContract.agentAccessSubtitle, "接入后,Agent 可以查看请旨里有哪些凭证,并在你设定的权限内申请使用。") AppLanguage.current = "en" XCTAssertEqual( FrozenSettingsContract.agentAccessSubtitle, - "Once connected, an Agent first looks up which credentials Ask Key has, then asks within the permissions you set." + "Once connected, an Agent can look up which credentials Ask Key has and request them within the permissions you set." ) AppLanguage.current = "zh-Hans" XCTAssertEqual(FrozenSettingsContract.emptyLibraryAction, "新建第一份凭证") diff --git a/Tests/AskKeyAppTests/ScreenPresentationTests.swift b/Tests/AskKeyAppTests/ScreenPresentationTests.swift index 8386de0..70a9543 100644 --- a/Tests/AskKeyAppTests/ScreenPresentationTests.swift +++ b/Tests/AskKeyAppTests/ScreenPresentationTests.swift @@ -97,7 +97,7 @@ final class ScreenPresentationTests: WorkspaceVisualContractTestSupport { let todayRows = presentation.sections[0].rows XCTAssertEqual(todayRows.map(\.time), ["18:18", "17:52"]) XCTAssertEqual(todayRows.map(\.sentence.plainText), [ - "Demo Agent 用 demo-api 运行 deploy.sh", // i18n-literal: Expected Simplified Chinese catalog value. + "Demo Agent 请求用 demo-api 运行 deploy.sh", // i18n-literal: Expected Simplified Chinese catalog value. "Claude Code 使用了 demo-api", // i18n-literal: Expected Simplified Chinese catalog value. ]) XCTAssertEqual(todayRows.map(\.resultTitle), ["已拒绝", "已允许"]) // i18n-literal: Expected Simplified Chinese catalog value. diff --git a/Tests/AskKeyAppTests/WelcomeStepsTests.swift b/Tests/AskKeyAppTests/WelcomeStepsTests.swift index 63ca088..e9109da 100644 --- a/Tests/AskKeyAppTests/WelcomeStepsTests.swift +++ b/Tests/AskKeyAppTests/WelcomeStepsTests.swift @@ -126,4 +126,19 @@ final class WelcomeStepsTests: AppLanguageExperienceTestSupport { for render in renders { XCTAssertGreaterThan(render.count, 8_000) } XCTAssertEqual(Set(renders).count, renders.count) } + + func testTryItStepMatchesTheSavedPermission() { + let previous = AppLanguage.current + defer { AppLanguage.current = previous } + AppLanguage.current = "en" + func trial(_ permission: CredentialPermission) -> String { + WelcomeStepsPresentation( + storedCredentialCount: 1, + savedCredential: .init(name: "demo-api", permission: permission) + ).steps[2].message + } + XCTAssertTrue(trial(.ask).contains("will ask you in a prompt")) + XCTAssertTrue(trial(.allowed).contains("without a prompt")) + XCTAssertTrue(trial(.hidden).contains("cannot see a Hidden credential")) + } } From 0942167f514f97346ed43030a79ce383da0d5e94 Mon Sep 17 00:00:00 2001 From: sudoHG Date: Mon, 5 Oct 2026 21:01:00 +0800 Subject: [PATCH 5/5] Document the pre-release checks on the maintainer's Mac --- docs/testing.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index 7e521aa..bf08a8f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -109,6 +109,16 @@ In credential mode each `xcrun notarytool submit … --wait` runs as: `PRIVATE_KEY_FILE`, `KEY_ID`, and `ISSUER_ID` are the credential's delivery mappings (file path, key id, and issuer id). The script expands them only in the helper's target shell and never prints their values. Tests may point `ASKKEY_RELEASE_HELPER` at a stub; that override is not a release input. Automation tests must not call a real notary service or use the maintainer's identity. +## Pre-release checks on the maintainer's Mac + +CI cannot check focus, Touch ID or real clients. Before notarizing, review the release diff for these behaviors, then install the signed `Release` build at `/Applications/Ask Key.app` and walk through this list once with the maintainer. The planner triggers agent requests itself (for example `askkey run --wait-for-approval` with a harmless command that prints no values, or a Codex session); the maintainer only touches the sensor, clicks decisions and reports what they see. Notarize the same build afterwards; its signature hash must match the one tested. + +- Every Touch ID entry: unlock management, reveal and copy a value, revoke a timed allowance, permanently delete, replace on import, settings actions, approving a read and a write. The prompt accepts a finger without a click. +- With another app's window behind Ask Key, unlock management: Ask Key stays in front afterwards, and its Dock icon does not disappear during the prompt. +- With the management window open behind another app, an agent request arrives: only the approval prompt appears; after Allow and Touch ID, focus returns to the other app. +- Clicking the menu-bar item with the management window open behind another app. +- The sample prompt from Agent access runs as written in a new client session; each supported client that is installed still reports Connected; an SSH request is reminded to look up credentials first. + ## Receipt and review Follow [the issue workflow](agents/issue-workflow.md) and [the PR template](../.github/pull_request_template.md). Report the base and head SHAs, changed paths, each acceptance command and outcome, Swift passed/failed/skipped counts when run, comparison with the base, and both CI job results. Explain checks omitted under a documentation-only issue rather than claiming they ran. Before pushing, build locally and run the tests for the changed code with `swift test --filter`, plus the hygiene and module checks; the full suite and desktop flows run in CI, and both CI jobs must be green before acceptance. Address conversation comments, submitted reviews, and inline threads as well as CI failures before presenting the PR for acceptance.