Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Sources/AskKeyAppKit/App/AppDelegate+Approval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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
Expand All @@ -240,6 +244,7 @@ extension AppDelegate {
}
}
}
if let privacyTimer { RunLoop.main.add(privacyTimer, forMode: .common) }
panel.level = .modalPanel
panel.makeKeyAndOrderFront(nil)
}
Expand Down
23 changes: 20 additions & 3 deletions Sources/AskKeyAppKit/App/AppDelegate+WindowManagement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -127,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
Expand Down Expand Up @@ -193,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
}
}
6 changes: 6 additions & 0 deletions Sources/AskKeyAppKit/App/ApprovalPromptContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
71 changes: 69 additions & 2 deletions Sources/AskKeyAppKit/ManagementAuthenticationProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -210,7 +213,9 @@ enum ManagementAuthenticationSubprocess {
outcome = .failed
}
DispatchQueue.main.async {
writeAndTerminate(ManagementAuthenticationResponse(outcome: outcome))
writeAndTerminate(ManagementAuthenticationResponse(
outcome: outcome, promptWasActive: NSApp.isActive
))
}
}
return true
Expand Down Expand Up @@ -289,13 +294,22 @@ 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)
}
}

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 { promptCount.value > 0 }
private static let promptCount = PromptCounter()

private let queue = DispatchQueue(label: "com.sudohg.askkey.authentication")
private let executableURL: URL?
private let timeout: TimeInterval
Expand Down Expand Up @@ -336,6 +350,22 @@ final class ManagementAuthenticationRunner: @unchecked Sendable {
presentation: presentation
) else { return .failed }
let process = Process()
let wasActive = ActivationFlag()
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, and not when
// the user moved to another app while the prompt was open.
defer {
DispatchQueue.main.async {
Self.promptCount.decrement()
NotificationCenter.default.post(name: .managementAuthenticationPromptDidEnd, object: nil)
// NSApp is nil when the runner is exercised without an app.
guard wasActive.isSet, !userSwitchedAway.isSet,
let app = NSApp as NSApplication? else { return }
app.activate()
}
}
process.executableURL = executable
process.arguments = ManagementAuthenticationSubprocess.arguments(
language: presentation.language,
Expand All @@ -356,11 +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) {
NSApp.yieldActivation(to: prompt)
app.yieldActivation(to: prompt)
}
}
_ = handedOver.wait(timeout: .now() + 2)
try input.fileHandleForWriting.write(contentsOf: payloadData)
try input.fileHandleForWriting.close()
} catch {
Expand All @@ -381,6 +419,7 @@ final class ManagementAuthenticationRunner: @unchecked Sendable {
) else {
return .failed
}
if response.promptWasActive == false { userSwitchedAway.set() }
return response.outcome
}

Expand All @@ -404,3 +443,31 @@ 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 } }
}

/// 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) } }
}

extension Notification.Name {
static let managementAuthenticationPromptDidEnd = Notification.Name("ManagementAuthenticationPromptDidEnd")
}
103 changes: 80 additions & 23 deletions Sources/AskKeyAppKit/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
5 changes: 4 additions & 1 deletion Sources/AskKeyAppKit/SessionPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down
5 changes: 5 additions & 0 deletions Sources/AskKeyAppKit/VaultViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading