From a927dd15964958df16191e291d7713a5161bba5d Mon Sep 17 00:00:00 2001 From: sudoHG Date: Fri, 9 Oct 2026 10:06:12 +0800 Subject: [PATCH 1/3] Make agent approval cards readable at a glance Titles name the object (credential, value or groups) and the requester. Read cards show the full command, what it receives and the unverified purpose; write cards use fixed sections with a change summary, status tags, plain-language item rows and a value box only for new or replaced values; organize steps read as sentences with no-change and merge tags. The card body scrolls under a 680-point cap with a persistent scroll bar and an overflow line, so actions and the footer always stay visible. Buttons and the footer state each decision's consequence, the pending list uses the same object-naming verbs, and the catalog carries English and Simplified Chinese copy. Display and copy only: the Broker, Vault, approval digests and summaries are unchanged. --- .../App/ApprovalCardComponents.swift | 146 ++ Sources/AskKeyAppKit/App/ApprovalCopy.swift | 110 ++ .../App/ApprovalPromptContent.swift | 132 +- .../AskKeyAppKit/App/ApprovalScrollArea.swift | 194 +++ .../App/FrozenAgentApprovalPrompt.swift | 268 ++-- .../App/FrozenApprovalActions.swift | 60 +- .../FrozenOrganizationApprovalContent.swift | 127 +- .../App/FrozenWriteApprovalContent.swift | 225 ++- .../App/FrozenWriteRevealCopy.swift | 4 +- .../App/FrozenWriteSummaryContent.swift | 210 ++- .../Resources/Localizable.xcstrings | 1353 ++++++++--------- .../PendingRequestPresentation.swift | 27 +- .../AskKeyAppTests/ApprovalCardFixtures.swift | 173 +++ .../ApprovalCardLayoutTests.swift | 99 ++ .../ApprovalMetadataContentTests.swift | 171 --- .../ApprovalPromptContentTests.swift | 292 ++-- .../Batch4SettingsLanguageTests.swift | 8 +- .../FrozenWriteSummaryContentTests.swift | 235 ++- .../LocalizationUnificationTests.swift | 4 +- .../OrganizationApprovalContentTests.swift | 222 ++- .../ScreenPresentationTests.swift | 15 +- .../WorkspaceInteractionTests.swift | 12 +- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 70 +- 23 files changed, 2533 insertions(+), 1624 deletions(-) create mode 100644 Sources/AskKeyAppKit/App/ApprovalCardComponents.swift create mode 100644 Sources/AskKeyAppKit/App/ApprovalCopy.swift create mode 100644 Sources/AskKeyAppKit/App/ApprovalScrollArea.swift create mode 100644 Tests/AskKeyAppTests/ApprovalCardFixtures.swift create mode 100644 Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift delete mode 100644 Tests/AskKeyAppTests/ApprovalMetadataContentTests.swift diff --git a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift new file mode 100644 index 0000000..87e2224 --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift @@ -0,0 +1,146 @@ +import AppKit +import SwiftUI + +/// A fixed card section: its heading, an optional status tag, then content. +struct ApprovalSection: View { + let title: String + var tag: ApprovalTag? + let identifier: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { + Text(verbatim: title) + .font(Theme.Fonts.caption.weight(.semibold)) + .foregroundStyle(Theme.textSecondary) + if let tag { ApprovalTagView(tag: tag) } + } + content + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.text) + } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .approvalScrollMarker() + .accessibilityElement(children: .contain) + .accessibilityIdentifier(identifier) + } +} + +struct ApprovalTagView: View { + let tag: ApprovalTag + + var body: some View { + Text(verbatim: tag.title) + .font(Theme.Fonts.caption.weight(.semibold)) + .foregroundStyle(tag.color) + .padding(.horizontal, Theme.Spacing.xs) + .padding(.vertical, 1) + .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Spacing.xs)) + .fixedSize() + } +} + +extension ApprovalTag { + /// Grey for no change, blue for edits, green for additions, orange for + /// replaced values and merges, red for removals; new groups stay neutral. + var color: Color { + switch self { + case .unchanged, .noChange: return Theme.textSecondary + case .changed: return ApprovalTagPalette.blue + case .new: return ApprovalTagPalette.green + case .newGroup: return Theme.text + case .replaced, .mayBeReplaced, .merge: return ApprovalTagPalette.orange + case .removed: return Theme.warning + } + } +} + +private enum ApprovalTagPalette { + static let blue = dynamic(light: 0x0A60C2, dark: 0x6CB6FF) + static let green = dynamic(light: 0x1B7F3B, dark: 0x5FD38A) + static let orange = dynamic(light: 0xB35300, dark: 0xFFA657) + + private static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let hex = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light + return NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, alpha: 1) + }) + } +} + +/// Card text whose variable names render in the monospaced face. A status +/// tag leads the first line and the text wraps under it at full width. +struct ApprovalLineText: View { + let line: ApprovalLine + var tag: ApprovalTag? + + var body: some View { + Text(attributed) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var attributed: AttributedString { + var result = AttributedString() + if let tag { + var label = AttributedString("\u{2009}" + tag.title + "\u{2009}") + label.font = Theme.Fonts.caption.weight(.semibold) + label.foregroundColor = tag.color + label.backgroundColor = Theme.neutralSubtle + result += label + AttributedString(" ") + } + for segment in line.segments { + var part = AttributedString(segment.text) + if segment.code { part.font = Theme.Fonts.mono } + result += part + } + return result + } +} + +enum ApprovalButtonRole: Equatable { + case primary, secondary, destructive +} + +/// Full-width stacked alert button: the one filled accent action, a white +/// bordered secondary action, or a bordered red action whose effect is hard +/// to undo. "Deny" is secondary, never red. +struct ApprovalPromptButton: View { + let title: String + let role: ApprovalButtonRole + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(Theme.Fonts.body) + .foregroundStyle(foreground) + .lineLimit(1) + .minimumScaleFactor(0.85) + .padding(.horizontal, Theme.Spacing.sm) + .frame(maxWidth: .infinity) + .frame(height: Theme.controlHeight) + .background(role == .primary ? Theme.accent : Theme.surface, in: .rect(cornerRadius: Theme.Radius.control)) + .overlay { + if role != .primary { + RoundedRectangle(cornerRadius: Theme.Radius.control) + .stroke(role == .destructive ? Theme.warning.opacity(0.45) : Theme.separator) + } + } + .shadow(color: Theme.cardShadow, radius: 1, y: 0.5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var foreground: Color { + switch role { + case .primary: return Theme.onAccent + case .secondary: return Theme.text + case .destructive: return Theme.warning + } + } +} diff --git a/Sources/AskKeyAppKit/App/ApprovalCopy.swift b/Sources/AskKeyAppKit/App/ApprovalCopy.swift new file mode 100644 index 0000000..8c99d0a --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalCopy.swift @@ -0,0 +1,110 @@ +import Foundation +import AskKeyBroker + +/// Shared wording for approval cards and the pending list. Credential and +/// group names are quoted the catalog's way and only break inside when one +/// line cannot hold them. +enum ApprovalCopy { + static func requester(_ request: BrokerApprovalOperationRequest) -> String { + request.callerName.flatMap { $0.isEmpty ? nil : $0 } ?? appLocalized("Local Agent") + } + + static func quoted(_ name: String) -> String { + appLocalizedFormat("Quoted name: %@", unbreakable(name)) + } + + /// Word joiners and no-break spaces keep a name on one line when it fits; + /// a name longer than a line still wraps by character. + static func unbreakable(_ name: String) -> String { + let joiner: Character = "\u{2060}" + var result = String(joiner) + for character in name { + result.append(character == " " ? "\u{00A0}" : character) + result.append(joiner) + } + return result + } + + static func group(_ name: String?) -> String { + quoted(name ?? appLocalized("Ungrouped")) + } + + static func bytes(_ count: Int) -> String { + count == 1 + ? appLocalized("1 byte") + : appLocalizedFormat("%@ bytes", count.formatted(.number.locale(AppLanguage.locale(for: AppLanguage.current)))) + } + + /// Rule-5 delivery wording; the variable name is the code argument. + static func delivery(_ delivery: BrokerComponentDelivery) -> ApprovalLine { + switch delivery { + case .environmentVariable(let variable): + return ApprovalLine(format: appLocalized("given to programs as environment variable %@"), code: [variable]) + case .temporaryFile(let variable): + return ApprovalLine(format: appLocalized("given to programs as a temporary file (path in %@)"), code: [variable]) + case .none: + return ApprovalLine(appLocalized("kept in Ask Key only, never given to agents")) + } + } + + /// "3 credentials (2 hidden from agents)"; the clause is omitted at zero. + static func members(_ count: Int, hidden: Int) -> String { + let members = count == 1 + ? appLocalizedFormat("%lld credential", count) + : appLocalizedFormat("%lld credentials", count) + return hidden > 0 ? appLocalizedFormat("%1$@ (%2$lld hidden from agents)", members, hidden) : members + } + + static func sentences(_ parts: [String]) -> String { + parts.joined(separator: appLocalized("Sentence separator")) + } +} + +/// One line of card text in which variable names render as code. +struct ApprovalLine: Equatable { + struct Segment: Equatable { + let text: String + let code: Bool + } + + let segments: [Segment] + + var plainText: String { segments.map(\.text).joined() } + + init(_ text: String) { + segments = text.isEmpty ? [] : [Segment(text: text, code: false)] + } + + /// Splits a localized `%@` format; arguments listed in `code` render as code. + init(format: String, plain: [String] = [], code: [String] = []) { + let arguments = plain + code + segments = EmphasizedSentence(format: format, arguments: arguments).runs.map { run in + Segment(text: run.text, code: run.argument.map { $0 >= plain.count } ?? false) + } + } + + private init(segments: [Segment]) { self.segments = segments } + + static func + (lhs: ApprovalLine, rhs: ApprovalLine) -> ApprovalLine { + ApprovalLine(segments: lhs.segments + rhs.segments) + } +} + +/// Small status labels on neutral backgrounds. Color carries risk, not novelty. +enum ApprovalTag: Equatable { + case unchanged, changed, new, newGroup, replaced, mayBeReplaced, removed, noChange, merge + + var title: String { + switch self { + case .unchanged: return appLocalized("Unchanged") + case .changed: return appLocalized("Changed") + case .new: return appLocalized("New") + case .newGroup: return appLocalized("New group tag") + case .replaced: return appLocalized("Replaced") + case .mayBeReplaced: return appLocalized("May be replaced") + case .removed: return appLocalized("Removed") + case .noChange: return appLocalized("No change") + case .merge: return appLocalized("Merge") + } + } +} diff --git a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift index 37e16f5..5d6c45f 100644 --- a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift +++ b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift @@ -1,8 +1,7 @@ -import AppKit import AskKeyBroker -/// Copy and detail rows shown by the approval prompt, derived from the pending -/// request. Caller and purpose are self-declared; the command, working +/// Copy shown by the approval card, derived from the pending request. The +/// requester's name and purpose are self-declared; the command, working /// directory and delivered names come from the App-derived display summary. struct ApprovalPromptContent: Equatable { struct Row: Equatable { @@ -12,60 +11,59 @@ struct ApprovalPromptContent: Equatable { } let operation: BrokerApprovalOperation + let requester: String let title: String - /// One-line target summary, already middle-truncated by the display summary. - let commandSummary: String? - let fullCommand: String? - let rows: [Row] + /// The whole command line, never shortened. + let command: String? + /// What a read hands the command if allowed, one line per variable. + let receives: [ApprovalLine] + let purpose: String? + let detailRows: [Row] - init(request: BrokerApprovalOperationRequest, credentialName: String) { - let caller = request.callerName ?? appLocalized("Local Agent") + init(request: BrokerApprovalOperationRequest, credentialName: String, + valueOnlyChange: Bool = false, organizationSteps: Int? = nil) { + let requester = ApprovalCopy.requester(request) + self.requester = requester operation = request.operation - switch request.operation { - case .read: title = appLocalizedFormat("“%@” wants to use “%@”", caller, credentialName) - case .create: title = appLocalizedFormat("“%@” wants to create “%@”", caller, credentialName) - case .modify: title = appLocalizedFormat("“%@” wants to modify “%@”", caller, credentialName) - case .delete: title = appLocalizedFormat("“%@” wants to delete “%@”", caller, credentialName) - case .organize: title = appLocalizedFormat("“%@” wants to organize credentials", caller) + if request.operation == .organize { + title = Self.organizationTitle(requester: requester, steps: organizationSteps) + } else { + title = EmphasizedSentence(format: Self.titleFormat(request.operation, valueOnlyChange: valueOnlyChange), + arguments: [requester, ApprovalCopy.quoted(credentialName)]).plainText } let display = request.operation == .read ? request.display : nil - commandSummary = display?.commandSummary - fullCommand = display?.commandLine + command = display?.commandLine + receives = request.operation == .read ? Self.receives(display, credentialName: credentialName) : [] + purpose = request.callerPurpose.flatMap { $0.isEmpty ? nil : $0 } var rows: [Row] = [] - if let delivered = Self.deliveredNames(display) { - rows.append(Row(label: appLocalized("Delivers"), value: delivered, monospaced: false)) - } 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"), - value: appLocalizedFormat("%@ (self-declared, unverified)", purpose), - monospaced: false - )) + rows.append(Row(label: appLocalized("Runs in"), value: directory, monospaced: true)) } - if request.operation == .delete { - rows.append(Row( - label: appLocalized("Destination"), - value: appLocalized("Recycle Bin · Recoverable for 30 days"), - monospaced: false - )) + rows.append(Row(label: appLocalized("Requested by"), + value: appLocalizedFormat("%@ (name provided by the requester; Ask Key can't verify it)", requester), + monospaced: false)) + detailRows = rows + } + + /// Shared with the pending list so both name the object the same way. + static func titleFormat(_ operation: BrokerApprovalOperation, valueOnlyChange: Bool = false) -> String { + switch operation { + case .read: return appLocalized("%1$@ wants to use the credential %2$@") + case .create: return appLocalized("%1$@ wants to create the credential %2$@") + case .modify: + return valueOnlyChange + ? appLocalized("%1$@ wants to replace the value of %2$@") + : appLocalized("%1$@ wants to change the credential %2$@") + case .delete: return appLocalized("%1$@ wants to delete the credential %2$@") + case .organize: return appLocalized("%@ wants to reorganize your groups") } - self.rows = rows } - /// Rows for Details. The full command is added when the one-line summary - /// cannot show all of it. - func detailRows(commandFits: Bool) -> [Row] { - guard let fullCommand, !fullCommand.isEmpty, !commandFits else { return rows } - return [Row(label: appLocalized("Command"), value: fullCommand, monospaced: true)] + rows + static func organizationTitle(requester: String, steps: Int?) -> String { + guard let steps else { return appLocalizedFormat("%@ wants to reorganize your groups", requester) } + return steps == 1 + ? appLocalizedFormat("%@ wants to reorganize your groups (1 step)", requester) + : appLocalizedFormat("%1$@ wants to reorganize your groups (%2$lld steps)", requester, steps) } var cancelledAuthenticationNote: String { @@ -74,31 +72,21 @@ struct ApprovalPromptContent: Equatable { : appLocalized("You cancelled authentication. Nothing was changed, and the request is still pending.") } - var retryTitle: String { - operation == .read ? appLocalized("Authenticate and allow") : appLocalized("Authenticate and approve") - } - - /// Names only: nil when the mapping is unavailable before approval - /// (bundles), so the prompt shows no delivered names at all. - static func deliveredNames(_ display: BrokerApprovalOperationRequest.Display?) -> String? { - guard let display, - let environment = display.environmentVariables, - let files = display.temporaryFileVariables else { return nil } - let names = environment + files.map { appLocalizedFormat("%@ (file)", $0) } - guard !names.isEmpty else { return nil } - return names.joined(separator: appLocalized("List separator")) - } - - /// Whether "to run " shows the whole command on one line of the - /// given width: not truncated by the display summary and not clipped. - func commandFits(prefix: String, width: CGFloat) -> Bool { - guard let commandSummary, commandSummary == fullCommand else { return false } - let prefixWidth = (prefix + " " as NSString).size( - withAttributes: [.font: NSFont.systemFont(ofSize: 13)] - ).width - let commandWidth = (commandSummary as NSString).size( - withAttributes: [.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)] - ).width - return prefixWidth + commandWidth <= width + /// Variable names only; a multi-item credential's mapping is not opened + /// before approval, so its items are described without names. + static func receives(_ display: BrokerApprovalOperationRequest.Display?, credentialName: String) -> [ApprovalLine] { + guard let display else { return [] } + let name = ApprovalCopy.quoted(credentialName) + guard let environment = display.environmentVariables, let files = display.temporaryFileVariables else { + return [ApprovalLine(appLocalizedFormat( + "The items of %@ that are set to be given to programs (names are shown after you approve)", name))] + } + let rows = environment.map { + ApprovalLine(format: appLocalized("%1$@ · as environment variable %2$@"), plain: [name], code: [$0]) + } + files.map { + ApprovalLine(format: appLocalized("%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)"), + plain: [name], code: [$0]) + } + return rows.isEmpty ? [ApprovalLine(appLocalized("Nothing from this credential is given to the command"))] : rows } } diff --git a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift new file mode 100644 index 0000000..8176604 --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift @@ -0,0 +1,194 @@ +import SwiftUI + +/// What the overflow line counts while a capped region hides content. +enum ApprovalOverflowUnit: Equatable { + case lines(height: CGFloat) + case steps + case sections +} + +enum ApprovalOverflow { + /// The line shown while content is hidden below; nil once the end is visible. + static func hint(unit: ApprovalOverflowUnit, hiddenHeight: CGFloat, hiddenMarkers: Int) -> String? { + guard hiddenHeight > 1 else { return nil } + switch unit { + case .lines(let height): + let lines = max(1, Int(((hiddenHeight - 1) / max(height, 1)).rounded(.up))) + return lines == 1 + ? appLocalized("1 more line — scroll to see it") + : appLocalizedFormat("%lld more lines — scroll to see them", lines) + case .steps where hiddenMarkers > 0: + return hiddenMarkers == 1 + ? appLocalized("1 more step — scroll to see it") + : appLocalizedFormat("%lld more steps — scroll to see them", hiddenMarkers) + case .sections where hiddenMarkers > 0: + return hiddenMarkers == 1 + ? appLocalized("1 more section below — scroll to see it") + : appLocalizedFormat("%lld more sections below — scroll to see them", hiddenMarkers) + case .steps, .sections: + return appLocalized("More below — scroll to see it") + } + } +} + +/// A capped region that never cuts content silently: while its content is +/// taller than the space it gets, a scroll bar stays visible and a line under +/// it says how much is still below. +struct ApprovalScrollArea: View { + let space: String + let unit: ApprovalOverflowUnit + var maxHeight: CGFloat? + var indicatorOffset: CGFloat = 10 + var identifier: String? + @ViewBuilder let content: (ScrollViewProxy) -> Content + @State private var contentFrame: CGRect = .zero + @State private var viewport: CGFloat = 0 + @State private var markers: [CGFloat] = [] + + var body: some View { + VStack(spacing: Theme.Spacing.xs) { + ScrollViewReader { proxy in + ScrollView { + content(proxy) + .frame(maxWidth: .infinity, alignment: .leading) + .environment(\.approvalScrollSpace, space) + .background(GeometryReader { geometry in + Color.clear.preference(key: ApprovalScrollContentKey.self, + value: [space: geometry.frame(in: .named(space))]) + }) + } + .scrollIndicators(.never) + .coordinateSpace(name: space) + .accessibilityIdentifier(identifier ?? space) + .frame(maxHeight: maxHeight) + .background(GeometryReader { geometry in + Color.clear.preference(key: ApprovalScrollViewportKey.self, value: [space: geometry.size.height]) + }) + } + .overlay(alignment: .topTrailing) { + if overflows { indicator.offset(x: indicatorOffset) } + } + // Stays while the content overflows so the region never jumps. + if overflows { hintLabel(hint ?? appLocalized("Reached the end")) } + } + .onPreferenceChange(ApprovalScrollContentKey.self) { contentFrame = $0[space] ?? .zero } + .onPreferenceChange(ApprovalScrollViewportKey.self) { viewport = $0[space] ?? 0 } + .onPreferenceChange(ApprovalScrollMarkerKey.self) { markers = $0[space] ?? [] } + } + + private var overflows: Bool { contentFrame.height > viewport + 1 && viewport > 0 } + + private var hint: String? { + ApprovalOverflow.hint(unit: unit, hiddenHeight: contentFrame.maxY - viewport, + hiddenMarkers: markers.filter { $0 > viewport + 1 }.count) + } + + private var indicator: some View { + let overflow = max(contentFrame.height - viewport, 1) + let progress = min(1, max(0, -contentFrame.minY / overflow)) + let thumb = max(18, viewport * viewport / max(contentFrame.height, 1)) + return ZStack(alignment: .top) { + Capsule().fill(Theme.separator).frame(width: 4, height: viewport) + Capsule().fill(Theme.textTertiary).frame(width: 4, height: thumb) + .offset(y: progress * (viewport - thumb)) + } + .frame(width: 4, height: viewport, alignment: .top) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private func hintLabel(_ text: String) -> some View { + HStack(spacing: Theme.Spacing.xs) { + if hint != nil { Image(systemName: "chevron.down").imageScale(.small) } + Text(verbatim: text) + } + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("approval-overflow-hint") + } +} + +extension View { + /// Marks a step or section whose bottom edge the overflow line counts. + func approvalScrollMarker() -> some View { modifier(ApprovalScrollMarker()) } +} + +private struct ApprovalScrollMarker: ViewModifier { + @Environment(\.approvalScrollSpace) private var space + + func body(content: Content) -> some View { + content.background(GeometryReader { geometry in + Color.clear.preference(key: ApprovalScrollMarkerKey.self, + value: space.map { [$0: [geometry.frame(in: .named($0)).maxY]] } ?? [:]) + }) + } +} + +private struct ApprovalScrollSpaceKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +private extension EnvironmentValues { + var approvalScrollSpace: String? { + get { self[ApprovalScrollSpaceKey.self] } + set { self[ApprovalScrollSpaceKey.self] = newValue } + } +} + +// Keyed by area so nested areas never read each other's geometry. +private struct ApprovalScrollContentKey: PreferenceKey { + static let defaultValue: [String: CGRect] = [:] + static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { + value.merge(nextValue()) { $1 } + } +} + +private struct ApprovalScrollViewportKey: PreferenceKey { + static let defaultValue: [String: CGFloat] = [:] + static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) { + value.merge(nextValue()) { $1 } + } +} + +private struct ApprovalScrollMarkerKey: PreferenceKey { + static let defaultValue: [String: [CGFloat]] = [:] + static func reduce(value: inout [String: [CGFloat]], nextValue: () -> [String: [CGFloat]]) { + value.merge(nextValue()) { $0 + $1 } + } +} + +/// Stacks the card's parts and gives the one flexible part, the scrolling +/// body, whatever height remains under the cap, so the actions stay visible. +struct ApprovalCardLayout: Layout { + let maximumHeight: CGFloat + var minimumFlexibleHeight: CGFloat = 80 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? FrozenAgentApprovalPrompt.contentWidth + return CGSize(width: width, height: heights(width: width, subviews: subviews).reduce(0, +)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for (subview, height) in zip(subviews, heights(width: bounds.width, subviews: subviews)) { + subview.place(at: CGPoint(x: bounds.midX, y: y), anchor: .top, + proposal: ProposedViewSize(width: bounds.width, height: height)) + y += height + } + } + + private func heights(width: CGFloat, subviews: Subviews) -> [CGFloat] { + var heights = subviews.map { $0.sizeThatFits(ProposedViewSize(width: width, height: nil)).height } + let excess = heights.reduce(0, +) - maximumHeight + if excess > 0, let index = subviews.firstIndex(where: { $0[ApprovalCardFlexibleKey.self] }) { + heights[index] = max(minimumFlexibleHeight, heights[index] - excess) + } + return heights + } +} + +struct ApprovalCardFlexibleKey: LayoutValueKey { + static let defaultValue = false +} diff --git a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift index da5f261..302f9b1 100644 --- a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift +++ b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift @@ -2,10 +2,17 @@ import AppKit import SwiftUI import AskKeyBroker -/// The approval prompt in the native alert layout: app icon, who wants which -/// credential, what will run, a collapsed Details section and stacked actions. +/// The approval card in the native alert layout: app icon, a title naming who +/// wants to do what to which object, fixed sections that scroll as one body +/// when the card would outgrow the screen, and stacked actions that state +/// each decision's consequence. struct FrozenAgentApprovalPrompt: View { static let width: CGFloat = 300 + /// Keeps the actions on the 768-point CI screen with menu bar and Dock. + static let maximumHeight: CGFloat = 680 + static let contentWidth = width - 2 * Theme.Spacing.lg + static let commandLineHeight = ceil(NSLayoutManager().defaultLineHeight( + for: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular))) let request: BrokerApprovalOperationRequest var trustedCredentialName: String? = nil @@ -15,47 +22,34 @@ struct FrozenAgentApprovalPrompt: View { var writeSummary: BrokerCredentialWriteSummary? = nil var organizationSummary: BrokerOrganizationSummary? = nil var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)? = nil - /// The approval whose system authentication was cancelled. The prompt then + /// The approval whose system authentication was cancelled. The card then /// offers to authenticate again for that same decision, or to deny. var cancelledAuthenticationDecision: BrokerApprovalDecision? = nil var detailsExpanded = false let finish: (BrokerApprovalDecision?) -> Void @State private var showsDetails: Bool? - private var content: ApprovalPromptContent { - ApprovalPromptContent( - request: request, - credentialName: trustedCredentialName ?? request.credentialName ?? request.targetID - ) - } - - private var contentWidth: CGFloat { Self.width - 2 * Theme.Spacing.lg } + private var isRead: Bool { request.operation == .read } + private var isOrganize: Bool { request.operation == .organize } + /// Write and organize cards are long, so their icon is smaller. + private var compact: Bool { !isRead } var body: some View { let _ = AppLanguage.store.resolved - let content = content - let commandFits = content.commandFits(prefix: appLocalized("to run"), width: contentWidth) - let details = content.detailRows(commandFits: commandFits) - let expanded = showsDetails ?? detailsExpanded - VStack(spacing: 0) { - appIcon - Text(content.title) - .font(Theme.Fonts.body.bold()) - .foregroundStyle(Theme.text) - .multilineTextAlignment(.center) - .lineLimit(request.operation == .organize ? 3 : nil) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, Theme.Spacing.md) - if let command = content.commandSummary { - (Text(appLocalized("to run")).font(Theme.Fonts.body) - + Text(verbatim: " ") - + Text(verbatim: command).font(Theme.Fonts.mono)) - .foregroundStyle(Theme.text) - .lineLimit(1) - .truncationMode(.middle) - .help(Text(verbatim: content.fullCommand ?? command)) - .padding(.top, Theme.Spacing.sm) - } + let requester = ApprovalCopy.requester(request) + let write = isRead || isOrganize ? nil : writeSummary.map { FrozenWriteSummaryContent(summary: $0, requester: requester) } + let organization = isOrganize ? organizationSummary.map(FrozenOrganizationSummaryContent.init) : nil + let content = ApprovalPromptContent(request: request, + credentialName: trustedCredentialName ?? request.credentialName ?? request.targetID, + valueOnlyChange: write?.valueOnlyChange ?? false, organizationSteps: organization?.rows.count) + let primary = FrozenApprovalActions.primary(operation: request.operation, + valueOnlyChange: write?.valueOnlyChange ?? false, steps: organization?.rows.count, + destructive: organization?.isDestructive ?? false) + let topPadding = compact ? Theme.Spacing.lg : Theme.Spacing.xl + ApprovalCardLayout(maximumHeight: Self.maximumHeight - topPadding - Theme.Spacing.lg) { + header(content.title) + cardBody(content, write: write, organization: organization) + .layoutValue(key: ApprovalCardFlexibleKey.self, value: true) if cancelledAuthenticationDecision != nil { Text(content.cancelledAuthenticationNote) .font(Theme.Fonts.secondary) @@ -65,32 +59,13 @@ struct FrozenAgentApprovalPrompt: View { .padding(.top, Theme.Spacing.md) .accessibilityIdentifier("approval-authentication-cancelled") } - if !details.isEmpty { - detailsToggle(expanded: expanded) - .padding(.top, Theme.Spacing.md) - if expanded { - if request.operation == .organize { - ScrollView { detailRows(details) }.frame(maxHeight: 90) - .padding(.top, Theme.Spacing.md) - } else { - detailRows(details).padding(.top, Theme.Spacing.md) - } - } - } - if request.operation == .organize { - FrozenOrganizationApprovalContent(summary: organizationSummary) - .padding(.top, Theme.Spacing.lg) - } else if request.operation != .read { - FrozenWriteApprovalContent(writeSummary: writeSummary, revealMaterial: revealMaterial) - .padding(.top, Theme.Spacing.lg) - } - actions + actions(primary) .padding(.top, Theme.Spacing.lg) footer .padding(.top, Theme.Spacing.md) } .padding(.horizontal, Theme.Spacing.lg) - .padding(.top, Theme.Spacing.xl) + .padding(.top, topPadding) .padding(.bottom, Theme.Spacing.lg) .frame(width: Self.width) .fixedSize(horizontal: false, vertical: true) @@ -98,20 +73,87 @@ struct FrozenAgentApprovalPrompt: View { .environment(\.locale, AppLanguage.store.locale) } - private var appIcon: some View { - Image(nsImage: AppIcon.load()) - .resizable() - .interpolation(.high) - .frame(width: 64, height: 64) - .accessibilityHidden(true) + private func header(_ title: String) -> some View { + VStack(spacing: Theme.Spacing.md) { + Image(nsImage: AppIcon.load()) + .resizable() + .interpolation(.high) + .frame(width: compact ? 40 : 64, height: compact ? 40 : 64) + .accessibilityHidden(true) + Text(verbatim: title) + .font(Theme.Fonts.body.bold()) + .foregroundStyle(Theme.text) + .multilineTextAlignment(.center) + .lineLimit(5) + .fixedSize(horizontal: false, vertical: true) + .help(Text(verbatim: title)) + .accessibilityIdentifier("approval-title") + } + .frame(maxWidth: .infinity) + .padding(.bottom, Theme.Spacing.md) + } + + private func cardBody(_ content: ApprovalPromptContent, write: FrozenWriteSummaryContent?, + organization: FrozenOrganizationSummaryContent?) -> some View { + ApprovalScrollArea(space: "approval-body", unit: isOrganize ? .steps : .sections, + identifier: isOrganize ? "approval-organization-operations" : "approval-body") { proxy in + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + if isRead { + if let command = content.command { commandSection(command) } + if !content.receives.isEmpty { + ApprovalSection(title: appLocalized("If you allow, this command receives"), + identifier: "approval-delivers") { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(content.receives.enumerated()), id: \.offset) { _, line in + ApprovalLineText(line: line) + } + } + } + } + } + if let purpose = content.purpose { + ApprovalSection(title: appLocalized("Stated purpose (not verified)"), identifier: "approval-purpose") { + Text(verbatim: purpose).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + } + if let organization { + FrozenOrganizationApprovalContent(content: organization) + } else if !isRead && !isOrganize { + FrozenWriteApprovalContent(operation: request.operation, content: write, + requester: content.requester, revealMaterial: revealMaterial) + } + details(content.detailRows, proxy: proxy) + } + } } - private func detailsToggle(expanded: Bool) -> some View { + private func commandSection(_ command: String) -> some View { + ApprovalSection(title: appLocalized("Command to run"), identifier: "approval-command") { + ApprovalScrollArea(space: "approval-command-lines", unit: .lines(height: Self.commandLineHeight), + maxHeight: Self.commandLineHeight * 4 + 1, indicatorOffset: Theme.Spacing.sm - 2) { _ in + Text(verbatim: command) + .font(Theme.Fonts.mono) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, Theme.Spacing.sm) + .padding(.vertical, Theme.Spacing.xs) + .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.control)) + } + } + + @ViewBuilder + private func details(_ rows: [ApprovalPromptContent.Row], proxy: ScrollViewProxy) -> some View { + let expanded = showsDetails ?? detailsExpanded Button { showsDetails = !expanded + guard !expanded else { return } + DispatchQueue.main.async { + withAnimation { proxy.scrollTo("approval-details-rows", anchor: .bottom) } + } } label: { HStack(spacing: Theme.Spacing.xs) { - Text(appLocalized("Details")).font(Theme.Fonts.body) + Text(appLocalized("Details")).font(Theme.Fonts.secondary) Image(systemName: expanded ? "chevron.down" : "chevron.right") .font(Theme.Fonts.caption.weight(.semibold)) .imageScale(.small) @@ -121,11 +163,7 @@ struct FrozenAgentApprovalPrompt: View { } .buttonStyle(.plain) .accessibilityIdentifier("approval-details") - } - - private func detailRows(_ rows: [ApprovalPromptContent.Row]) -> some View { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - Rectangle().fill(Theme.separator).frame(height: 1) + if expanded { Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.md, verticalSpacing: Theme.Spacing.sm) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in GridRow { @@ -133,7 +171,7 @@ struct FrozenAgentApprovalPrompt: View { .foregroundStyle(Theme.textSecondary) .fixedSize() Text(verbatim: row.value) - .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.body) + .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.secondary) .foregroundStyle(Theme.text) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) @@ -141,83 +179,59 @@ struct FrozenAgentApprovalPrompt: View { } } } - .font(Theme.Fonts.body) + .font(Theme.Fonts.secondary) + .approvalScrollMarker() + .id("approval-details-rows") + .accessibilityIdentifier("approval-details-rows") } } - private var actions: some View { - VStack(spacing: Theme.Spacing.sm) { + private func actions(_ primary: FrozenApprovalActions.Primary) -> some View { + let unavailable = isOrganize && organizationSummary == nil + return VStack(spacing: Theme.Spacing.sm) { if let retry = cancelledAuthenticationDecision { - ApprovalPromptButton(title: content.retryTitle, primary: true) { finish(retry) } - .disabled(request.operation == .organize && organizationSummary == nil) + ApprovalPromptButton(title: FrozenApprovalActions.retry(retry, primary: primary, minutes: timedAllowanceMinutes), + role: primary.role) { finish(retry) } + .disabled(unavailable) .accessibilityIdentifier("approval-retry-authentication") } else { - ApprovalPromptButton(title: primaryTitle, primary: true) { finish(.once) } - .disabled(request.operation == .organize && organizationSummary == nil) + ApprovalPromptButton(title: primary.title, role: primary.role) { finish(.once) } + .disabled(unavailable) .accessibilityIdentifier("approval-allow-once") - if request.operation == .read, timedAllowanceEnabled { - ApprovalPromptButton( - title: appLocalizedFormat("Allow for %lld minutes", timedAllowanceMinutes), - primary: false - ) { finish(.timedAllow(duration: nil)) } - .help(appLocalized("Applies to all local callers for this credential · Revocable anytime")) - .accessibilityIdentifier("approval-allow-timed") + if isRead, timedAllowanceEnabled { + VStack(spacing: Theme.Spacing.xs) { + ApprovalPromptButton(title: FrozenApprovalActions.timed(minutes: timedAllowanceMinutes), + role: .secondary) { finish(.timedAllow(duration: nil)) } + .accessibilityIdentifier("approval-allow-timed") + Text(FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes)) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("approval-timed-scope") + } } } - ApprovalPromptButton(title: appLocalized("Deny"), primary: false) { finish(.deny) } + ApprovalPromptButton(title: appLocalized("Deny"), role: .secondary) { finish(.deny) } .accessibilityIdentifier("approval-deny") } } private var footer: some View { - HStack(spacing: Theme.Spacing.xs) { + Button { finish(nil) } label: { TimelineView(.periodic(from: .now, by: 1)) { context in - Text(appLocalizedFormat("Expires in %@", FrozenCountdown.format(deadline: expiresAt, now: context.date))) + Text(verbatim: appLocalizedFormat("Expires in %@", FrozenCountdown.format(deadline: expiresAt, now: context.date)) + + " · " + appLocalized("Esc to decide later in Pending requests")) .monospacedDigit() + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } - Text(verbatim: "·") - Button(appLocalized("Esc to decide later")) { finish(nil) } - .buttonStyle(.plain) - .keyboardShortcut(.cancelAction) } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) .font(Theme.Fonts.caption) .foregroundStyle(Theme.textTertiary) - } - - private var primaryTitle: String { - switch request.operation { - case .read: return appLocalized("Allow Once") - case .create: return appLocalized("Approve Creation") - case .modify: return appLocalized("Approve Change") - case .delete: return appLocalized("Approve Deletion") - case .organize: return appLocalized("Approve Organization") - } - } -} - -/// Full-width stacked alert button: the one filled accent action, or a white -/// bordered secondary action. "Deny" is secondary, never red. -private struct ApprovalPromptButton: View { - let title: String - let primary: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(Theme.Fonts.body) - .foregroundStyle(primary ? Theme.onAccent : Theme.text) - .frame(maxWidth: .infinity) - .frame(height: Theme.controlHeight) - .background(primary ? Theme.accent : Theme.surface, in: .rect(cornerRadius: Theme.Radius.control)) - .overlay { - if !primary { - RoundedRectangle(cornerRadius: Theme.Radius.control).stroke(Theme.separator) - } - } - .shadow(color: Theme.cardShadow, radius: 1, y: 0.5) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + .help(appLocalized("When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires.")) + .accessibilityIdentifier("approval-decide-later") } } diff --git a/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift b/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift index 8475e4a..3b4bd73 100644 --- a/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift +++ b/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift @@ -1,19 +1,55 @@ import AskKeyBroker +/// Button titles that state each decision's consequence. enum FrozenApprovalActions { - static func titles( - operation: BrokerApprovalOperation, - timedAllowanceEnabled: Bool - ) -> [String] { + struct Primary: Equatable { + let title: String + let role: ApprovalButtonRole + } + + static func primary(operation: BrokerApprovalOperation, valueOnlyChange: Bool = false, + steps: Int? = nil, destructive: Bool = false) -> Primary { switch operation { - case .read: - return timedAllowanceEnabled - ? [appLocalized("Allow Once"), appLocalizedFormat("Allow for %lld minutes", 30), appLocalized("Deny")] - : [appLocalized("Allow Once"), appLocalized("Deny")] - case .create: return [appLocalized("Approve Creation"), appLocalized("Deny")] - case .modify: return [appLocalized("Approve Change"), appLocalized("Deny")] - case .delete: return [appLocalized("Approve Deletion"), appLocalized("Deny")] - case .organize: return [appLocalized("Approve Organization"), appLocalized("Deny")] + case .read: return Primary(title: appLocalized("Allow Once"), role: .primary) + case .create: return Primary(title: appLocalized("Create Credential"), role: .primary) + case .modify: + return Primary(title: valueOnlyChange ? appLocalized("Replace Value") : appLocalized("Save Changes"), + role: .primary) + case .delete: return Primary(title: appLocalized("Move to Trash"), role: .destructive) + case .organize: + let title: String + switch steps { + case nil: title = appLocalized("Apply Steps") + case 1?: title = appLocalized("Apply 1 Step") + case let count?: title = appLocalizedFormat("Apply %lld Steps", count) + } + return Primary(title: title, role: destructive ? .destructive : .primary) + } + } + + static func timed(minutes: Int) -> String { + appLocalizedFormat("Allow for %lld Minutes", minutes) + } + + /// What the timed allowance covers, stated under its button. + static func timedScope(minutes: Int) -> String { + appLocalizedFormat("For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.", minutes) + } + + /// Retries the decision the user chose before cancelling authentication. + static func retry(_ decision: BrokerApprovalDecision, primary: Primary, minutes: Int) -> String { + if case .timedAllow = decision { + return appLocalizedFormat("Authenticate and %@", timed(minutes: minutes)) + } + return appLocalizedFormat("Authenticate and %@", primary.title) + } + + static func titles(operation: BrokerApprovalOperation, timedAllowanceEnabled: Bool, minutes: Int = 30, + valueOnlyChange: Bool = false, steps: Int? = nil) -> [String] { + let primary = primary(operation: operation, valueOnlyChange: valueOnlyChange, steps: steps).title + if operation == .read, timedAllowanceEnabled { + return [primary, timed(minutes: minutes), appLocalized("Deny")] } + return [primary, appLocalized("Deny")] } } diff --git a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift index cfd888f..522522e 100644 --- a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift @@ -1,88 +1,111 @@ import SwiftUI import AskKeyBroker -struct FrozenOrganizationSummaryContent { +/// Each frozen step as a sentence. Counts come from the summary, which +/// records them as they stand when that step runs. +struct FrozenOrganizationSummaryContent: Equatable { struct Row: Equatable { + let number: Int + let tag: ApprovalTag? let title: String let detail: String? } let rows: [Row] + /// Deleting or merging groups cannot be undone in one step. + let isDestructive: Bool init(summary: BrokerOrganizationSummary) { rows = summary.operations.enumerated().map { index, operation in + let tag: ApprovalTag? let title: String let detail: String? switch operation { case .move(let credential, let from, let to): - title = appLocalizedFormat("Move %@: %@ → %@", credential, - from ?? appLocalized("Ungrouped"), to ?? appLocalized("Ungrouped")) + tag = nil + title = appLocalizedFormat("Move the credential %1$@ from %2$@ to %3$@", + ApprovalCopy.quoted(credential), ApprovalCopy.group(from), ApprovalCopy.group(to)) detail = nil case .createGroup(let name): - title = appLocalizedFormat("Create group “%@”", name) - detail = appLocalized("New group — created when approved") - case .existingGroup(let name, let members, let nonvisible): - title = appLocalizedFormat("Create group “%@”", name) - detail = appLocalizedFormat("Group already exists — no changes. %@", - Self.memberCountDescription(members: members, nonvisible: nonvisible)) - case .renameGroup(let from, let to, let members, let nonvisible): - title = appLocalizedFormat("Rename group “%@” → “%@”", from, to) - detail = Self.memberCountDescription(members: members, nonvisible: nonvisible) - case .mergeGroup(let from, let to, let members, let nonvisible, let targetMembers, let targetNonvisible): - title = appLocalizedFormat("Merge group “%@” into existing group “%@”", from, to) - detail = appLocalizedFormat("Source: %@. Existing group: %@.", - Self.memberCountDescription(members: members, nonvisible: nonvisible), - Self.memberCountDescription(members: targetMembers, nonvisible: targetNonvisible)) - case .deleteGroup(let name, let members, let nonvisible): - title = appLocalizedFormat("Delete group “%@”", name) - detail = Self.memberCountDescription(members: members, nonvisible: nonvisible) + tag = nil + title = appLocalizedFormat("Create the group %@", ApprovalCopy.quoted(name)) + detail = nil + case .existingGroup(let name, let members, let hidden): + tag = .noChange + title = appLocalizedFormat("Create the group %@", ApprovalCopy.quoted(name)) + detail = ApprovalCopy.sentences([ + appLocalized("This group already exists, so nothing is created or changed."), + Self.contents(members: members, hidden: hidden), + ]) + case .renameGroup(let from, let to, let members, let hidden): + tag = nil + title = appLocalizedFormat("Rename the group %1$@ to %2$@", ApprovalCopy.quoted(from), ApprovalCopy.quoted(to)) + detail = Self.contents(members: members, hidden: hidden) + case .mergeGroup(let from, let to, let members, let hidden, let targetMembers, let targetHidden): + tag = .merge + title = appLocalizedFormat("Merge the group %1$@ into the existing group %2$@", + ApprovalCopy.quoted(from), ApprovalCopy.quoted(to)) + detail = ApprovalCopy.sentences([ + appLocalizedFormat("%1$@ disappears and %2$@ will have %3$@.", ApprovalCopy.quoted(from), + ApprovalCopy.quoted(to), ApprovalCopy.members(members + targetMembers, hidden: hidden + targetHidden)), + appLocalized("A merge can't be undone automatically."), + ]) + case .deleteGroup(let name, let members, let hidden): + tag = nil + title = appLocalizedFormat("Delete the group %@", ApprovalCopy.quoted(name)) + detail = members == 0 + ? appLocalized("The group is empty.") + : appLocalizedFormat("Its %1$@ won't be deleted and will become %2$@.", + ApprovalCopy.members(members, hidden: hidden), ApprovalCopy.group(nil)) + } + return Row(number: index + 1, tag: tag, title: title, detail: detail) + } + isDestructive = summary.operations.contains { + switch $0 { + case .deleteGroup, .mergeGroup: return true + case .move, .createGroup, .existingGroup, .renameGroup: return false } - return .init(title: String(index + 1) + ". " + title, detail: detail) } } - private static func memberCountDescription(members: Int, nonvisible: Int) -> String { - if nonvisible > 0 { - return members == 1 - ? appLocalizedFormat("%lld credential, %lld not visible to agents", members, nonvisible) - : appLocalizedFormat("%lld credentials, %lld not visible to agents", members, nonvisible) - } - return members == 1 - ? appLocalizedFormat("%lld credential", members) - : appLocalizedFormat("%lld credentials", members) + private static func contents(members: Int, hidden: Int) -> String { + members == 0 + ? appLocalized("The group is empty.") + : appLocalizedFormat("It has %@.", ApprovalCopy.members(members, hidden: hidden)) } } -/// All operations remain readable by scrolling; actions stay outside the cap. +/// The ordered steps; the card's scrolling body counts them while any are +/// still below the visible part. struct FrozenOrganizationApprovalContent: View { - let summary: BrokerOrganizationSummary? + let content: FrozenOrganizationSummaryContent var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - Text(appLocalized("Proposed organization")) - .font(Theme.Fonts.caption.weight(.semibold)) - if let summary { - let rows = FrozenOrganizationSummaryContent(summary: summary).rows - ScrollView { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Text(verbatim: row.title) - .foregroundStyle(Theme.text) - if let detail = row.detail { - Text(verbatim: detail).foregroundStyle(Theme.textSecondary) - } - } - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + ForEach(content.rows, id: \.number) { row in + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { + Text(verbatim: "\(row.number).") + .monospacedDigit() + .foregroundStyle(Theme.textSecondary) + .frame(minWidth: 18, alignment: .trailing) + VStack(alignment: .leading, spacing: 2) { + ApprovalLineText(line: ApprovalLine(row.title), tag: row.tag) + .foregroundStyle(Theme.text) + if let detail = row.detail { + Text(verbatim: detail) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) } } - .font(Theme.Fonts.caption) - .textSelection(.enabled) } - .frame(maxHeight: 210) - .accessibilityIdentifier("approval-organization-operations") + .textSelection(.enabled) + .approvalScrollMarker() + .accessibilityElement(children: .combine) + .accessibilityIdentifier("approval-organization-step-\(row.number)") } } + .font(Theme.Fonts.secondary) + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift index e647c90..f275541 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift @@ -1,9 +1,11 @@ import SwiftUI import AskKeyBroker -/// The operation's frozen summary and separately authenticated value reveal. +/// The write card's fixed sections and the separately authenticated value view. struct FrozenWriteApprovalContent: View { - var writeSummary: BrokerCredentialWriteSummary? + let operation: BrokerApprovalOperation + var content: FrozenWriteSummaryContent? + var requester: String var revealMaterial: (@MainActor () async throws -> FrozenApprovalMaterial)? @State private var revealedMaterial: FrozenApprovalMaterial? @State private var revealing = false @@ -11,30 +13,128 @@ struct FrozenWriteApprovalContent: View { @State private var revealTask: Task? var body: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - if let writeSummary { - let content = FrozenWriteSummaryContent(summary: writeSummary) - ScrollView { - summaryRows(content.components) + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + if let content { + if let summary = content.changeSummary { + Text(verbatim: summary) + .font(Theme.Fonts.secondary) + .foregroundStyle(Theme.text) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .approvalScrollMarker() + .accessibilityIdentifier("approval-change-summary") } - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .frame(maxHeight: 70) - .accessibilityIdentifier("approval-components") - metadataContent(content) - } - if writeSummary?.operation != .delete { - frozenContent + if !content.components.isEmpty { items(content) } + if !content.valueComponents.isEmpty { + valueBox(heading: content.valueHeading, components: content.valueComponents) + } + instructions(content) + group(content) + if let after = content.afterApproval { + ApprovalSection(title: appLocalized("After you approve"), identifier: "approval-after-approval") { + Text(verbatim: after).fixedSize(horizontal: false, vertical: true) + } + } + } else if operation == .create || operation == .modify { + valueBox(heading: appLocalizedFormat("New value (provided by %@)", requester), components: []) } } - .font(Theme.Fonts.secondary) .onDisappear { revealTask?.cancel(); revealTask = nil; revealedMaterial = nil } } - private var frozenContent: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - Text(appLocalized("Frozen Content to Write")) + private func items(_ content: FrozenWriteSummaryContent) -> some View { + ApprovalSection(title: appLocalizedFormat("Items (%lld)", content.itemCount), tag: content.componentsTag, + identifier: "approval-components") { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + ForEach(Array(content.components.enumerated()), id: \.offset) { _, component in + VStack(alignment: .leading, spacing: 2) { + ApprovalLineText(line: component.line, tag: component.tag) + ForEach(Array(component.notes.enumerated()), id: \.offset) { _, note in + ApprovalLineText(line: note) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + } + } + } + if content.overwritesValues { + Text(appLocalized("A replaced value overwrites the old one. There is no history.")) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + @ViewBuilder + private func instructions(_ content: FrozenWriteSummaryContent) -> some View { + switch content.instructions { + case .unchanged: + EmptyView() + case .current(let text): + ApprovalSection(title: appLocalized("Instructions for agents"), identifier: "approval-usage-instructions") { + Text(verbatim: text).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + case .changed(let before, let after): + ApprovalSection(title: appLocalized("Instructions for agents"), tag: content.instructionsTag, + identifier: "approval-usage-instructions") { + comparison(before: Text(verbatim: before), after: Text(verbatim: after)) + } + } + } + + @ViewBuilder + private func group(_ content: FrozenWriteSummaryContent) -> some View { + switch content.group { + case .unchanged: + EmptyView() + case .current(let name): + ApprovalSection(title: appLocalized("Group"), identifier: "approval-group-changes") { + groupName(name, createsGroup: content.createsGroup) + } + case .changed(let before, let after): + ApprovalSection(title: appLocalized("Group"), tag: content.groupTag, identifier: "approval-group-changes") { + comparison(before: Text(verbatim: before), after: groupName(after, createsGroup: content.createsGroup)) + } + } + } + + private func groupName(_ name: String, createsGroup: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: name).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + if createsGroup { + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { + ApprovalTagView(tag: .newGroup) + Text(appLocalized("created when you approve")) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("approval-new-group") + } + } + } + + private func comparison(before: some View, after: some View) -> some View { + Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.sm, verticalSpacing: Theme.Spacing.xs) { + GridRow { + Text(appLocalized("Before")).foregroundStyle(Theme.textSecondary).fixedSize() + before.textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + GridRow { + Text(appLocalized("After")).foregroundStyle(Theme.textSecondary).fixedSize() + after.textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private func valueBox(heading: String, components: [FrozenWriteSummaryContent.Component]) -> some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text(verbatim: heading) .font(Theme.Fonts.caption.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) if let revealedMaterial { HStack { Text(revealedMaterial.title).lineLimit(1) @@ -42,82 +142,43 @@ struct FrozenWriteApprovalContent: View { Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary) Button(appLocalized("Hide")) { self.revealedMaterial = nil } }.font(Theme.Fonts.caption) - ScrollView { + ApprovalScrollArea(space: "approval-revealed-value", unit: .lines(height: 15), maxHeight: 85, + indicatorOffset: Theme.Spacing.sm) { _ in Text(verbatim: revealedMaterial.content) .font(Theme.Fonts.mono) .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - }.frame(height: 85) + .fixedSize(horizontal: false, vertical: true) + } } else { - HStack { - Text("••••••••").foregroundStyle(Theme.textSecondary) - Spacer() - Button(appLocalized("Authenticate and View"), action: reveal) - .disabled(revealMaterial == nil || revealing) - .accessibilityIdentifier("approval-reveal-frozen-material") + ForEach(Array(components.enumerated()), id: \.offset) { _, component in + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { + Text(verbatim: component.name).lineLimit(1).truncationMode(.middle) + Text(verbatim: ApprovalCopy.bytes(component.byteCount)).foregroundStyle(Theme.textSecondary).fixedSize() + Spacer(minLength: Theme.Spacing.xs) + Text(verbatim: "••••••••").foregroundStyle(Theme.textSecondary).fixedSize() + } } - Text(appLocalized("Viewing requires separate authentication and does not approve this request.")) - .font(Theme.Fonts.caption).foregroundStyle(Theme.textSecondary) + Button(appLocalized("Authenticate to View"), action: reveal) + .disabled(revealMaterial == nil || revealing) + .accessibilityIdentifier("approval-reveal-frozen-material") + Text(appLocalized("This is the value saved if you approve. Viewing needs another authentication and does not approve the request.")) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) } if revealFailed { Text(appLocalized("Unable to view: authentication was not completed or the request is no longer valid.")) .font(Theme.Fonts.caption).foregroundStyle(Theme.warning) + .fixedSize(horizontal: false, vertical: true) } } + .font(Theme.Fonts.secondary) .padding(Theme.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.group)) - } - - private func metadataContent(_ content: FrozenWriteSummaryContent) -> some View { - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Text(appLocalized("Usage instructions")) - .font(Theme.Fonts.caption.weight(.semibold)) - ScrollView { - summaryRows(content.instructions) - } - .frame(maxHeight: 60) - .accessibilityIdentifier("approval-usage-instructions") - Text(appLocalized("Group")) - .font(Theme.Fonts.caption.weight(.semibold)) - ScrollView { - summaryRows(content.group) - } - .frame(maxHeight: 30) - .accessibilityIdentifier("approval-group-changes") - if content.createsGroup { - Text(appLocalized("New group — created when approved")) - .foregroundStyle(Theme.warning) - .accessibilityIdentifier("approval-new-group") - } - } - .font(Theme.Fonts.caption) - .accessibilityIdentifier("approval-credential-metadata") - } - - private func summaryRows(_ rows: [FrozenWriteSummaryContent.Row]) -> some View { - Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.sm, verticalSpacing: Theme.Spacing.xs) { - ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - GridRow { - if let label = row.label { - Text(verbatim: label).foregroundStyle(Theme.textSecondary).fixedSize() - } - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - ForEach(Array(row.values.enumerated()), id: \.offset) { _, value in - summaryText(value) - } - } - .gridCellColumns(row.label == nil ? 2 : 1) - } - } - }.frame(maxWidth: .infinity, alignment: .leading) - } - - private func summaryText(_ value: String) -> some View { - Text(verbatim: value) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + .approvalScrollMarker() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("approval-value-box") } private func reveal() { diff --git a/Sources/AskKeyAppKit/App/FrozenWriteRevealCopy.swift b/Sources/AskKeyAppKit/App/FrozenWriteRevealCopy.swift index 962146e..c66cb68 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteRevealCopy.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteRevealCopy.swift @@ -1,6 +1,8 @@ enum FrozenWriteRevealCopy { + /// A create has no previous value, so only the value to save is shown. static func content(before: String, after: String) -> String { - appLocalized("Before") + "\n" + before + guard !before.isEmpty else { return after } + return appLocalized("Before") + "\n" + before + "\n\n" + appLocalized("After") + "\n" + after } } diff --git a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift index a303d67..c59b6f3 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift @@ -1,61 +1,187 @@ +import Foundation import AskKeyBroker /// Presentation only: the frozen summary remains the approval's source of truth. -struct FrozenWriteSummaryContent { - struct Row: Equatable { - let label: String? - let values: [String] +struct FrozenWriteSummaryContent: Equatable { + struct Component: Equatable { + let name: String + /// Set on modify only; create and delete show plain rows. + let tag: ApprovalTag? + let line: ApprovalLine + let notes: [ApprovalLine] + /// Listed in the value box: its value is new or may have been replaced. + let carriesValue: Bool + let byteCount: Int } - let components: [Row] - let instructions: [Row] - let group: [Row] + enum Change: Equatable { + /// Create and delete show the one value involved. + case current(String) + case changed(before: String, after: String) + /// Modify: folded into the change summary line. + case unchanged + } + + let operation: BrokerApprovalOperation + /// Modify only: "Changes: … · Unchanged: …". + let changeSummary: String? + /// Empty when a modify leaves every item as it was. + let components: [Component] + let itemCount: Int + let componentsTag: ApprovalTag? + let overwritesValues: Bool + let instructions: Change + let instructionsTag: ApprovalTag? + let group: Change + let groupTag: ApprovalTag? let createsGroup: Bool + let afterApproval: String? + let valueOnlyChange: Bool + let valueHeading: String - init(summary: BrokerCredentialWriteSummary) { - let beforeComponents = summary.before.map(Self.componentText) - let afterComponents = summary.after.map(Self.componentText) - if beforeComponents.isEmpty && afterComponents.isEmpty { - components = [] - } else { - // Equal byte counts and delivery descriptors do not prove equal values. - let unchanged = summary.before == summary.after && summary.beforeDigest != nil - && summary.beforeDigest == summary.afterDigest - components = Self.rows(operation: summary.operation, before: beforeComponents, - after: afterComponents, unchanged: unchanged) + var valueComponents: [Component] { components.filter(\.carriesValue) } + + init(summary: BrokerCredentialWriteSummary, requester: String) { + operation = summary.operation + let modify = summary.operation == .modify + let components = modify + ? Self.modifiedComponents(summary, requester: requester) + : (summary.operation == .delete ? summary.before : summary.after).map { + Component(name: $0.name, tag: nil, line: Self.line($0), notes: [], + carriesValue: summary.operation == .create, byteCount: $0.byteCount) + } + let componentsChanged = !modify || components.contains { $0.tag != .unchanged } + self.components = componentsChanged ? components : [] + itemCount = (summary.operation == .delete ? summary.before : summary.after).count + componentsTag = modify && componentsChanged ? .changed : nil + overwritesValues = components.contains { [.replaced, .mayBeReplaced, .changed].contains($0.tag) } + + let beforeInstructions = summary.beforeUsageInstructions ?? "" + let afterInstructions = summary.afterUsageInstructions ?? "" + let instructionsChanged = beforeInstructions != afterInstructions + let groupChanged = summary.beforeGroup != summary.afterGroup + switch summary.operation { + case .modify: + instructions = instructionsChanged + ? .changed(before: Self.instructionsText(beforeInstructions), after: Self.instructionsText(afterInstructions)) + : .unchanged + group = groupChanged + ? .changed(before: Self.groupText(summary.beforeGroup), after: Self.groupText(summary.afterGroup)) + : .unchanged + case .delete, .read, .organize: + instructions = .current(Self.instructionsText(beforeInstructions)) + group = .current(Self.groupText(summary.beforeGroup)) + case .create: + instructions = .current(Self.instructionsText(afterInstructions)) + group = .current(Self.groupText(summary.afterGroup)) } - instructions = Self.rows(operation: summary.operation, - before: [Self.instructionsText(summary.beforeUsageInstructions)], - after: [Self.instructionsText(summary.afterUsageInstructions)], - unchanged: (summary.beforeUsageInstructions ?? "") == (summary.afterUsageInstructions ?? "")) - group = Self.rows(operation: summary.operation, - before: [summary.beforeGroup ?? appLocalized("Ungrouped")], - after: [summary.afterGroup ?? appLocalized("Ungrouped")], - unchanged: summary.beforeGroup == summary.afterGroup) + instructionsTag = modify && instructionsChanged ? .changed : nil + groupTag = modify && groupChanged ? .changed : nil createsGroup = summary.createsGroup && (summary.operation == .create || summary.operation == .modify) + changeSummary = modify ? Self.changeSummary(items: componentsChanged, + instructions: instructionsChanged, group: groupChanged) : nil + valueOnlyChange = modify && componentsChanged && !instructionsChanged && !groupChanged + && components.allSatisfy { [.unchanged, .replaced, .mayBeReplaced].contains($0.tag) } + switch summary.operation { + case .create: + afterApproval = appLocalized("The credential's agent permission will be Ask every time: agents need your approval each time they use it.") + case .delete: + afterApproval = appLocalized("It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.") + case .read, .modify, .organize: + afterApproval = nil + } + valueHeading = summary.operation == .create + ? appLocalizedFormat("Value to save (provided by %@)", requester) + : appLocalizedFormat("New value (provided by %@)", requester) } - private static func rows(operation: BrokerApprovalOperation, before: [String], - after: [String], unchanged: Bool) -> [Row] { - switch operation { - case .create: - return after.isEmpty ? [] : [.init(label: nil, values: after)] - case .delete, .read, .organize: - return before.isEmpty ? [] : [.init(label: nil, values: before)] - case .modify: - if unchanged { - return [.init(label: appLocalized("Unchanged"), values: before)] + /// The summary carries one digest over all items, so a same-size item can + /// be proven unchanged or replaced only when nothing else differs. + private static func modifiedComponents(_ summary: BrokerCredentialWriteSummary, + requester: String) -> [Component] { + let digestsEqual = summary.beforeDigest != nil && summary.beforeDigest == summary.afterDigest + let soleReplacement = !digestsEqual && summary.beforeDigest != nil && summary.afterDigest != nil + && summary.before == summary.after && summary.after.count == 1 + let before = Dictionary(summary.before.map { (normalized($0.name), $0) }, uniquingKeysWith: { first, _ in first }) + let afterNames = Set(summary.after.map { normalized($0.name) }) + var rows = summary.after.map { after -> Component in + guard let old = before[normalized(after.name)] else { + return Component(name: after.name, tag: .new, line: line(after), notes: [], + carriesValue: true, byteCount: after.byteCount) } - return [.init(label: appLocalized("Before"), values: before), - .init(label: appLocalized("After"), values: after)] + if old.byteCount != after.byteCount || old.payloadKind != after.payloadKind { + var notes = [ApprovalLine(appLocalizedFormat("old value %1$@ → new value %2$@ (new value from %3$@)", + ApprovalCopy.bytes(old.byteCount), ApprovalCopy.bytes(after.byteCount), requester))] + if old.delivery != after.delivery { + notes.append(previous(ApprovalCopy.delivery(old.delivery))) + } + return Component(name: after.name, tag: .replaced, + line: ApprovalLine(after.name + " · ") + ApprovalCopy.delivery(after.delivery), + notes: notes, carriesValue: true, byteCount: after.byteCount) + } + if old.delivery != after.delivery || old.masked != after.masked || old.name != after.name { + var notes = [previous(line(old))] + if old.masked != after.masked { + notes.append(ApprovalLine(after.masked + ? appLocalized("Now masked when shown in Ask Key") + : appLocalized("Now shown in full in Ask Key"))) + } + notes.append(ApprovalLine(appLocalized("Same size; Ask Key can't tell whether the value was replaced. Authenticate to view."))) + return Component(name: after.name, tag: .changed, line: line(after), notes: notes, + carriesValue: true, byteCount: after.byteCount) + } + if digestsEqual { + return Component(name: after.name, tag: .unchanged, line: line(after), notes: [], + carriesValue: false, byteCount: after.byteCount) + } + if soleReplacement { + return Component(name: after.name, tag: .replaced, + line: ApprovalLine(after.name + " · ") + ApprovalCopy.delivery(after.delivery), + notes: [ApprovalLine(appLocalizedFormat("old value %1$@ → new value %2$@ (new value from %3$@)", + ApprovalCopy.bytes(old.byteCount), ApprovalCopy.bytes(after.byteCount), requester))], + carriesValue: true, byteCount: after.byteCount) + } + return Component(name: after.name, tag: .mayBeReplaced, line: line(after), + notes: [ApprovalLine(appLocalized("Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view."))], + carriesValue: true, byteCount: after.byteCount) } + rows += summary.before.filter { !afterNames.contains(normalized($0.name)) }.map { + Component(name: $0.name, tag: .removed, line: line($0), notes: [], carriesValue: false, byteCount: $0.byteCount) + } + return rows + } + + /// "{name} · {N} bytes · {delivery in plain words}". + static func line(_ item: BrokerCredentialComponentSummary) -> ApprovalLine { + ApprovalLine(item.name + " · " + ApprovalCopy.bytes(item.byteCount) + " · ") + ApprovalCopy.delivery(item.delivery) + } + + private static func previous(_ line: ApprovalLine) -> ApprovalLine { + let parts = appLocalized("Before: %@").components(separatedBy: "%@") + return ApprovalLine(parts.first ?? "") + line + ApprovalLine(parts.dropFirst().joined()) + } + + private static func changeSummary(items: Bool, instructions: Bool, group: Bool) -> String { + let sections = [(appLocalized("items"), items), (appLocalized("instructions"), instructions), + (appLocalized("group"), group)] + let separator = appLocalized("List separator") + let changed = sections.filter(\.1).map(\.0).joined(separator: separator) + let unchanged = sections.filter { !$0.1 }.map(\.0).joined(separator: separator) + if changed.isEmpty { return appLocalized("Nothing changes") } + if unchanged.isEmpty { return appLocalizedFormat("Changes: %@", changed) } + return appLocalizedFormat("Changes: %1$@ · Unchanged: %2$@", changed, unchanged) + } + + private static func instructionsText(_ value: String) -> String { + value.isEmpty ? appLocalized("None") : value } - private static func instructionsText(_ value: String?) -> String { - value.flatMap { $0.isEmpty ? nil : $0 } ?? appLocalized("None") + private static func groupText(_ name: String?) -> String { + name.map(ApprovalCopy.quoted) ?? appLocalized("Ungrouped") } - private static func componentText(_ item: BrokerCredentialComponentSummary) -> String { - "\(item.name) · \(item.byteCount) B · \(item.delivery.environmentVariable ?? "App")" + private static func normalized(_ name: String) -> String { + name.precomposedStringWithCanonicalMapping + .folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX")) } } diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index fc533f1..f0193f5 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -1,141 +1,716 @@ { "sourceLanguage": "en", "strings": { - "Group already exists — no changes. %@": { + "Reached the end": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Group already exists — no changes. %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "分组已存在,不会更改。%@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Group already exists — no changes. %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Reached the end" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已经到底" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Reached the end!!]" } } } }, - "Merge group “%@” into existing group “%@”": { + "%1$@ (%2$lld hidden from agents)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Merge group “%@” into existing group “%@”" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "将分组“%@”合并到已有分组“%@”" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Merge group “%@” into existing group “%@”!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ (%2$lld hidden from agents)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@(其中 %2$lld 个对 Agent 隐藏)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ (%2$lld hidden from agents)!!]" } } } }, - "Source: %@. Existing group: %@.": { + "%1$@ disappears and %2$@ will have %3$@.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Source: %@. Existing group: %@." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "来源:%@;已有分组:%@。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Source: %@. Existing group: %@.!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ disappears and %2$@ will have %3$@." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "合并后%1$@消失,%2$@共有 %3$@。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ disappears and %2$@ will have %3$@.!!]" } } } }, - "“%@” wants to organize credentials": { + "%1$@ wants to change the credential %2$@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "“%@” wants to organize credentials" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "“%@”想整理凭证" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%@” wants to organize credentials!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to change the credential %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想修改凭证%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to change the credential %2$@!!]" } } } }, - "%@ wants to organize credentials": { + "%1$@ wants to create the credential %2$@": { "extractionState": "manual", - "generatesSymbol": false, "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@ wants to organize credentials" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 想整理凭证" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ wants to organize credentials!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to create the credential %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想新建凭证%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to create the credential %2$@!!]" } } } }, - "Approve Organization": { + "%1$@ wants to delete the credential %2$@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Approve Organization" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准整理" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Approve Organization!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to delete the credential %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想删除凭证%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to delete the credential %2$@!!]" } } } }, - "Proposed organization": { + "%1$@ wants to reorganize your groups (%2$lld steps)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Proposed organization" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "待执行的整理" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Proposed organization!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to reorganize your groups (%2$lld steps)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想调整分组(共 %2$lld 步)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to reorganize your groups (%2$lld steps)!!]" } } } }, - "Move %@: %@ → %@": { + "%1$@ wants to replace the value of %2$@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Move %@: %@ → %@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "移动 %@:%@ → %@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Move %@: %@ → %@!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to replace the value of %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想替换凭证%2$@的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to replace the value of %2$@!!]" } } } }, - "Create group “%@”": { + "%1$@ wants to use the credential %2$@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Create group “%@”" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "创建分组“%@”" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Create group “%@”!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to use the credential %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想使用凭证%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to use the credential %2$@!!]" } } } }, - "Rename group “%@” → “%@”": { + "%1$@ wants to use the credential %2$@ to run %3$@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Rename group “%@” → “%@”" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "重命名分组“%@” → “%@”" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Rename group “%@” → “%@”!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to use the credential %2$@ to run %3$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想使用凭证%2$@运行 %3$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to use the credential %2$@ to run %3$@!!]" } } } }, - "Delete group “%@”": { + "%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Delete group “%@”" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除分组“%@”" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delete group “%@”!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@· 作为临时文件(路径在 %2$@,最多 5 分钟后删除)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)!!]" } } } }, - "%lld credential": { + "%1$@ · as environment variable %2$@": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld credential" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credential!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%1$@ · as environment variable %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@· 作为环境变量 %2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ · as environment variable %2$@!!]" } } } }, - "%lld credentials": { + "%@ (name provided by the requester; Ask Key can't verify it)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld credentials" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credentials!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ (name provided by the requester; Ask Key can't verify it)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@(名称由请求方提供,请旨无法核实)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ (name provided by the requester; Ask Key can't verify it)!!]" } } } }, - "%lld credential, %lld not visible to agents": { + "%@ bytes": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld credential, %lld not visible to agents" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证,其中 %lld 个 Agent 看不到" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credential, %lld not visible to agents!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ bytes" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 字节" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ bytes!!]" } } } }, - "%lld credentials, %lld not visible to agents": { + "%@ wants to reorganize your groups": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld credentials, %lld not visible to agents" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证,其中 %lld 个 Agent 看不到" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credentials, %lld not visible to agents!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ wants to reorganize your groups" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 想调整分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ wants to reorganize your groups!!]" } } } }, - "Unchanged": { + "%@ wants to reorganize your groups (1 step)": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Unchanged" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "不变" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Unchanged!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "%@ wants to reorganize your groups (1 step)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 想调整分组(共 1 步)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ wants to reorganize your groups (1 step)!!]" } } + } + }, + "%lld more lines — scroll to see them": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lld more lines — scroll to see them" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 %lld 行,向下滚动查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld more lines — scroll to see them!!]" } } + } + }, + "%lld more sections below — scroll to see them": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lld more sections below — scroll to see them" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有 %lld 部分,向下滚动查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld more sections below — scroll to see them!!]" } } + } + }, + "%lld more steps — scroll to see them": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lld more steps — scroll to see them" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 %lld 步,向下滚动查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld more steps — scroll to see them!!]" } } + } + }, + "1 byte": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 byte" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "1 字节" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 byte!!]" } } + } + }, + "1 more line — scroll to see it": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 more line — scroll to see it" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 1 行,向下滚动查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 more line — scroll to see it!!]" } } + } + }, + "1 more section below — scroll to see it": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 more section below — scroll to see it" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有 1 部分,向下滚动查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 more section below — scroll to see it!!]" } } + } + }, + "1 more step — scroll to see it": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 more step — scroll to see it" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "还有 1 步,向下滚动查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 more step — scroll to see it!!]" } } + } + }, + "A merge can't be undone automatically.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "A merge can't be undone automatically." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "合并无法自动撤销。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!A merge can't be undone automatically.!!]" } } + } + }, + "A replaced value overwrites the old one. There is no history.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "A replaced value overwrites the old one. There is no history." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换会覆盖旧值,没有历史记录。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!A replaced value overwrites the old one. There is no history.!!]" } } + } + }, + "After you approve": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "After you approve" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!After you approve!!]" } } + } + }, + "Allow for %lld Minutes": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Allow for %lld Minutes" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 分钟内都允许" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Allow for %lld Minutes!!]" } } + } + }, + "Apply %lld Steps": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Apply %lld Steps" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行这 %lld 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply %lld Steps!!]" } } + } + }, + "Apply 1 Step": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Apply 1 Step" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行这 1 步" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply 1 Step!!]" } } + } + }, + "Apply Steps": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Apply Steps" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行这些步骤" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Apply Steps!!]" } } + } + }, + "Authenticate and %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Authenticate and %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重新验证,%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Authenticate and %@!!]" } } + } + }, + "Authenticate to View": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Authenticate to View" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "验证后查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Authenticate to View!!]" } } + } + }, + "Before: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Before: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "原来:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Before: %@!!]" } } + } + }, + "Changed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Changed" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已修改" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changed!!]" } } + } + }, + "Changes: %1$@ · Unchanged: %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Changes: %1$@ · Unchanged: %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "会改动:%1$@ · 不变:%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changes: %1$@ · Unchanged: %2$@!!]" } } + } + }, + "Changes: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Changes: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "会改动:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Changes: %@!!]" } } + } + }, + "Command to run": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Command to run" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "要运行的命令" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Command to run!!]" } } + } + }, + "Create the group %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Create the group %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新建分组%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Create the group %@!!]" } } + } + }, + "Delete the group %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Delete the group %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除分组%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delete the group %@!!]" } } + } + }, + "Esc to decide later in Pending requests": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Esc to decide later in Pending requests" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Esc 先收起,稍后在「待处理请求」里决定" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Esc to decide later in Pending requests!!]" } } + } + }, + "For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 分钟内,这个 macOS 用户下的任何 Agent 或命令读取这个凭证都不再询问;不包括修改。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.!!]" } } + } + }, + "If you allow, this command receives": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "If you allow, this command receives" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后这个命令会拿到" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you allow, this command receives!!]" } } + } + }, + "Instructions for agents": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Instructions for agents" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "给 Agent 的使用说明" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Instructions for agents!!]" } } + } + }, + "It has %@.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "It has %@." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "组里有 %@。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!It has %@.!!]" } } + } + }, + "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证会移到回收站保留 30 天,期间可以在回收站恢复,之后永久删除。这段时间里 Agent 不能使用,也看不到它。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.!!]" } } + } + }, + "Items (%lld)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Items (%lld)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%lld 项)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%lld)!!]" } } + } + }, + "Its %1$@ won't be deleted and will become %2$@.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Its %1$@ won't be deleted and will become %2$@." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "组里 %1$@不会被删除,会变成%2$@。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Its %1$@ won't be deleted and will become %2$@.!!]" } } + } + }, + "May be replaced": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "May be replaced" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "可能已替换" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!May be replaced!!]" } } + } + }, + "Merge": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Merge" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "合并" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Merge!!]" } } + } + }, + "Merge the group %1$@ into the existing group %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Merge the group %1$@ into the existing group %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "把分组%1$@合并到已有分组%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Merge the group %1$@ into the existing group %2$@!!]" } } + } + }, + "More below — scroll to see it": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "More below — scroll to see it" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有内容,向下滚动查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!More below — scroll to see it!!]" } } + } + }, + "Move the credential %1$@ from %2$@ to %3$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Move the credential %1$@ from %2$@ to %3$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "把凭证%1$@从%2$@移到%3$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Move the credential %1$@ from %2$@ to %3$@!!]" } } + } + }, + "Move to Trash": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Move to Trash" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到回收站" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Move to Trash!!]" } } + } + }, + "New": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "New" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New!!]" } } + } + }, + "New group tag": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "New group" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New group!!]" } } + } + }, + "New value (provided by %@)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "New value (provided by %@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新值(由 %@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New value (provided by %@)!!]" } } + } + }, + "No change": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "No change" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无变化" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!No change!!]" } } + } + }, + "Nothing changes": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Nothing changes" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "不会改动任何内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Nothing changes!!]" } } + } + }, + "Nothing from this credential is given to the command": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Nothing from this credential is given to the command" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "不交给这个命令任何值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Nothing from this credential is given to the command!!]" } } + } + }, + "Now masked when shown in Ask Key": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Now masked when shown in Ask Key" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "现在在请旨里默认遮挡显示" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Now masked when shown in Ask Key!!]" } } + } + }, + "Now shown in full in Ask Key": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Now shown in full in Ask Key" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "现在在请旨里默认完整显示" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Now shown in full in Ask Key!!]" } } + } + }, + "Removed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Removed" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Removed!!]" } } + } + }, + "Rename the group %1$@ to %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Rename the group %1$@ to %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "把分组%1$@改名为%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Rename the group %1$@ to %2$@!!]" } } + } + }, + "Replace Value": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Replace Value" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Replace Value!!]" } } } }, - "New group — created when approved": { + "Replaced": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "New group — created when approved" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "新分组 · 批准后创建" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New group — created when approved!!]" } } + "en": { "stringUnit": { "state": "translated", "value": "Replaced" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Replaced!!]" } } + } + }, + "Runs in": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Runs in" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "运行目录" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Runs in!!]" } } + } + }, + "Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "大小和交付方式没变,无法确认值是否被替换;可验证后查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view.!!]" } } + } + }, + "Same size; Ask Key can't tell whether the value was replaced. Authenticate to view.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Same size; Ask Key can't tell whether the value was replaced. Authenticate to view." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "大小没变,无法确认值是否被替换;可验证后查看" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Same size; Ask Key can't tell whether the value was replaced. Authenticate to view.!!]" } } + } + }, + "Sentence separator": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": " " } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": " " } } + } + }, + "Stated purpose (not verified)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Stated purpose (not verified)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "对方说的用途(未核实)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Stated purpose (not verified)!!]" } } + } + }, + "The credential's agent permission will be Ask every time: agents need your approval each time they use it.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The credential's agent permission will be Ask every time: agents need your approval each time they use it." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这个凭证的 Agent 权限是「每次询问」:Agent 每次使用都要你批准。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The credential's agent permission will be Ask every time: agents need your approval each time they use it.!!]" } } + } + }, + "The group is empty.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The group is empty." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "组里没有凭证。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The group is empty.!!]" } } + } + }, + "The items of %@ that are set to be given to programs (names are shown after you approve)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The items of %@ that are set to be given to programs (names are shown after you approve)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@里设为交给程序的所有项(具体名称批准后才能看到)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The items of %@ that are set to be given to programs (names are shown after you approve)!!]" } } + } + }, + "This group already exists, so nothing is created or changed.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This group already exists, so nothing is created or changed." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这个分组已经存在,不会新建或改动任何内容。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This group already exists, so nothing is created or changed.!!]" } } + } + }, + "This is the value saved if you approve. Viewing needs another authentication and does not approve the request.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This is the value saved if you approve. Viewing needs another authentication and does not approve the request." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后保存的就是这个值。查看要再验证一次,查看不等于批准。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This is the value saved if you approve. Viewing needs another authentication and does not approve the request.!!]" } } + } + }, + "Value to save (provided by %@)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Value to save (provided by %@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "要保存的值(由 %@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Value to save (provided by %@)!!]" } } + } + }, + "When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "倒计时结束后请求失效:不会交付或写入任何内容,Agent 会收到已失效的结果。按 Esc 只是先收起卡片,请求会留在「待处理请求」里,直到失效。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires.!!]" } } + } + }, + "created when you approve": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "created when you approve" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后创建" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!created when you approve!!]" } } + } + }, + "given to programs as a temporary file (path in %@)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "given to programs as a temporary file (path in %@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "使用时作为临时文件交给程序(路径在 %@)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!given to programs as a temporary file (path in %@)!!]" } } + } + }, + "given to programs as environment variable %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "given to programs as environment variable %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "使用时作为环境变量 %@ 交给程序" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!given to programs as environment variable %@!!]" } } + } + }, + "group": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "group" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "分组" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!group!!]" } } + } + }, + "instructions": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "instructions" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "使用说明" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!instructions!!]" } } + } + }, + "items": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!items!!]" } } + } + }, + "kept in Ask Key only, never given to agents": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "kept in Ask Key only, never given to agents" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "只存在请旨里,不交给任何 Agent" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!kept in Ask Key only, never given to agents!!]" } } + } + }, + "old value %1$@ → new value %2$@ (new value from %3$@)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "old value %1$@ → new value %2$@ (new value from %3$@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值 %1$@ → 新值 %2$@(新值由 %3$@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!old value %1$@ → new value %2$@ (new value from %3$@)!!]" } } + } + }, + "Quoted name: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "“%@”" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "「%@」" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!“%@”!!]" } } + } + }, + "%lld credential": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lld credential" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credential!!]" } } + } + }, + "%lld credentials": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%lld credentials" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 个凭证" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld credentials!!]" } } + } + }, + "Unchanged": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Unchanged" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "不变" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Unchanged!!]" } } } }, "None": { @@ -644,52 +1219,6 @@ } } }, - "%@ (file)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ (file)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@(文件)" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!%@ (file)!!]" - } - } - } - }, - "%@ (self-declared, unverified)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ (self-declared, unverified)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@(自报,未核实)" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!%@ (self-declared, unverified)!!]" - } - } - } - }, "%@ did not complete the connection check. Restart %@, then try again.": { "extractionState": "extracted_with_value", "localizations": { @@ -1504,23 +2033,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": ", " } } } }, - "%1$@ wants to use %2$@": { - "extractionState": "manual", - "generatesSymbol": false, - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to use %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想用 %2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to use %2$@!!]" } } - } - }, - "%1$@ wants to use %2$@ to run %3$@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to use %2$@ to run %3$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想用 %2$@ 运行 %3$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to use %2$@ to run %3$@!!]" } } - } - }, "%1$@ used %2$@ to run %3$@": { "extractionState": "manual", "localizations": { @@ -1529,32 +2041,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ used %2$@ to run %3$@!!]" } } } }, - "%1$@ wants to create %2$@": { - "extractionState": "manual", - "generatesSymbol": false, - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to create %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想新建凭证 %2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to create %2$@!!]" } } - } - }, - "%1$@ wants to change %2$@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to change %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想修改 %2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to change %2$@!!]" } } - } - }, - "%1$@ wants to delete %2$@": { - "extractionState": "manual", - "generatesSymbol": false, - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to delete %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想删除 %2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to delete %2$@!!]" } } - } - }, "Missed or deferred requests stay here for 5 minutes.": { "extractionState": "manual", "localizations": { @@ -2203,7 +2689,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "修改后" + "value": "改为" } }, "qps-ploc": { @@ -2657,7 +3143,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Allow once" + "value": "Allow Once" } }, "zh-Hans": { @@ -2858,98 +3344,6 @@ } } }, - "Applies to all local callers for this credential · Revocable anytime": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Applies to all local callers for this credential · Revocable anytime" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "对这份凭证的所有本地调用方生效 · 可随时收回" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Applies to all local callers for this credential · Revocable anytime!!]" - } - } - } - }, - "Approve Change": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Approve Change" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "批准修改" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Approve Change!!]" - } - } - } - }, - "Approve Creation": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Approve Creation" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "批准创建" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Approve Creation!!]" - } - } - } - }, - "Approve Deletion": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Approve Deletion" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "批准删除" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Approve Deletion!!]" - } - } - } - }, "Approve this Agent credential change": { "extractionState": "extracted_with_value", "localizations": { @@ -3686,29 +4080,6 @@ } } }, - "Authenticate and View": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authenticate and View" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "验证并查看" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Authenticate and View!!]" - } - } - } - }, "Authenticate to Edit Contents": { "extractionState": "extracted_with_value", "localizations": { @@ -3836,7 +4207,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "修改前" + "value": "原来" } }, "qps-ploc": { @@ -5101,7 +5472,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "创建凭证" + "value": "新建凭证" } }, "qps-ploc": { @@ -6262,29 +6633,6 @@ } } }, - "Destination": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Destination" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "去向" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Destination!!]" - } - } - } - }, "Development build — uses a separate vault from the production app": { "extractionState": "extracted_with_value", "localizations": { @@ -7205,29 +7553,6 @@ } } }, - "Frozen Content to Write": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Frozen Content to Write" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "待写入的冻结内容" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Frozen Content to Write!!]" - } - } - } - }, "General": { "extractionState": "extracted_with_value", "localizations": { @@ -10887,29 +11212,6 @@ } } }, - "Recycle Bin · Recoverable for 30 days": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Recycle Bin · Recoverable for 30 days" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "回收站 · 保留 30 天,可恢复" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Recycle Bin · Recoverable for 30 days!!]" - } - } - } - }, "Refresh Status": { "extractionState": "extracted_with_value", "localizations": { @@ -13166,29 +13468,6 @@ } } }, - "Usage instructions": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Usage instructions" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "使用说明" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Usage instructions!!]" - } - } - } - }, "Usage instructions must be at most 4096 bytes.": { "extractionState": "extracted_with_value", "localizations": { @@ -13500,59 +13779,36 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "查看全部" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!View all!!]" - } - } - } - }, - "View the frozen file submitted for approval": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "View the frozen file submitted for approval" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确认查看待批准的冻结文件内容" + "value": "查看全部" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!View the frozen file submitted for approval!!]" + "value": "[!!View all!!]" } } } }, - "Viewing requires separate authentication and does not approve this request.": { + "View the frozen file submitted for approval": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Viewing requires separate authentication and does not approve this request." + "value": "View the frozen file submitted for approval" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "查看需要独立验证,不会批准本次请求。" + "value": "确认查看待批准的冻结文件内容" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!Viewing requires separate authentication and does not approve this request.!!]" + "value": "[!!View the frozen file submitted for approval!!]" } } } @@ -14199,98 +14455,6 @@ } } }, - "“%@” wants to create “%@”": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "“%@” wants to create “%@”" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "“%@”想创建“%@”" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!“%@” wants to create “%@”!!]" - } - } - } - }, - "“%@” wants to delete “%@”": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "“%@” wants to delete “%@”" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "“%@”想删除“%@”" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!“%@” wants to delete “%@”!!]" - } - } - } - }, - "“%@” wants to modify “%@”": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "“%@” wants to modify “%@”" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "“%@”想修改“%@”" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!“%@” wants to modify “%@”!!]" - } - } - } - }, - "“%@” wants to use “%@”": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "“%@” wants to use “%@”" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "“%@”想使用“%@”" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!“%@” wants to use “%@”!!]" - } - } - } - }, "← Back": { "extractionState": "extracted_with_value", "localizations": { @@ -14567,29 +14731,6 @@ } } }, - "to run": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "to run" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用于运行" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!to run!!]" - } - } - } - }, "Waiting for authentication…": { "extractionState": "extracted_with_value", "localizations": { @@ -14797,52 +14938,6 @@ } } }, - "Location": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Location" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "位置" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Location!!]" - } - } - } - }, - "Stated purpose": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Stated purpose" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "说明" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Stated purpose!!]" - } - } - } - }, "The connection did not finish, and original settings could not be restored. Writing has stopped and the backup was kept.": { "extractionState": "extracted_with_value", "localizations": { @@ -14889,121 +14984,6 @@ } } }, - "Allow for %lld minutes": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Allow for %lld minutes" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "允许 %lld 分钟" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Allow for %lld minutes!!]" - } - } - } - }, - "Authenticate and allow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authenticate and allow" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重新验证并允许" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Authenticate and allow!!]" - } - } - } - }, - "Authenticate and approve": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authenticate and approve" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重新验证并批准" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Authenticate and approve!!]" - } - } - } - }, - "Command": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Command" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "命令" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Command!!]" - } - } - } - }, - "Delivers": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delivers" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "交付" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Delivers!!]" - } - } - } - }, "Details": { "extractionState": "manual", "localizations": { @@ -15027,29 +15007,6 @@ } } }, - "Esc to decide later": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Esc to decide later" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Esc 稍后处理" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Esc to decide later!!]" - } - } - } - }, "Expires in %@": { "extractionState": "manual", "localizations": { @@ -15062,7 +15019,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "%@ 后失效" + "value": "%@ 后自动失效" } }, "qps-ploc": { diff --git a/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift b/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift index c9f28e0..808fb5d 100644 --- a/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift +++ b/Sources/AskKeyAppKit/Views/Credentials/PendingRequestPresentation.swift @@ -1,24 +1,27 @@ import Foundation import AskKeyBroker -/// One pending request as a sentence: " wants to use -/// to run ". The command comes from the App-derived display +/// One pending request as a sentence that names the object the way the +/// approval card's title does. The command comes from the App-derived display /// summary of the bound request, never from the helper's own claims. struct PendingRequestPresentation: Equatable { let sentence: EmphasizedSentence init(approval: BrokerPendingApproval) { let request = approval.request - let caller = request.callerName ?? appLocalized("Local Agent") + let requester = ApprovalCopy.requester(request) + let credential = ApprovalCopy.quoted(approval.displayCredentialName) if request.operation == .read, let command = request.display?.commandSummary, !command.isEmpty { sentence = EmphasizedSentence( - format: appLocalized("%1$@ wants to use %2$@ to run %3$@"), - arguments: [caller, approval.displayCredentialName, command] + format: appLocalized("%1$@ wants to use the credential %2$@ to run %3$@"), + arguments: [requester, credential, command] ) + } else if request.operation == .organize { + sentence = EmphasizedSentence(format: ApprovalPromptContent.titleFormat(.organize), arguments: [requester]) } else { sentence = EmphasizedSentence( - format: Self.format(request.operation), - arguments: [caller, approval.displayCredentialName] + format: ApprovalPromptContent.titleFormat(request.operation), + arguments: [requester, credential] ) } } @@ -26,14 +29,4 @@ struct PendingRequestPresentation: Equatable { static func expiry(deadline: Date?, now: Date) -> String { appLocalizedFormat("Expires in %@", FrozenCountdown.format(deadline: deadline, now: now)) } - - private static func format(_ operation: BrokerApprovalOperation) -> String { - switch operation { - case .read: return appLocalized("%1$@ wants to use %2$@") - case .create: return appLocalized("%1$@ wants to create %2$@") - case .organize: return appLocalized("%@ wants to organize credentials") - case .modify: return appLocalized("%1$@ wants to change %2$@") - case .delete: return appLocalized("%1$@ wants to delete %2$@") - } - } } diff --git a/Tests/AskKeyAppTests/ApprovalCardFixtures.swift b/Tests/AskKeyAppTests/ApprovalCardFixtures.swift new file mode 100644 index 0000000..85cde52 --- /dev/null +++ b/Tests/AskKeyAppTests/ApprovalCardFixtures.swift @@ -0,0 +1,173 @@ +import AppKit +import SwiftUI +import AskKeyBroker +@testable import AskKeyAppKit + +/// Synthetic approval cards covering every card type. Values are never real. +@MainActor +enum ApprovalCardFixtures { + enum Card: String, CaseIterable { + case readDefault, readDetails, readFile, readCancelled, readWithoutTimed, readLongCommand, readBundle + case create, modifyMetadata, modifyValue, modifyAdded, delete + case organize, organizeExistingAndMerge, organizeTwelveSteps + } + + static func request(_ operation: BrokerApprovalOperation, name: String? = "Staging API", + purpose: String? = "Deploy the staging site", + display: BrokerApprovalOperationRequest.Display? = nil) -> BrokerApprovalOperationRequest { + BrokerApprovalOperationRequest( + operationID: "synthetic-\(operation.rawValue)", + credentialID: operation == .organize ? "" : "synthetic-credential", + targetID: operation == .organize ? "credential-library" : "synthetic-credential", + operation: operation, + payloadDigest: String(repeating: "a", count: 64), + credentialName: operation == .organize ? nil : name, + callerName: "Claude Code", + callerPurpose: purpose, + display: display, + organizationCredentialIDs: operation == .organize ? [] : nil + ) + } + + static func display(command: String = "./deploy.sh --env staging", environment: [String]? = ["STAGING_API_TOKEN"], + files: [String]? = []) -> BrokerApprovalOperationRequest.Display { + .init(commandLine: command, workingDirectory: "~/web", executableBasename: "deploy.sh", + environmentVariables: environment, temporaryFileVariables: files) + } + + static func component(_ name: String, bytes: Int, _ delivery: BrokerComponentDelivery, + kind: BrokerCatalogPayloadKind = .text) -> BrokerCredentialComponentSummary { + .init(name: name, payloadKind: kind, byteCount: bytes, delivery: delivery, masked: true) + } + + static let token = component("TOKEN", bytes: 28, .environmentVariable("RELEASE_CHECK_TOKEN")) + static let certificate = component("CERT", bytes: 1_234, .temporaryFile("RELEASE_CERT_FILE"), kind: .file) + static let recovery = component("RECOVERY_CODE", bytes: 16, .none) + static let user = component("USER", bytes: 12, .environmentVariable("RELEASE_USER")) + + static let createSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .create, + before: [], after: [token, certificate, recovery], beforeDigest: nil, afterDigest: "after", + beforeUsageInstructions: nil, afterUsageInstructions: "Use only for the release smoke check. Keep values out of logs.", + beforeGroup: nil, afterGroup: "Release Tools", createsGroup: true) + + static let metadataSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [token, user], after: [token, user], beforeDigest: "same", afterDigest: "same", + beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use only for the nightly release check.", + beforeGroup: "Release Tools", afterGroup: "Release Tools") + + static let valueSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [component("TOKEN", bytes: 40, .environmentVariable("RELEASE_CHECK_TOKEN"))], + after: [component("TOKEN", bytes: 52, .environmentVariable("RELEASE_CHECK_TOKEN"))], + beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: "Use for release checks.", + afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Release Tools") + + static let addedSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, + before: [token, user], after: [token, user, certificate], beforeDigest: "old", afterDigest: "new", + beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use for release checks.", + beforeGroup: "Release Tools", afterGroup: "Release Tools") + + static let deleteSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .delete, + before: [token, user], after: [], beforeDigest: "current", afterDigest: nil, + beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: nil, + beforeGroup: "Release Tools", afterGroup: nil) + + static let regularOrganization = BrokerOrganizationSummary(operations: [ + .createGroup("Staging Services"), + .move(credential: "Staging API", from: "Old Services", to: "Staging Services"), + .renameGroup(from: "Old Services", to: "Renamed Services", members: 3, nonvisible: 2), + .deleteGroup(name: "Temp", members: 1, nonvisible: 0), + ]) + + static let existingAndMerge = BrokerOrganizationSummary(operations: [ + .existingGroup(name: "Existing Private Services", members: 1, nonvisible: 1), + .mergeGroup(from: "Merge Source", to: "Existing Merge Services", members: 2, nonvisible: 1, + targetMembers: 2, targetNonvisible: 2), + ]) + + static let twelveSteps = BrokerOrganizationSummary(operations: (1...12).map { index in + switch index % 4 { + case 0: return .deleteGroup(name: "Temp \(index)", members: 2, nonvisible: 1) + case 1: return .createGroup("Group \(index)") + case 2: return .move(credential: "Service \(index)", from: nil, to: "Group \(index - 1)") + default: return .renameGroup(from: "Old \(index)", to: "New \(index)", members: 1, nonvisible: 0) + } + }) + + static func prompt(_ card: Card) -> FrozenAgentApprovalPrompt { + let expiry = Date().addingTimeInterval(299) + switch card { + case .readDefault: + return .init(request: request(.read, display: display()), expiresAt: expiry, timedAllowanceEnabled: true, finish: { _ in }) + case .readDetails: + return .init(request: request(.read, display: display()), expiresAt: expiry, timedAllowanceEnabled: true, + detailsExpanded: true, finish: { _ in }) + case .readFile: + return .init(request: request(.read, display: display(environment: [], files: ["STAGING_CERT_FILE"])), + expiresAt: expiry, timedAllowanceEnabled: true, finish: { _ in }) + case .readCancelled: + return .init(request: request(.read, display: display()), expiresAt: expiry, timedAllowanceEnabled: true, + cancelledAuthenticationDecision: .timedAllow(duration: nil), finish: { _ in }) + case .readWithoutTimed: + return .init(request: request(.read, display: display()), expiresAt: expiry, timedAllowanceEnabled: false, finish: { _ in }) + case .readLongCommand: + let command = "./deploy.sh " + (1...24).map { "--flag-\($0) value-\($0)" }.joined(separator: " ") + return .init(request: request(.read, display: display(command: command)), expiresAt: expiry, + timedAllowanceEnabled: true, finish: { _ in }) + case .readBundle: + return .init(request: request(.read, display: display(environment: nil, files: nil)), expiresAt: expiry, + timedAllowanceEnabled: true, finish: { _ in }) + case .create: + return write(.create, createSummary) + case .modifyMetadata: + return write(.modify, metadataSummary) + case .modifyValue: + return write(.modify, valueSummary) + case .modifyAdded: + return write(.modify, addedSummary) + case .delete: + return write(.delete, deleteSummary) + case .organize: + return organize(regularOrganization) + case .organizeExistingAndMerge: + return organize(existingAndMerge) + case .organizeTwelveSteps: + return organize(twelveSteps) + } + } + + static func write(_ operation: BrokerApprovalOperation, _ summary: BrokerCredentialWriteSummary, + purpose: String? = "Save the release smoke-check credential") -> FrozenAgentApprovalPrompt { + .init(request: request(operation, name: summary.credentialName, purpose: purpose), + expiresAt: Date().addingTimeInterval(299), timedAllowanceEnabled: true, writeSummary: summary, + revealMaterial: { throw CancellationError() }, finish: { _ in }) + } + + static func organize(_ summary: BrokerOrganizationSummary, + purpose: String? = "Tidy the synthetic credential library") -> FrozenAgentApprovalPrompt { + .init(request: request(.organize, purpose: purpose), expiresAt: Date().addingTimeInterval(299), + timedAllowanceEnabled: true, organizationSummary: summary, finish: { _ in }) + } + + /// Lays the card out and lets its scroll areas publish their geometry. + static func host(_ prompt: FrozenAgentApprovalPrompt) -> NSHostingView { + _ = NSApplication.shared + let hosting = NSHostingView(rootView: prompt) + hosting.appearance = NSAppearance(named: .aqua) + hosting.frame = CGRect(origin: .zero, size: hosting.fittingSize) + for _ in 0..<3 { + hosting.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + hosting.frame.size = hosting.fittingSize + } + return hosting + } + + static func withLanguages(_ body: (String) throws -> Void) rethrows { + let previous = AppLanguage.current + defer { AppLanguage.current = previous } + for language in ["en", "zh-Hans"] { + AppLanguage.current = language + try body(language) + } + } +} diff --git a/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift new file mode 100644 index 0000000..9223918 --- /dev/null +++ b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift @@ -0,0 +1,99 @@ +import AppKit +import SwiftUI +import XCTest +import AskKeyBroker +@testable import AskKeyAppKit + +/// Every card keeps its actions on a 768-point screen: the body scrolls, with +/// a visible scroll bar and an overflow line, instead of pushing them away. +@MainActor +final class ApprovalCardLayoutTests: AskKeyAppTestCase { + private typealias Fixtures = ApprovalCardFixtures + + private func height(_ prompt: FrozenAgentApprovalPrompt) -> CGFloat { + let size = Fixtures.host(prompt).fittingSize + XCTAssertEqual(size.width, FrozenAgentApprovalPrompt.width) + return size.height + } + + func testEveryCardTypeStaysWithin680PointsInBothLanguages() { + Fixtures.withLanguages { language in + for card in Fixtures.Card.allCases { + XCTAssertLessThanOrEqual(height(Fixtures.prompt(card)), FrozenAgentApprovalPrompt.maximumHeight, + "\(language) \(card.rawValue)") + } + } + } + + func testExtremeContentScrollsInsideTheCapWithoutRevealingValues() { + let long = String(repeating: "Guidance for the synthetic service. ", count: 110) + let name = String(repeating: "Long Name ", count: 25) + var reveals = 0 + let write = BrokerCredentialWriteSummary(credentialName: name, operation: .modify, + before: (1...8).map { Fixtures.component("ITEM_\($0)", bytes: 10 + $0, .environmentVariable("VARIABLE_\($0)")) }, + after: (1...16).map { Fixtures.component("ITEM_\($0)", bytes: 20 + $0, .temporaryFile("PATH_\($0)")) }, + beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: long, afterUsageInstructions: long + " Updated.", + beforeGroup: name, afterGroup: name + "2", createsGroup: true) + let organization = BrokerOrganizationSummary(operations: (0..<64).map { index in + index.isMultiple(of: 2) + ? .existingGroup(name: name, members: 1, nonvisible: 1) + : .mergeGroup(from: name, to: name + "B", members: 4, nonvisible: 1, targetMembers: 2, targetNonvisible: 2) + }) + let caller = String(repeating: "Synthetic Agent ", count: 250) + func request(_ operation: BrokerApprovalOperation, display: BrokerApprovalOperationRequest.Display? = nil) -> BrokerApprovalOperationRequest { + .init(operationID: "extreme", credentialID: "extreme", targetID: "extreme", operation: operation, + payloadDigest: String(repeating: "a", count: 64), credentialName: name, callerName: caller, + callerPurpose: long, display: display, organizationCredentialIDs: operation == .organize ? [] : nil) + } + let command = (1...200).map { "--flag-\($0)" }.joined(separator: " ") + Fixtures.withLanguages { language in + for expanded in [false, true] { + for cancelled in [nil, BrokerApprovalDecision.once] { + let prompts: [FrozenAgentApprovalPrompt] = [ + .init(request: request(.read, display: Fixtures.display(command: command)), timedAllowanceEnabled: true, + cancelledAuthenticationDecision: cancelled, detailsExpanded: expanded, finish: { _ in }), + .init(request: request(.modify), timedAllowanceEnabled: true, writeSummary: write, + revealMaterial: { reveals += 1; throw CancellationError() }, + cancelledAuthenticationDecision: cancelled, detailsExpanded: expanded, finish: { _ in }), + .init(request: request(.organize), timedAllowanceEnabled: true, organizationSummary: organization, + cancelledAuthenticationDecision: cancelled, detailsExpanded: expanded, finish: { _ in }), + ] + for prompt in prompts { + XCTAssertEqual(height(prompt), FrozenAgentApprovalPrompt.maximumHeight, accuracy: 1, + "\(language): long content scrolls inside the capped card") + } + } + } + } + XCTAssertEqual(reveals, 0, "laying out a card never asks to reveal a value") + } + + func testCommandsOverFourLinesScrollWithAnOverflowLine() { + func read(lines: Int) -> FrozenAgentApprovalPrompt { + let command = (1...lines).map { "./step-\($0).sh" }.joined(separator: "\n") + return .init(request: Fixtures.request(.read, display: Fixtures.display(command: command)), + timedAllowanceEnabled: true, finish: { _ in }) + } + Fixtures.withLanguages { language in + let one = height(read(lines: 1)) + let four = height(read(lines: 4)) + let five = height(read(lines: 5)) + let thirty = height(read(lines: 30)) + XCTAssertEqual(four - one, 3 * FrozenAgentApprovalPrompt.commandLineHeight, accuracy: 3, + "\(language): up to four lines show in full") + XCTAssertGreaterThan(five, four, "\(language): the overflow line appears under a capped command") + XCTAssertLessThan(five - four, FrozenAgentApprovalPrompt.commandLineHeight + 6, + "\(language): the fifth line scrolls instead of growing the card") + XCTAssertEqual(thirty, five, accuracy: 1, "\(language): longer commands keep the same cap") + } + } + + func testDetailsGrowTheReadCardAndTheCancelledStateDropsTheTimedAction() { + Fixtures.withLanguages { language in + let collapsed = height(Fixtures.prompt(.readDefault)) + XCTAssertGreaterThan(height(Fixtures.prompt(.readDetails)), collapsed, "\(language): Details expand in place") + XCTAssertLessThan(height(Fixtures.prompt(.readCancelled)), collapsed, "\(language): one retry replaces two allow buttons") + XCTAssertLessThan(height(Fixtures.prompt(.readWithoutTimed)), collapsed) + } + } +} diff --git a/Tests/AskKeyAppTests/ApprovalMetadataContentTests.swift b/Tests/AskKeyAppTests/ApprovalMetadataContentTests.swift deleted file mode 100644 index c6877f6..0000000 --- a/Tests/AskKeyAppTests/ApprovalMetadataContentTests.swift +++ /dev/null @@ -1,171 +0,0 @@ -import AppKit -import SwiftUI -import XCTest -import AskKeyBroker -@testable import AskKeyAppKit - -final class ApprovalMetadataContentTests: AskKeyAppTestCase { - func testApprovalMetadataCopyInEnglishAndSimplifiedChinese() { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - AppLanguage.current = "en" - XCTAssertEqual(appLocalized("Usage instructions"), "Usage instructions") - XCTAssertEqual(appLocalized("Group"), "Group") - XCTAssertEqual(appLocalized("New group — created when approved"), "New group — created when approved") - XCTAssertEqual(appLocalized("None"), "None") - XCTAssertEqual(appLocalized("Unchanged"), "Unchanged") - AppLanguage.current = "zh-Hans" - XCTAssertEqual(appLocalized("Usage instructions"), "使用说明") // i18n-literal: Assert Simplified Chinese metadata approval copy. - XCTAssertEqual(appLocalized("Group"), "分组") // i18n-literal: Assert Simplified Chinese metadata approval copy. - XCTAssertEqual(appLocalized("New group — created when approved"), "新分组 · 批准后创建") // i18n-literal: Assert Simplified Chinese metadata approval copy. - XCTAssertEqual(appLocalized("None"), "无") // i18n-literal: Assert Simplified Chinese metadata approval copy. - XCTAssertEqual(appLocalized("Unchanged"), "不变") // i18n-literal: Assert Simplified Chinese unchanged metadata copy. - } - - @MainActor - func testFullMetadataFitsPromptWidthWithoutInvokingReveal() throws { - _ = NSApplication.shared - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - AppLanguage.current = "en" - let summary = BrokerCredentialWriteSummary(credentialName: "Service", operation: .modify, - before: [], after: [], beforeDigest: nil, afterDigest: nil, - beforeUsageInstructions: "Original guidance", afterUsageInstructions: String(repeating: "Complete guidance\n", count: 200), - beforeGroup: "Original", afterGroup: "New Group", createsGroup: true) - var reveals = 0 - let view = FrozenWriteApprovalContent(writeSummary: summary, revealMaterial: { - reveals += 1 - throw CancellationError() - }) - let hosting = NSHostingView(rootView: view.frame(width: 252)) - let size = hosting.fittingSize - XCTAssertEqual(size.width, 252) - XCTAssertGreaterThan(size.height, 200) - XCTAssertLessThan(size.height, 650, "Instructions scroll rather than growing the prompt without bound") - XCTAssertEqual(reveals, 0, "Catalog-visible metadata requires no reveal authentication") - } - - @MainActor - func testLongGroupNamesDoNotPushApprovalActionsOutsideThePrompt() { - _ = NSApplication.shared - let request = BrokerApprovalOperationRequest(operationID: "synthetic", credentialID: "synthetic", - targetID: "synthetic", operation: .modify, payloadDigest: String(repeating: "a", count: 64), - credentialName: "Staging Service") - func size(group: String) -> NSSize { - let summary = BrokerCredentialWriteSummary(credentialName: "Staging Service", operation: .modify, - before: [], after: [], beforeDigest: nil, afterDigest: nil, - beforeUsageInstructions: "Original", afterUsageInstructions: String(repeating: "Guidance\n", count: 400), - beforeGroup: group, afterGroup: group, createsGroup: true) - return NSHostingView(rootView: FrozenAgentApprovalPrompt(request: request, timedAllowanceEnabled: true, - writeSummary: summary, finish: { _ in })).fittingSize - } - let short = size(group: "Staging") - let long = size(group: String(repeating: "Wide Group ", count: 23)) - XCTAssertEqual(long.width, FrozenAgentApprovalPrompt.width) - XCTAssertGreaterThan(long.height, short.height, "Short groups use their content height") - XCTAssertLessThanOrEqual(long.height - short.height, 30, "Long groups stay within the existing height cap") - XCTAssertLessThan(long.height, 680) - } - - @MainActor - func testCreateApprovalKeepsActionsWithinCIVisibleScreenHeight() { - _ = NSApplication.shared - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - let request = BrokerApprovalOperationRequest(operationID: "synthetic", credentialID: "synthetic", - targetID: "synthetic", operation: .create, payloadDigest: String(repeating: "a", count: 64), - credentialName: "Staging API", callerName: "E2E Agent") - for language in ["en", "zh-Hans"] { - AppLanguage.current = language - for instructions in ["Use only for staging API requests. Consume STAGING_TOKEN; keep values out of logs.", - String(repeating: "Guidance\n", count: 400)] { - let summary = BrokerCredentialWriteSummary(credentialName: "Staging API", operation: .create, - before: [], after: [.init(name: "token", payloadKind: .text, byteCount: 24, - delivery: .environmentVariable("STAGING_TOKEN"), masked: true)], - beforeDigest: nil, afterDigest: nil, beforeUsageInstructions: nil, - afterUsageInstructions: instructions, beforeGroup: nil, afterGroup: "Staging Services", createsGroup: true) - let size = NSHostingView(rootView: FrozenAgentApprovalPrompt(request: request, timedAllowanceEnabled: true, - writeSummary: summary, finish: { _ in })).fittingSize - XCTAssertEqual(size.width, FrozenAgentApprovalPrompt.width) - XCTAssertLessThan(size.height, 680, "Approval and Deny must fit the 768-point CI screen with menu bar and Dock") - } - } - } - - @MainActor - func testInstructionsHeightFollowsContentUntilTheCapInBothLanguages() { - _ = NSApplication.shared - withLanguages { language in - let line = language == "en" ? "Short guidance" : "简短说明" // i18n-literal: Synthetic localized short instructions. - let lineHeight = NSHostingView(rootView: Text(verbatim: line).font(Theme.Fonts.caption) - .frame(width: 252)).fittingSize.height - for operation in [BrokerApprovalOperation.create, .delete] { - @MainActor - func height(lines: Int) -> CGFloat { - let instructions = Array(repeating: line, count: lines).joined(separator: "\n") - return hosting(operation: operation, instructions: instructions).fittingSize.height - } - let short = height(lines: 1) - let medium = height(lines: 2) - let long = height(lines: 12) - XCTAssertEqual(medium - short, lineHeight, accuracy: 1) - XCTAssertEqual(long - short, 60 - lineHeight, accuracy: 1) - XCTAssertEqual(height(lines: 40), long, accuracy: 1, "Overflow scrolls instead of growing the card") - } - } - } - - @MainActor - func testGroupHeightFollowsContentUntilTheCapInBothLanguages() { - _ = NSApplication.shared - withLanguages { language in - let shortGroup = language == "en" ? "Staging" : "测试" // i18n-literal: Synthetic localized group names. - let lineHeight = NSHostingView(rootView: Text(verbatim: shortGroup).font(Theme.Fonts.caption) - .frame(width: 252)).fittingSize.height - for operation in [BrokerApprovalOperation.create, .delete] { - let short = hosting(operation: operation, group: shortGroup).fittingSize.height - let long = hosting(operation: operation, group: String(repeating: shortGroup + " ", count: 25)).fittingSize.height - XCTAssertEqual(long - short, 30 - lineHeight, accuracy: 1) - let longer = hosting(operation: operation, group: String(repeating: shortGroup + " ", count: 30)).fittingSize.height - XCTAssertEqual(longer, long, accuracy: 1, "Overflow groups scroll within the existing cap") - } - } - } - - @MainActor - func testDeleteOmitsFrozenContentWhileCreateAndModifyKeepItInBothLanguages() { - _ = NSApplication.shared - withLanguages { _ in - // A missing summary renders the existing frozen-content block alone. - let frozenHeight = NSHostingView(rootView: FrozenWriteApprovalContent(writeSummary: nil) - .frame(width: 252)).fittingSize.height - XCTAssertGreaterThan(frozenHeight, 80, "Measure the complete localized heading, reveal button and note") - let deleteHeight = hosting(operation: .delete).fittingSize.height - for operation in [BrokerApprovalOperation.create, .modify] { - let writeHeight = hosting(operation: operation).fittingSize.height - XCTAssertEqual(writeHeight - deleteHeight, frozenHeight + Theme.Spacing.sm, accuracy: 1, - "Delete omits the entire frozen block; create and modify retain it") - } - } - } - - @MainActor - private func withLanguages(_ assertion: @MainActor (String) -> Void) { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - for language in ["en", "zh-Hans"] { - AppLanguage.current = language - assertion(language) - } - } - - @MainActor - private func hosting(operation: BrokerApprovalOperation, instructions: String = "Use only on staging.", - group: String = "Staging") -> NSHostingView { - let summary = BrokerCredentialWriteSummary(credentialName: "Staging API", operation: operation, - before: [], after: [], beforeDigest: nil, afterDigest: nil, - beforeUsageInstructions: instructions, afterUsageInstructions: instructions, - beforeGroup: group, afterGroup: group) - return NSHostingView(rootView: FrozenWriteApprovalContent(writeSummary: summary).frame(width: 252)) - } -} diff --git a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift index 3fe0742..8a840b0 100644 --- a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift +++ b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift @@ -1,160 +1,190 @@ -import AppKit -import SwiftUI import XCTest import AskKeyBroker @testable import AskKeyAppKit +/// Titles, read-card sections and buttons in English and Simplified Chinese. +@MainActor final class ApprovalPromptContentTests: AskKeyAppTestCase { - private func request( - _ operation: BrokerApprovalOperation = .read, - purpose: String? = "Deploy staging", - display: BrokerApprovalOperationRequest.Display? = nil - ) -> BrokerApprovalOperationRequest { - BrokerApprovalOperationRequest( - operationID: "synthetic-operation", - credentialID: "synthetic-credential", - targetID: "synthetic-credential", - operation: operation, - payloadDigest: "synthetic-digest", - credentialName: "demo-api", - callerName: "Demo Agent", - callerPurpose: purpose, - display: display - ) - } + private typealias Fixtures = ApprovalCardFixtures - private func display( - command: String = "./deploy.sh --env staging", - environment: [String]? = ["API_KEY"], - files: [String]? = [] - ) -> BrokerApprovalOperationRequest.Display { - .init( - commandLine: command, - workingDirectory: "~/work/shop-api", - executableBasename: "deploy.sh", - environmentVariables: environment, - temporaryFileVariables: files - ) + private func plain(_ text: String) -> String { + text.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") } - private func withLanguage(_ language: String, _ body: () throws -> Void) rethrows { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - AppLanguage.current = language - try body() + private func content(_ operation: BrokerApprovalOperation, display: BrokerApprovalOperationRequest.Display? = nil, + valueOnly: Bool = false, steps: Int? = nil) -> ApprovalPromptContent { + ApprovalPromptContent(request: Fixtures.request(operation, display: display), credentialName: "Staging API", + valueOnlyChange: valueOnly, organizationSteps: steps) } - func testReadPromptShowsTargetAndDetailsWithUnverifiedPurpose() { - withLanguage("en") { - let content = ApprovalPromptContent( - request: request(display: display(files: ["CERT_FILE"])), - credentialName: "demo-api" - ) - XCTAssertEqual(content.title, "“Demo Agent” wants to use “demo-api”") - XCTAssertEqual(content.commandSummary, "./deploy.sh --env staging") - 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)", - ]) - } - withLanguage("zh-Hans") { - let content = ApprovalPromptContent( - request: request(display: display(environment: ["API_KEY", "API_ENDPOINT"])), - 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(\.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. - ]) + func testTitlesNameTheObjectInBothLanguages() { + Fixtures.withLanguages { language in + let titles = [ + content(.read), content(.create), content(.modify, valueOnly: true), content(.modify), + content(.delete), content(.organize, steps: 4), content(.organize, steps: 1), content(.organize), + ].map { plain($0.title) } + if language == "en" { + XCTAssertEqual(titles, [ + "Claude Code wants to use the credential “Staging API”", + "Claude Code wants to create the credential “Staging API”", + "Claude Code wants to replace the value of “Staging API”", + "Claude Code wants to change the credential “Staging API”", + "Claude Code wants to delete the credential “Staging API”", + "Claude Code wants to reorganize your groups (4 steps)", + "Claude Code wants to reorganize your groups (1 step)", + "Claude Code wants to reorganize your groups", + ]) + } else { + XCTAssertEqual(titles, [ + "Claude Code 想使用凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. + "Claude Code 想新建凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. + "Claude Code 想替换凭证「Staging API」的值", // i18n-literal: Assert the Simplified Chinese approval title. + "Claude Code 想修改凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. + "Claude Code 想删除凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese approval title. + "Claude Code 想调整分组(共 4 步)", // i18n-literal: Assert the Simplified Chinese approval title. + "Claude Code 想调整分组(共 1 步)", // i18n-literal: Assert the Simplified Chinese approval title. + "Claude Code 想调整分组", // i18n-literal: Assert the Simplified Chinese approval title. + ]) + } } } - func testBundlesShowNoDeliveredNames() { - withLanguage("en") { - let content = ApprovalPromptContent( - request: request(display: display(environment: nil, files: nil)), - credentialName: "demo-api" - ) - XCTAssertFalse(content.rows.map(\.label).contains("Delivers")) - XCTAssertNil(ApprovalPromptContent.deliveredNames(display(environment: [], files: []))) + func testNamesKeepTheirQuotesAndWrapAsAWhole() { + Fixtures.withLanguages { language in + let quoted = ApprovalCopy.quoted("Staging API") + XCTAssertEqual(plain(quoted), language == "en" ? "“Staging API”" : "「Staging API」") // i18n-literal: Chinese corner brackets. + XCTAssertFalse(quoted.contains(" "), "spaces inside a name never break") + XCTAssertEqual(quoted.filter { $0 == "\u{2060}" }.count, "Staging API".count + 1) + XCTAssertEqual(plain(ApprovalCopy.group(nil)), language == "en" ? "“Ungrouped”" : "「未分组」") // i18n-literal: Chinese ungrouped name. } } - func testFullCommandMovesIntoDetailsOnlyWhenTheLineCannotShowIt() { - withLanguage("en") { - let short = ApprovalPromptContent(request: request(display: display()), credentialName: "demo-api") - XCTAssertTrue(short.commandFits(prefix: "to run", width: 268)) - XCTAssertEqual(short.detailRows(commandFits: true), short.rows) - - let longCommand = "./deploy.sh " + String(repeating: "--flag value ", count: 20) - let long = ApprovalPromptContent( - request: request(display: display(command: longCommand)), - credentialName: "demo-api" - ) - XCTAssertNotEqual(long.commandSummary, longCommand) - XCTAssertFalse(long.commandFits(prefix: "to run", width: 268)) - let rows = long.detailRows(commandFits: false) - XCTAssertEqual(rows.first?.label, "Command") - XCTAssertEqual(rows.first?.value, longCommand) - XCTAssertEqual(rows.first?.monospaced, true) + func testReadCardShowsTheFullCommandWhatItReceivesAndTheUnverifiedPurpose() { + let longCommand = "./deploy.sh " + (1...30).map { "--flag-\($0) value" }.joined(separator: " ") + Fixtures.withLanguages { language in + let read = content(.read, display: Fixtures.display(command: longCommand)) + XCTAssertEqual(read.command, longCommand, "the command is never shortened in the middle") + XCTAssertEqual(read.purpose, "Deploy the staging site") + XCTAssertEqual(read.receives.map { plain($0.plainText) }, [language == "en" + ? "“Staging API” · as environment variable STAGING_API_TOKEN" + : "「Staging API」· 作为环境变量 STAGING_API_TOKEN"]) // i18n-literal: Assert the Simplified Chinese delivery row. + XCTAssertEqual(read.receives.first?.segments.filter(\.code).map(\.text), ["STAGING_API_TOKEN"]) + XCTAssertEqual(read.detailRows.map(\.label), language == "en" + ? ["Runs in", "Requested by"] : ["运行目录", "请求方"]) // i18n-literal: Assert Simplified Chinese detail labels. + XCTAssertEqual(read.detailRows.map(\.value), ["~/web", language == "en" + ? "Claude Code (name provided by the requester; Ask Key can't verify it)" + : "Claude Code(名称由请求方提供,请旨无法核实)"]) // i18n-literal: Assert the Simplified Chinese requester row. + XCTAssertEqual(read.detailRows.first?.monospaced, true) + let headings = ["Command to run", "If you allow, this command receives", "Stated purpose (not verified)", "Details"] + .map { appLocalized($0) } + XCTAssertEqual(headings, language == "en" + ? ["Command to run", "If you allow, this command receives", "Stated purpose (not verified)", "Details"] : [ + "要运行的命令", "批准后这个命令会拿到", "对方说的用途(未核实)", "详细信息", // i18n-literal: Assert Simplified Chinese read headings. + ]) } } - func testCancelledAuthenticationCopyFollowsTheOperation() { - withLanguage("zh-Hans") { - let read = ApprovalPromptContent(request: request(), credentialName: "demo-api") - XCTAssertEqual(read.cancelledAuthenticationNote, "你取消了验证,凭证没有交付,请求仍在等待。") // i18n-literal: Assert the Simplified Chinese approval copy. - XCTAssertEqual(read.retryTitle, "重新验证并允许") // i18n-literal: Assert the Simplified Chinese approval copy. + func testReadDeliveryRowsCoverFilesMultiItemCredentialsAndNoDelivery() { + Fixtures.withLanguages { language in + let file = content(.read, display: Fixtures.display(environment: [], files: ["STAGING_CERT_FILE"])) + let bundle = content(.read, display: Fixtures.display(environment: nil, files: nil)) + let nothing = content(.read, display: Fixtures.display(environment: [], files: [])) + let rows = [file, bundle, nothing].map { $0.receives.map { plain($0.plainText) } } + if language == "en" { + XCTAssertEqual(rows, [ + ["“Staging API” · as a temporary file (path in STAGING_CERT_FILE, removed within 5 minutes)"], + ["The items of “Staging API” that are set to be given to programs (names are shown after you approve)"], + ["Nothing from this credential is given to the command"], + ]) + } else { + XCTAssertEqual(rows, [ + ["「Staging API」· 作为临时文件(路径在 STAGING_CERT_FILE,最多 5 分钟后删除)"], // i18n-literal: Assert the Simplified Chinese file row. + ["「Staging API」里设为交给程序的所有项(具体名称批准后才能看到)"], // i18n-literal: Assert the Simplified Chinese bundle row. + ["不交给这个命令任何值"], // i18n-literal: Assert the Simplified Chinese no-delivery row. + ]) + } + XCTAssertTrue(content(.delete, display: Fixtures.display()).receives.isEmpty) + XCTAssertNil(content(.create, display: Fixtures.display()).command, "write cards never show a run target") } - withLanguage("en") { - let modify = ApprovalPromptContent(request: request(.modify), credentialName: "demo-api") - XCTAssertEqual(modify.title, "“Demo Agent” wants to modify “demo-api”") - XCTAssertEqual( - modify.cancelledAuthenticationNote, - "You cancelled authentication. Nothing was changed, and the request is still pending." - ) - XCTAssertEqual(modify.retryTitle, "Authenticate and approve") + } + + func testButtonsStateTheirConsequenceInBothLanguages() { + Fixtures.withLanguages { language in + let titles = [ + FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: true), + FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: false), + FrozenApprovalActions.titles(operation: .create, timedAllowanceEnabled: true), + FrozenApprovalActions.titles(operation: .modify, timedAllowanceEnabled: true), + FrozenApprovalActions.titles(operation: .modify, timedAllowanceEnabled: true, valueOnlyChange: true), + FrozenApprovalActions.titles(operation: .delete, timedAllowanceEnabled: true), + FrozenApprovalActions.titles(operation: .organize, timedAllowanceEnabled: true, steps: 4), + FrozenApprovalActions.titles(operation: .organize, timedAllowanceEnabled: true, steps: 1), + ] + if language == "en" { + XCTAssertEqual(titles, [ + ["Allow Once", "Allow for 30 Minutes", "Deny"], ["Allow Once", "Deny"], + ["Create Credential", "Deny"], ["Save Changes", "Deny"], ["Replace Value", "Deny"], + ["Move to Trash", "Deny"], ["Apply 4 Steps", "Deny"], ["Apply 1 Step", "Deny"], + ]) + XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), + "For 30 minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.") + } else { + XCTAssertEqual(titles, [ + ["允许本次", "30 分钟内都允许", "拒绝"], ["允许本次", "拒绝"], // i18n-literal: Assert Simplified Chinese read buttons. + ["新建凭证", "拒绝"], ["保存修改", "拒绝"], ["替换值", "拒绝"], // i18n-literal: Assert Simplified Chinese write buttons. + ["移到回收站", "拒绝"], ["执行这 4 步", "拒绝"], ["执行这 1 步", "拒绝"], // i18n-literal: Assert Simplified Chinese write buttons. + ]) + XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), + "30 分钟内,这个 macOS 用户下的任何 Agent 或命令读取这个凭证都不再询问;不包括修改。") // i18n-literal: Assert the Simplified Chinese timed scope. + } + XCTAssertEqual(FrozenApprovalActions.primary(operation: .delete).role, .destructive) + XCTAssertEqual(FrozenApprovalActions.primary(operation: .organize, steps: 2, destructive: true).role, .destructive) + XCTAssertEqual(FrozenApprovalActions.primary(operation: .organize, steps: 2).role, .primary) + XCTAssertEqual(FrozenApprovalActions.primary(operation: .create).role, .primary) } } - func testWriteApprovalsKeepTheirContentWithoutARunTarget() { - withLanguage("en") { - let content = ApprovalPromptContent( - request: request(.delete, display: display()), - credentialName: "demo-api" - ) - XCTAssertEqual(content.title, "“Demo Agent” wants to delete “demo-api”") - XCTAssertNil(content.commandSummary) - XCTAssertEqual(content.rows.map(\.label), ["Requested by", "Stated purpose", "Destination"]) + func testCancelledAuthenticationRetriesTheOriginalChoice() { + Fixtures.withLanguages { language in + let read = FrozenApprovalActions.primary(operation: .read) + let trash = FrozenApprovalActions.primary(operation: .delete) + let retries = [ + FrozenApprovalActions.retry(.once, primary: read, minutes: 30), + FrozenApprovalActions.retry(.timedAllow(duration: nil), primary: read, minutes: 30), + FrozenApprovalActions.retry(.once, primary: trash, minutes: 30), + ] + XCTAssertEqual(retries, language == "en" + ? ["Authenticate and Allow Once", "Authenticate and Allow for 30 Minutes", "Authenticate and Move to Trash"] + : ["重新验证,允许本次", "重新验证,30 分钟内都允许", "重新验证,移到回收站"]) // i18n-literal: Assert Simplified Chinese retry buttons. + XCTAssertEqual(content(.read).cancelledAuthenticationNote, language == "en" + ? "You cancelled authentication. The credential was not delivered, and the request is still pending." + : "你取消了验证,凭证没有交付,请求仍在等待。") // i18n-literal: Assert the Simplified Chinese cancelled note. } } - @MainActor - func testPromptStatesRenderAtTheAlertWidth() throws { - _ = NSApplication.shared - let states: [FrozenAgentApprovalPrompt] = [ - .init(request: request(display: display()), timedAllowanceEnabled: true, finish: { _ in }), - .init(request: request(display: display()), timedAllowanceEnabled: true, - detailsExpanded: true, finish: { _ in }), - .init(request: request(display: display()), timedAllowanceEnabled: true, - cancelledAuthenticationDecision: .once, finish: { _ in }), - ] - var heights: [CGFloat] = [] - for state in states { - let hosting = NSHostingView(rootView: state) - hosting.appearance = NSAppearance(named: .aqua) - let size = hosting.fittingSize - XCTAssertEqual(size.width, FrozenAgentApprovalPrompt.width) - heights.append(size.height) + func testFooterAndOverflowCopyInBothLanguages() { + Fixtures.withLanguages { language in + let footer = appLocalizedFormat("Expires in %@", "4:59") + " · " + appLocalized("Esc to decide later in Pending requests") + let hints = [ + ApprovalOverflow.hint(unit: .steps, hiddenHeight: 120, hiddenMarkers: 3), + ApprovalOverflow.hint(unit: .steps, hiddenHeight: 20, hiddenMarkers: 1), + ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 46, hiddenMarkers: 0), + ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 10, hiddenMarkers: 0), + ApprovalOverflow.hint(unit: .sections, hiddenHeight: 80, hiddenMarkers: 2), + ApprovalOverflow.hint(unit: .sections, hiddenHeight: 8, hiddenMarkers: 0), + ApprovalOverflow.hint(unit: .steps, hiddenHeight: 0.5, hiddenMarkers: 0), + ] + if language == "en" { + XCTAssertEqual(footer, "Expires in 4:59 · Esc to decide later in Pending requests") + XCTAssertEqual(hints, ["3 more steps — scroll to see them", "1 more step — scroll to see it", + "3 more lines — scroll to see them", "1 more line — scroll to see it", + "2 more sections below — scroll to see them", "More below — scroll to see it", nil]) + } else { + XCTAssertEqual(footer, "4:59 后自动失效 · Esc 先收起,稍后在「待处理请求」里决定") // i18n-literal: Assert the Simplified Chinese footer. + XCTAssertEqual(hints, ["还有 3 步,向下滚动查看", "还有 1 步,向下滚动查看", // i18n-literal: Assert Simplified Chinese overflow hints. + "还有 3 行,向下滚动查看", "还有 1 行,向下滚动查看", // i18n-literal: Assert Simplified Chinese overflow hints. + "下面还有 2 部分,向下滚动查看", "下面还有内容,向下滚动查看", nil]) // i18n-literal: Assert Simplified Chinese overflow hints. + } } - XCTAssertGreaterThan(heights[1], heights[0], "expanding Details must grow the prompt") - XCTAssertLessThan(heights[2], heights[0] + 40, "the cancelled state drops the timed action") } } diff --git a/Tests/AskKeyAppTests/Batch4SettingsLanguageTests.swift b/Tests/AskKeyAppTests/Batch4SettingsLanguageTests.swift index 1115727..6dad6a8 100644 --- a/Tests/AskKeyAppTests/Batch4SettingsLanguageTests.swift +++ b/Tests/AskKeyAppTests/Batch4SettingsLanguageTests.swift @@ -107,8 +107,8 @@ final class Batch4SettingsLanguageTests: AskKeyAppTestCase { func testFrozenWriteRevealAndApprovalReasonsStayLanguagePure() { AppLanguage.current = "zh-Hans" let chineseReveal = FrozenWriteRevealCopy.content(before: "old", after: "new") - XCTAssertTrue(chineseReveal.contains("修改前")) - XCTAssertTrue(chineseReveal.contains("修改后")) + XCTAssertTrue(chineseReveal.contains("原来")) + XCTAssertTrue(chineseReveal.contains("改为")) XCTAssertFalse(chineseReveal.contains("Before")) XCTAssertFalse(chineseReveal.contains("After")) XCTAssertEqual( @@ -124,8 +124,8 @@ final class Batch4SettingsLanguageTests: AskKeyAppTestCase { let englishReveal = FrozenWriteRevealCopy.content(before: "old", after: "new") XCTAssertTrue(englishReveal.contains("Before")) XCTAssertTrue(englishReveal.contains("After")) - XCTAssertFalse(englishReveal.contains("修改前")) - XCTAssertFalse(englishReveal.contains("修改后")) + XCTAssertFalse(englishReveal.contains("原来")) + XCTAssertFalse(englishReveal.contains("改为")) AppLanguage.current = "en" } diff --git a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift index 6be0064..87265a3 100644 --- a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift +++ b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift @@ -2,115 +2,188 @@ import XCTest import AskKeyBroker @testable import AskKeyAppKit +/// Write cards: plain-language item rows, status tags, the change summary, +/// the value box and what happens after approval. +@MainActor final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { - private typealias Row = FrozenWriteSummaryContent.Row + private typealias Fixtures = ApprovalCardFixtures - private struct Copy { - let before: String - let after: String - let unchanged: String - let none: String - let ungrouped: String + private func content(_ summary: BrokerCredentialWriteSummary) -> FrozenWriteSummaryContent { + FrozenWriteSummaryContent(summary: summary, requester: "Claude Code") } - private func withLanguages(_ assertion: (Copy) -> Void) { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - let fixtures = [ - ("en", Copy(before: "Before", after: "After", unchanged: "Unchanged", none: "None", ungrouped: "Ungrouped")), - ("zh-Hans", Copy(before: "修改前", after: "修改后", unchanged: "不变", none: "无", ungrouped: "未分组")), // i18n-literal: Assert localized write summary rows. - ] - for (language, copy) in fixtures { - AppLanguage.current = language - assertion(copy) - } + private func lines(_ content: FrozenWriteSummaryContent) -> [String] { + content.components.map(\.line.plainText) } - private func component(_ byteCount: Int = 24) -> BrokerCredentialComponentSummary { - .init(name: "token", payloadKind: .text, byteCount: byteCount, - delivery: .environmentVariable("STAGING_TOKEN"), masked: true) + private func modification(before: [BrokerCredentialComponentSummary], after: [BrokerCredentialComponentSummary], + beforeDigest: String? = "old", afterDigest: String? = "new") -> BrokerCredentialWriteSummary { + .init(credentialName: "Release Check", operation: .modify, before: before, after: after, + beforeDigest: beforeDigest, afterDigest: afterDigest, beforeUsageInstructions: "Use for release checks.", + afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Release Tools") } - private func modification(beforeDigest: String? = "same", afterDigest: String? = "same", - afterBytes: Int = 24, beforeInstructions: String? = "Original guidance", afterInstructions: String? = "Original guidance", - beforeGroup: String? = "Staging", afterGroup: String? = "Staging") -> BrokerCredentialWriteSummary { - .init(credentialName: "Staging API", operation: .modify, before: [component()], after: [component(afterBytes)], - beforeDigest: beforeDigest, afterDigest: afterDigest, beforeUsageInstructions: beforeInstructions, - afterUsageInstructions: afterInstructions, beforeGroup: beforeGroup, afterGroup: afterGroup) + func testCreateListsEveryItemInPlainWordsAndStatesThePermission() { + Fixtures.withLanguages { language in + let create = content(Fixtures.createSummary) + if language == "en" { + XCTAssertEqual(lines(create), [ + "TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN", + "CERT · 1,234 bytes · given to programs as a temporary file (path in RELEASE_CERT_FILE)", + "RECOVERY_CODE · 16 bytes · kept in Ask Key only, never given to agents", + ]) + XCTAssertEqual(create.afterApproval, + "The credential's agent permission will be Ask every time: agents need your approval each time they use it.") + XCTAssertEqual(create.valueHeading, "Value to save (provided by Claude Code)") + XCTAssertEqual(appLocalizedFormat("Items (%lld)", create.itemCount), "Items (3)") + } else { + XCTAssertEqual(lines(create), [ + "TOKEN · 28 字节 · 使用时作为环境变量 RELEASE_CHECK_TOKEN 交给程序", // i18n-literal: Assert the Simplified Chinese item row. + "CERT · 1,234 字节 · 使用时作为临时文件交给程序(路径在 RELEASE_CERT_FILE)", // i18n-literal: Assert the Simplified Chinese item row. + "RECOVERY_CODE · 16 字节 · 只存在请旨里,不交给任何 Agent", // i18n-literal: Assert the Simplified Chinese item row. + ]) + XCTAssertEqual(create.afterApproval, "这个凭证的 Agent 权限是「每次询问」:Agent 每次使用都要你批准。") // i18n-literal: Assert the Simplified Chinese permission statement. + XCTAssertEqual(create.valueHeading, "要保存的值(由 Claude Code 提供)") // i18n-literal: Assert the Simplified Chinese value heading. + XCTAssertEqual(appLocalizedFormat("Items (%lld)", create.itemCount), "凭证内容(3 项)") // i18n-literal: Assert the Simplified Chinese items heading. + } + XCTAssertEqual(create.components.map(\.tag), [nil, nil, nil], "create rows carry no status tags") + XCTAssertEqual(create.components.first?.line.segments.filter(\.code).map(\.text), ["RELEASE_CHECK_TOKEN"]) + XCTAssertEqual(create.valueComponents.map(\.name), ["TOKEN", "CERT", "RECOVERY_CODE"]) + XCTAssertEqual(create.instructions, .current("Use only for the release smoke check. Keep values out of logs.")) + XCTAssertEqual(create.group.plain, .current(language == "en" ? "“Release Tools”" : "「Release Tools」")) // i18n-literal: Chinese corner brackets. + XCTAssertTrue(create.createsGroup) + XCTAssertNil(create.changeSummary) + } } - func testCreateShowsOnlyValuesToWriteWithoutComparisonLabels() { - withLanguages { _ in - let summary = BrokerCredentialWriteSummary(credentialName: "Staging API", operation: .create, - before: [], after: [component()], beforeDigest: nil, afterDigest: "new", - beforeUsageInstructions: nil, afterUsageInstructions: "New guidance", - beforeGroup: nil, afterGroup: "New Services", createsGroup: true) - let content = FrozenWriteSummaryContent(summary: summary) - XCTAssertEqual(content.components, [Row(label: nil, values: ["token · 24 B · STAGING_TOKEN"])]) - XCTAssertEqual(content.instructions, [Row(label: nil, values: ["New guidance"])]) - XCTAssertEqual(content.group, [Row(label: nil, values: ["New Services"])]) - XCTAssertTrue(content.createsGroup) + func testMetadataOnlyChangeFoldsUnchangedSectionsIntoTheSummary() { + Fixtures.withLanguages { language in + let modify = content(Fixtures.metadataSummary) + XCTAssertTrue(modify.components.isEmpty, "unchanged items collapse into the summary line") + XCTAssertTrue(modify.valueComponents.isEmpty, "no value box without a new or replaced value") + XCTAssertEqual(modify.instructionsTag, .changed) + XCTAssertEqual(modify.group, .unchanged) + XCTAssertFalse(modify.valueOnlyChange) + if language == "en" { + XCTAssertEqual(modify.changeSummary, "Changes: instructions · Unchanged: items, group") + XCTAssertEqual(modify.instructions, .changed(before: "Use for release checks.", after: "Use only for the nightly release check.")) + XCTAssertEqual([appLocalized("Before"), appLocalized("After")], ["Before", "After"]) + } else { + XCTAssertEqual(modify.changeSummary, "会改动:使用说明 · 不变:凭证内容、分组") // i18n-literal: Assert the Simplified Chinese change summary. + XCTAssertEqual([appLocalized("Before"), appLocalized("After")], ["原来", "改为"]) // i18n-literal: Assert Simplified Chinese comparison labels. + } } } - func testDeleteShowsOnlyCurrentValuesWithoutComparisonLabels() { - withLanguages { _ in - let summary = BrokerCredentialWriteSummary(credentialName: "Staging API", operation: .delete, - before: [component()], after: [], beforeDigest: "current", afterDigest: nil, - beforeUsageInstructions: "Current guidance", afterUsageInstructions: nil, - beforeGroup: "Current Services", afterGroup: nil) - let content = FrozenWriteSummaryContent(summary: summary) - XCTAssertEqual(content.components, [Row(label: nil, values: ["token · 24 B · STAGING_TOKEN"])]) - XCTAssertEqual(content.instructions, [Row(label: nil, values: ["Current guidance"])]) - XCTAssertEqual(content.group, [Row(label: nil, values: ["Current Services"])]) - XCTAssertFalse(content.createsGroup) + func testValueOnlyChangeTagsTheReplacementAndNamesWhoProvidedIt() { + Fixtures.withLanguages { language in + let modify = content(Fixtures.valueSummary) + XCTAssertTrue(modify.valueOnlyChange) + XCTAssertEqual(modify.components.map(\.tag), [.replaced]) + XCTAssertEqual(modify.componentsTag, .changed) + XCTAssertTrue(modify.overwritesValues) + XCTAssertEqual(modify.valueComponents.map(\.byteCount), [52]) + XCTAssertEqual(modify.instructions, .unchanged) + let replacement = modify.components.first?.notes.map(\.plainText) + if language == "en" { + XCTAssertEqual(lines(modify), ["TOKEN · given to programs as environment variable RELEASE_CHECK_TOKEN"]) + XCTAssertEqual(replacement, ["old value 40 bytes → new value 52 bytes (new value from Claude Code)"]) + XCTAssertEqual(modify.changeSummary, "Changes: items · Unchanged: instructions, group") + XCTAssertEqual(modify.valueHeading, "New value (provided by Claude Code)") + XCTAssertEqual(ApprovalTag.replaced.title, "Replaced") + } else { + XCTAssertEqual(replacement, ["旧值 40 字节 → 新值 52 字节(新值由 Claude Code 提供)"]) // i18n-literal: Assert the Simplified Chinese replacement row. + XCTAssertEqual(modify.changeSummary, "会改动:凭证内容 · 不变:使用说明、分组") // i18n-literal: Assert the Simplified Chinese change summary. + XCTAssertEqual(ApprovalTag.replaced.title, "替换") // i18n-literal: Assert the Simplified Chinese tag. + } } } - func testMetadataOnlyModifyShowsUnchangedComponentsAndGroupOnce() { - withLanguages { copy in - let content = FrozenWriteSummaryContent(summary: modification(afterInstructions: "New guidance")) - XCTAssertEqual(content.components, [Row(label: copy.unchanged, values: ["token · 24 B · STAGING_TOKEN"])]) - XCTAssertEqual(content.instructions, [Row(label: copy.before, values: ["Original guidance"]), - Row(label: copy.after, values: ["New guidance"])]) - XCTAssertEqual(content.group, [Row(label: copy.unchanged, values: ["Staging"])]) - XCTAssertFalse(content.createsGroup) + func testAddedItemMarksSameSizeItemsAsPossiblyReplaced() { + Fixtures.withLanguages { language in + let modify = content(Fixtures.addedSummary) + XCTAssertEqual(modify.components.map(\.tag), [.mayBeReplaced, .mayBeReplaced, .new]) + XCTAssertEqual(modify.valueComponents.map(\.name), ["TOKEN", "USER", "CERT"]) + XCTAssertFalse(modify.valueOnlyChange) + XCTAssertEqual(modify.components.first?.notes.map(\.plainText), [language == "en" + ? "Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view." + : "大小和交付方式没变,无法确认值是否被替换;可验证后查看"]) // i18n-literal: Assert the Simplified Chinese uncertainty line. + XCTAssertEqual([ApprovalTag.mayBeReplaced, .new].map(\.title), language == "en" + ? ["May be replaced", "New"] : ["可能已替换", "新增"]) // i18n-literal: Assert Simplified Chinese tags. } } - func testValueOnlyModifyShowsUnchangedMetadataOnce() { - withLanguages { copy in - let content = FrozenWriteSummaryContent(summary: modification(beforeDigest: "old", afterDigest: "new", afterBytes: 25)) - XCTAssertEqual(content.components, [Row(label: copy.before, values: ["token · 24 B · STAGING_TOKEN"]), - Row(label: copy.after, values: ["token · 25 B · STAGING_TOKEN"])]) - XCTAssertEqual(content.instructions, [Row(label: copy.unchanged, values: ["Original guidance"])]) - XCTAssertEqual(content.group, [Row(label: copy.unchanged, values: ["Staging"])]) + func testOnlyProvableStatesAreClaimed() { + let previous = AppLanguage.current + defer { AppLanguage.current = previous } + AppLanguage.current = "en" + let token = Fixtures.token + let user = Fixtures.user + // Equal digests prove nothing changed; the items section collapses. + XCTAssertTrue(content(modification(before: [token], after: [token], beforeDigest: "same", afterDigest: "same")).components.isEmpty) + // A lone same-size item whose digest changed was replaced. + XCTAssertEqual(content(modification(before: [token], after: [token])).components.map(\.tag), [.replaced]) + // Missing digests prove nothing. + XCTAssertEqual(content(modification(before: [token], after: [token], beforeDigest: nil, afterDigest: nil)) + .components.map(\.tag), [.mayBeReplaced]) + // Two same-size items: either may be the replaced one. + XCTAssertEqual(content(modification(before: [token, user], after: [token, user])).components.map(\.tag), + [.mayBeReplaced, .mayBeReplaced]) + let moved = Fixtures.component("TOKEN", bytes: 28, .temporaryFile("RELEASE_CHECK_FILE")) + let changed = content(modification(before: [token, user], after: [moved])) + XCTAssertEqual(changed.components.map(\.tag), [.changed, .removed]) + XCTAssertEqual(changed.components.first?.notes.map(\.plainText), [ + "Before: TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN", + "Same size; Ask Key can't tell whether the value was replaced. Authenticate to view.", + ]) + XCTAssertEqual(changed.valueComponents.map(\.name), ["TOKEN"], "a re-sent value may differ, so it can be viewed") + XCTAssertEqual([ApprovalTag.changed, .removed].map(\.title), ["Changed", "Removed"]) + } + + func testDeleteShowsWhatIsDeletedAndWhereItGoes() { + Fixtures.withLanguages { language in + let delete = content(Fixtures.deleteSummary) + XCTAssertEqual(delete.components.map(\.tag), [nil, nil]) + XCTAssertTrue(delete.valueComponents.isEmpty) + XCTAssertEqual(delete.itemCount, 2) + XCTAssertEqual(delete.instructions, .current("Use for release checks.")) + XCTAssertEqual(delete.afterApproval, language == "en" + ? "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile." + : "凭证会移到回收站保留 30 天,期间可以在回收站恢复,之后永久删除。这段时间里 Agent 不能使用,也看不到它。") // i18n-literal: Assert the Simplified Chinese delete statement. + XCTAssertEqual([appLocalized("Instructions for agents"), appLocalized("Group"), appLocalized("After you approve")], + language == "en" ? ["Instructions for agents", "Group", "After you approve"] + : ["给 Agent 的使用说明", "分组", "批准后"]) // i18n-literal: Assert Simplified Chinese section headings. } } - func testClearingMetadataShowsEmptyInstructionAndUngroupedAfterValues() { - withLanguages { copy in - let content = FrozenWriteSummaryContent(summary: modification(afterInstructions: "", afterGroup: nil)) - XCTAssertEqual(content.components, [Row(label: copy.unchanged, values: ["token · 24 B · STAGING_TOKEN"])]) - XCTAssertEqual(content.instructions, [Row(label: copy.before, values: ["Original guidance"]), - Row(label: copy.after, values: [copy.none])]) - XCTAssertEqual(content.group, [Row(label: copy.before, values: ["Staging"]), - Row(label: copy.after, values: [copy.ungrouped])]) + func testNewGroupTagIsNeutralAndExplained() { + Fixtures.withLanguages { language in + XCTAssertEqual([ApprovalTag.newGroup.title, appLocalized("created when you approve")], language == "en" + ? ["New group", "created when you approve"] : ["新分组", "批准后创建"]) // i18n-literal: Assert the Simplified Chinese new-group tag. + XCTAssertEqual(ApprovalTag.newGroup.color, Theme.text, "a new group is not a warning") + XCTAssertEqual(ApprovalTag.removed.color, Theme.warning) + XCTAssertEqual(ApprovalTag.unchanged.color, Theme.textSecondary) } } - func testSameLengthValueReplacementIsNotLabeledUnchanged() { - withLanguages { copy in - let content = FrozenWriteSummaryContent(summary: modification(beforeDigest: "old-value", afterDigest: "new-value")) - XCTAssertEqual(content.components, [Row(label: copy.before, values: ["token · 24 B · STAGING_TOKEN"]), - Row(label: copy.after, values: ["token · 24 B · STAGING_TOKEN"])]) + func testNothingChangedIsSaidPlainly() { + Fixtures.withLanguages { language in + let same = content(modification(before: [Fixtures.token], after: [Fixtures.token], beforeDigest: "same", afterDigest: "same")) + XCTAssertEqual(same.changeSummary, language == "en" ? "Nothing changes" : "不会改动任何内容") // i18n-literal: Assert the Simplified Chinese no-change summary. } } +} - func testMissingValueDigestsDoNotClaimComponentsAreUnchanged() { - withLanguages { copy in - let content = FrozenWriteSummaryContent(summary: modification(beforeDigest: nil, afterDigest: nil)) - XCTAssertEqual(content.components.map(\.label), [copy.before, copy.after]) +private extension FrozenWriteSummaryContent.Change { + /// The displayed text without the word joiners that keep names whole. + var plain: Self { + func clean(_ text: String) -> String { + text.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") + } + switch self { + case .current(let value): return .current(clean(value)) + case .changed(let before, let after): return .changed(before: clean(before), after: clean(after)) + case .unchanged: return .unchanged } } } diff --git a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift index de73e3d..61c1397 100644 --- a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift +++ b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift @@ -176,7 +176,6 @@ final class LocalizationUnificationTests: AskKeyAppTestCase { let allowedAliases = [ "AskKey": "Ask Key", "Always Allow": "Allow", - "Allow Once": "Allow once", "Do Not Allow Agent": "Hidden", "Agent approvals": "Agent", "List separator": ", ", @@ -184,6 +183,9 @@ final class LocalizationUnificationTests: AskKeyAppTestCase { "Ask: Agents wait for your approval each time.": "Ask every time: Agents wait for your approval each time.", "New credentials default to Ask.": "New credentials default to Ask every time.", "Component list separator": ", ", + "Quoted name: %@": "“%@”", + "Sentence separator": " ", + "New group tag": "New group", ] XCTAssertEqual(english, allowedAliases[key], "English value must match the key or a declared alias: \(key)") } diff --git a/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift b/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift index 3193b6a..41bc456 100644 --- a/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift +++ b/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift @@ -1,137 +1,135 @@ -import AppKit -import SwiftUI import XCTest import AskKeyBroker @testable import AskKeyAppKit +/// Organize cards: steps as sentences, no-change and merge tags, hidden +/// counts and the step-counting button in both languages. +@MainActor final class OrganizationApprovalContentTests: AskKeyAppTestCase { - func testInvisibleExistingTargetsShowNoOpAndMergeTruthInBothLanguages() { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - let summary = BrokerOrganizationSummary(operations: [ - .existingGroup(name: "Target", members: 1, nonvisible: 1), - .mergeGroup(from: "Source", to: "Target", members: 4, nonvisible: 1, targetMembers: 2, targetNonvisible: 2), - ]) - for language in ["en", "zh-Hans"] { - AppLanguage.current = language - let rows = FrozenOrganizationSummaryContent(summary: summary).rows + private typealias Fixtures = ApprovalCardFixtures + + private func plain(_ text: String?) -> String? { + text?.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") + } + + func testRegularStepsReadAsSentences() { + Fixtures.withLanguages { language in + let content = FrozenOrganizationSummaryContent(summary: Fixtures.regularOrganization) + XCTAssertEqual(content.rows.map(\.number), [1, 2, 3, 4]) + XCTAssertEqual(content.rows.map(\.tag), [nil, nil, nil, nil]) + XCTAssertNil(content.rows[0].detail, "a new group needs no second line") + XCTAssertTrue(content.isDestructive, "deleting a group is styled as destructive") + let titles = content.rows.map { plain($0.title) } + let details = content.rows.map { plain($0.detail) } if language == "en" { - XCTAssertEqual(rows[0].title, "1. Create group “Target”") - XCTAssertEqual(rows[0].detail, "Group already exists — no changes. 1 credential, 1 not visible to agents") - XCTAssertEqual(rows[1].title, "2. Merge group “Source” into existing group “Target”") - XCTAssertEqual(rows[1].detail, "Source: 4 credentials, 1 not visible to agents. Existing group: 2 credentials, 2 not visible to agents.") + XCTAssertEqual(titles, [ + "Create the group “Staging Services”", + "Move the credential “Staging API” from “Old Services” to “Staging Services”", + "Rename the group “Old Services” to “Renamed Services”", + "Delete the group “Temp”", + ]) + XCTAssertEqual(details, [nil, nil, "It has 3 credentials (2 hidden from agents).", + "Its 1 credential won't be deleted and will become “Ungrouped”."]) } else { - XCTAssertEqual(rows[0].title, "1. 创建分组“Target”") // i18n-literal: Assert reviewed Simplified Chinese no-op copy. - XCTAssertEqual(rows[0].detail, "分组已存在,不会更改。1 个凭证,其中 1 个 Agent 看不到") // i18n-literal: Assert reviewed Simplified Chinese no-op copy. - XCTAssertEqual(rows[1].title, "2. 将分组“Source”合并到已有分组“Target”") // i18n-literal: Assert reviewed Simplified Chinese merge copy. - XCTAssertEqual(rows[1].detail, "来源:4 个凭证,其中 1 个 Agent 看不到;已有分组:2 个凭证,其中 2 个 Agent 看不到。") // i18n-literal: Assert reviewed Simplified Chinese merge counts. + XCTAssertEqual(titles, [ + "新建分组「Staging Services」", // i18n-literal: Assert the Simplified Chinese organize step. + "把凭证「Staging API」从「Old Services」移到「Staging Services」", // i18n-literal: Assert the Simplified Chinese organize step. + "把分组「Old Services」改名为「Renamed Services」", // i18n-literal: Assert the Simplified Chinese organize step. + "删除分组「Temp」", // i18n-literal: Assert the Simplified Chinese organize step. + ]) + XCTAssertEqual(details, [nil, nil, "组里有 3 个凭证(其中 2 个对 Agent 隐藏)。", // i18n-literal: Assert the Simplified Chinese step detail. + "组里 1 个凭证不会被删除,会变成「未分组」。"]) // i18n-literal: Assert the Simplified Chinese step detail. } } } - func testOrderedRowsShowGroupEffectsAndNonvisibleCountsInBothLanguages() { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - let summary = BrokerOrganizationSummary(operations: [.move(credential: "API", from: nil, to: "Staging"), - .createGroup("New"), .renameGroup(from: "Old", to: "New", members: 4, nonvisible: 1), - .deleteGroup(name: "Unused", members: 3, nonvisible: 2)]) - for language in ["en", "zh-Hans"] { - AppLanguage.current = language - let rows = FrozenOrganizationSummaryContent(summary: summary).rows - XCTAssertEqual(rows.count, 4) - XCTAssertTrue(rows[0].title.hasPrefix("1. ")) - XCTAssertTrue(rows[0].title.contains("API")) - XCTAssertTrue(rows[0].title.contains(appLocalized("Ungrouped"))) - XCTAssertTrue(rows[1].title.hasPrefix("2. ")) - XCTAssertEqual(rows[1].detail, appLocalized("New group — created when approved")) - XCTAssertEqual(rows[2].detail, appLocalizedFormat("%lld credentials, %lld not visible to agents", 4, 1)) - XCTAssertEqual(rows[3].detail, appLocalizedFormat("%lld credentials, %lld not visible to agents", 3, 2)) - if language == "zh-Hans" { - XCTAssertEqual(rows[2].title, "3. 重命名分组“Old” → “New”") // i18n-literal: Assert reviewed Simplified Chinese organization copy. - XCTAssertEqual(rows[2].detail, "4 个凭证,其中 1 个 Agent 看不到") // i18n-literal: Assert reviewed Simplified Chinese organization copy. - XCTAssertEqual(rows[3].detail, "3 个凭证,其中 2 个 Agent 看不到") // i18n-literal: Assert reviewed Simplified Chinese organization copy. - XCTAssertEqual(appLocalized("Proposed organization"), "待执行的整理") // i18n-literal: Assert reviewed Simplified Chinese organization copy. + func testExistingGroupAndMergeStartWithTheirTagsAndStateTheResult() { + Fixtures.withLanguages { language in + let content = FrozenOrganizationSummaryContent(summary: Fixtures.existingAndMerge) + XCTAssertEqual(content.rows.map(\.tag), [.noChange, .merge]) + XCTAssertTrue(content.isDestructive, "a merge cannot be undone automatically") + if language == "en" { + XCTAssertEqual(content.rows.map(\.tag?.title), ["No change", "Merge"]) + XCTAssertEqual(plain(content.rows[0].title), "Create the group “Existing Private Services”") + XCTAssertEqual(content.rows[0].detail, + "This group already exists, so nothing is created or changed. It has 1 credential (1 hidden from agents).") + XCTAssertEqual(plain(content.rows[1].title), "Merge the group “Merge Source” into the existing group “Existing Merge Services”") + XCTAssertEqual(plain(content.rows[1].detail), + "“Merge Source” disappears and “Existing Merge Services” will have 4 credentials (3 hidden from agents). A merge can't be undone automatically.") + } else { + XCTAssertEqual(content.rows.map(\.tag?.title), ["无变化", "合并"]) // i18n-literal: Assert Simplified Chinese organize tags. + XCTAssertEqual(content.rows[0].detail, + "这个分组已经存在,不会新建或改动任何内容。组里有 1 个凭证(其中 1 个对 Agent 隐藏)。") // i18n-literal: Assert the Simplified Chinese no-op detail. + XCTAssertEqual(plain(content.rows[1].title), "把分组「Merge Source」合并到已有分组「Existing Merge Services」") // i18n-literal: Assert the Simplified Chinese merge step. + XCTAssertEqual(plain(content.rows[1].detail), + "合并后「Merge Source」消失,「Existing Merge Services」共有 4 个凭证(其中 3 个对 Agent 隐藏)。合并无法自动撤销。") // i18n-literal: Assert the Simplified Chinese merge result. } - XCTAssertEqual(FrozenApprovalActions.titles(operation: .organize, timedAllowanceEnabled: true), - [appLocalized("Approve Organization"), appLocalized("Deny")]) } } - func testRenameAndDeleteMemberCountsPluralizeAndOmitZeroNonvisibleInBothLanguages() { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - let cases = [ - (members: 0, nonvisible: 0, english: "0 credentials", chinese: "0 个凭证"), // i18n-literal: Assert reviewed Simplified Chinese member counts. - (members: 1, nonvisible: 0, english: "1 credential", chinese: "1 个凭证"), // i18n-literal: Assert reviewed Simplified Chinese member counts. - (members: 2, nonvisible: 0, english: "2 credentials", chinese: "2 个凭证"), // i18n-literal: Assert reviewed Simplified Chinese member counts. - (members: 1, nonvisible: 1, english: "1 credential, 1 not visible to agents", chinese: "1 个凭证,其中 1 个 Agent 看不到"), // i18n-literal: Assert reviewed Simplified Chinese member counts. - (members: 2, nonvisible: 1, english: "2 credentials, 1 not visible to agents", chinese: "2 个凭证,其中 1 个 Agent 看不到"), // i18n-literal: Assert reviewed Simplified Chinese member counts. - (members: 2, nonvisible: 2, english: "2 credentials, 2 not visible to agents", chinese: "2 个凭证,其中 2 个 Agent 看不到"), // i18n-literal: Assert reviewed Simplified Chinese member counts. - ] - for language in ["en", "zh-Hans"] { - AppLanguage.current = language - for testCase in cases { - let summary = BrokerOrganizationSummary(operations: [ - .renameGroup(from: "Old", to: "New", members: testCase.members, nonvisible: testCase.nonvisible), - .deleteGroup(name: "Unused", members: testCase.members, nonvisible: testCase.nonvisible), - ]) - let rows = FrozenOrganizationSummaryContent(summary: summary).rows - let expected = language == "en" ? testCase.english : testCase.chinese - XCTAssertEqual(rows.map(\.detail), [expected, expected], "\(language), \(testCase.members) members, \(testCase.nonvisible) nonvisible") - } + func testHiddenClauseIsOmittedAtZeroAndEmptyGroupsSaySo() { + Fixtures.withLanguages { language in + let summary = BrokerOrganizationSummary(operations: [ + .renameGroup(from: "Old", to: "New", members: 2, nonvisible: 0), + .deleteGroup(name: "Unused", members: 0, nonvisible: 0), + .existingGroup(name: "Shared", members: 0, nonvisible: 0), + ]) + let details = FrozenOrganizationSummaryContent(summary: summary).rows.map { plain($0.detail) } + XCTAssertEqual(details, language == "en" + ? ["It has 2 credentials.", "The group is empty.", + "This group already exists, so nothing is created or changed. The group is empty."] + : ["组里有 2 个凭证。", "组里没有凭证。", "这个分组已经存在,不会新建或改动任何内容。组里没有凭证。"]) // i18n-literal: Assert Simplified Chinese member counts. + XCTAssertFalse(FrozenOrganizationSummaryContent(summary: BrokerOrganizationSummary(operations: [ + .createGroup("New"), .move(credential: "API", from: nil, to: "New"), + ])).isDestructive) } } - @MainActor - func testMaximumBatchAndExpandedDetailsKeepActionsWithin680PointsWithoutReveal() { - _ = NSApplication.shared - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - let request = BrokerApprovalOperationRequest(operationID: "synthetic", credentialID: "", targetID: "credential-library", - operation: .organize, payloadDigest: String(repeating: "a", count: 64), - callerName: "Synthetic Agent", callerPurpose: String(repeating: "Synthetic purpose ", count: 200), organizationCredentialIDs: []) - let summary = BrokerOrganizationSummary(operations: (0..<64).map { index in - index.isMultiple(of: 2) - ? .existingGroup(name: String(repeating: "Existing group ", count: 15), members: 1, nonvisible: 1) - : .mergeGroup(from: String(repeating: "Original group ", count: 15), to: String(repeating: "Existing group ", count: 15), - members: 4, nonvisible: 1, targetMembers: 2, targetNonvisible: 2) - }) - var reveals = 0 - for language in ["en", "zh-Hans"] { - AppLanguage.current = language - for (expanded, cancelled) in [(false, false), (true, false), (true, true)] { - let view = FrozenAgentApprovalPrompt(request: request, timedAllowanceEnabled: true, - organizationSummary: summary, revealMaterial: { reveals += 1; throw CancellationError() }, - cancelledAuthenticationDecision: cancelled ? .once : nil, - detailsExpanded: expanded, finish: { _ in }) - let size = NSHostingView(rootView: view).fittingSize - XCTAssertEqual(size.width, 300) - XCTAssertLessThanOrEqual(size.height, 680, "Every operation scrolls with Approve and Deny outside the scrolling area") + func testTwelveStepsAreNumberedAndCountedInTitleAndButton() { + Fixtures.withLanguages { language in + let content = FrozenOrganizationSummaryContent(summary: Fixtures.twelveSteps) + XCTAssertEqual(content.rows.map(\.number), Array(1...12)) + let title = ApprovalPromptContent(request: Fixtures.request(.organize), credentialName: "credential-library", + organizationSteps: content.rows.count).title + XCTAssertFalse(title.contains("credential-library")) + let primary = FrozenApprovalActions.primary(operation: .organize, steps: 12, destructive: content.isDestructive) + XCTAssertEqual(primary.role, .destructive) + if language == "en" { + XCTAssertEqual(title, "Claude Code wants to reorganize your groups (12 steps)") + XCTAssertEqual(primary.title, "Apply 12 Steps") + XCTAssertEqual(plain(content.rows[1].title), "Move the credential “Service 2” from “Ungrouped” to “Group 1”") + } else { + XCTAssertEqual(title, "Claude Code 想调整分组(共 12 步)") // i18n-literal: Assert the Simplified Chinese organize title. + XCTAssertEqual(primary.title, "执行这 12 步") // i18n-literal: Assert the Simplified Chinese organize button. + XCTAssertEqual(plain(content.rows[1].title), "把凭证「Service 2」从「未分组」移到「Group 1」") // i18n-literal: Assert the Simplified Chinese move step. } } - XCTAssertEqual(reveals, 0) } - func testOrganizationPromptCopyOmitsCredentialTargetAndOffersWriteAuthenticationRetry() { - let previous = AppLanguage.current - defer { AppLanguage.current = previous } - for language in ["en", "zh-Hans"] { - AppLanguage.current = language - let request = BrokerApprovalOperationRequest(operationID: "synthetic", credentialID: "", targetID: "credential-library", - operation: .organize, payloadDigest: String(repeating: "a", count: 64), callerName: "Synthetic Agent", organizationCredentialIDs: []) - let content = ApprovalPromptContent(request: request, credentialName: "credential-library") - XCTAssertEqual(content.title, language == "zh-Hans" - ? "“Synthetic Agent”想整理凭证" // i18n-literal: Assert reviewed Simplified Chinese organization copy. - : "“Synthetic Agent” wants to organize credentials") - XCTAssertFalse(content.title.contains("credential-library")) - XCTAssertNil(content.commandSummary) - XCTAssertEqual(content.retryTitle, appLocalized("Authenticate and approve")) - XCTAssertEqual(content.cancelledAuthenticationNote, appLocalized("You cancelled authentication. Nothing was changed, and the request is still pending.")) - let pending = BrokerPendingApproval(requestID: "request", capability: "capability", request: request) - XCTAssertEqual(PendingRequestPresentation(approval: pending).sentence.plainText, language == "zh-Hans" - ? "Synthetic Agent 想整理凭证" // i18n-literal: Assert reviewed Simplified Chinese organization copy. - : "Synthetic Agent wants to organize credentials") - XCTAssertFalse(PendingRequestPresentation(approval: pending).sentence.plainText.contains("credential-library")) + func testPendingListUsesTheSameObjectNamingVerbs() { + Fixtures.withLanguages { language in + let sentences = [ + Fixtures.request(.read, display: Fixtures.display()), Fixtures.request(.read), + Fixtures.request(.create), Fixtures.request(.modify), Fixtures.request(.delete), Fixtures.request(.organize), + ].map { request in + plain(PendingRequestPresentation(approval: BrokerPendingApproval(requestID: "request", capability: "capability", + request: request, trustedCredentialName: "Staging API")).sentence.plainText) + } + XCTAssertEqual(sentences, language == "en" ? [ + "Claude Code wants to use the credential “Staging API” to run ./deploy.sh --env staging", + "Claude Code wants to use the credential “Staging API”", + "Claude Code wants to create the credential “Staging API”", + "Claude Code wants to change the credential “Staging API”", + "Claude Code wants to delete the credential “Staging API”", + "Claude Code wants to reorganize your groups", + ] : [ + "Claude Code 想使用凭证「Staging API」运行 ./deploy.sh --env staging", // i18n-literal: Assert the Simplified Chinese pending sentence. + "Claude Code 想使用凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. + "Claude Code 想新建凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. + "Claude Code 想修改凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. + "Claude Code 想删除凭证「Staging API」", // i18n-literal: Assert the Simplified Chinese pending sentence. + "Claude Code 想调整分组", // i18n-literal: Assert the Simplified Chinese pending sentence. + ]) } } } diff --git a/Tests/AskKeyAppTests/ScreenPresentationTests.swift b/Tests/AskKeyAppTests/ScreenPresentationTests.swift index 70a9543..50abc3a 100644 --- a/Tests/AskKeyAppTests/ScreenPresentationTests.swift +++ b/Tests/AskKeyAppTests/ScreenPresentationTests.swift @@ -33,18 +33,18 @@ final class ScreenPresentationTests: WorkspaceVisualContractTestSupport { trustedCredentialName: "demo-api" ) let presentation = PendingRequestPresentation(approval: approval) - XCTAssertEqual(presentation.sentence.plainText, "Demo Agent 想用 demo-api") // i18n-literal: Expected Simplified Chinese catalog value. + XCTAssertEqual(unjoined(presentation.sentence.plainText), "Demo Agent 想使用凭证「demo-api」") // i18n-literal: Expected Simplified Chinese catalog value. XCTAssertFalse(presentation.sentence.plainText.contains("unverified purpose")) let now = Date(timeIntervalSince1970: 1_000) XCTAssertEqual( PendingRequestPresentation.expiry(deadline: now.addingTimeInterval(214), now: now), - "3:34 后失效" // i18n-literal: Expected Simplified Chinese catalog value. + "3:34 后自动失效" // i18n-literal: Expected Simplified Chinese catalog value. ) AppLanguage.current = "en" defer { AppLanguage.current = "zh-Hans" } XCTAssertEqual( - PendingRequestPresentation(approval: approval).sentence.plainText, - "Demo Agent wants to use demo-api" + unjoined(PendingRequestPresentation(approval: approval).sentence.plainText), + "Demo Agent wants to use the credential “demo-api”" ) } @@ -67,10 +67,15 @@ final class ScreenPresentationTests: WorkspaceVisualContractTestSupport { trustedCredentialName: "demo-api" ) let sentence = PendingRequestPresentation(approval: approval).sentence - XCTAssertEqual(sentence.plainText, "Demo Agent 想用 demo-api 运行 ./deploy.sh --env staging") // i18n-literal: Expected Simplified Chinese catalog value. + XCTAssertEqual(unjoined(sentence.plainText), "Demo Agent 想使用凭证「demo-api」运行 ./deploy.sh --env staging") // i18n-literal: Expected Simplified Chinese catalog value. XCTAssertEqual(sentence.runs.last, .init(text: "./deploy.sh --env staging", argument: 2)) } + /// Names are joined with invisible word joiners so they wrap as a whole. + private func unjoined(_ text: String) -> String { + text.replacingOccurrences(of: "\u{2060}", with: "").replacingOccurrences(of: "\u{00A0}", with: " ") + } + func testAccessRecordsGroupByDayNewestFirstWithColoredResults() { let now = Date(timeIntervalSince1970: 1_790_000_000) let today = calendar.startOfDay(for: now) diff --git a/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift b/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift index 06cdef8..e7a3e4e 100644 --- a/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift +++ b/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift @@ -103,16 +103,16 @@ final class WorkspaceInteractionTests: WorkspaceVisualContractTestSupport { AppLanguage.current = "zh-Hans" XCTAssertEqual( FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: true), - ["允许本次", "允许 30 分钟", "拒绝"] + ["允许本次", "30 分钟内都允许", "拒绝"] ) XCTAssertEqual( FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: false), ["允许本次", "拒绝"] ) let writeActions: [(BrokerApprovalOperation, String, String)] = [ - (.create, "批准创建", "Approve Creation"), - (.modify, "批准修改", "Approve Change"), - (.delete, "批准删除", "Approve Deletion"), + (.create, "新建凭证", "Create Credential"), + (.modify, "保存修改", "Save Changes"), + (.delete, "移到回收站", "Move to Trash"), ] for (operation, chinese, _) in writeActions { XCTAssertEqual(FrozenApprovalActions.titles(operation: operation, timedAllowanceEnabled: true), @@ -120,9 +120,9 @@ final class WorkspaceInteractionTests: WorkspaceVisualContractTestSupport { } AppLanguage.current = "en" XCTAssertEqual(FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: true), - ["Allow once", "Allow for 30 minutes", "Deny"]) + ["Allow Once", "Allow for 30 Minutes", "Deny"]) XCTAssertEqual(FrozenApprovalActions.titles(operation: .read, timedAllowanceEnabled: false), - ["Allow once", "Deny"]) + ["Allow Once", "Deny"]) for (operation, _, english) in writeActions { XCTAssertEqual(FrozenApprovalActions.titles(operation: operation, timedAllowanceEnabled: true), [english, "Deny"]) diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index 8996710..d680c32 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -71,8 +71,21 @@ final class ScreenshotE2ETests: E2EBaseCase { app.launch() _ = try waitForEvidence("approval-pending.json") XCTAssertTrue(app.buttons["approval-details"].waitForExistence(timeout: 8)) + waitForCardText("Claude Code wants to use the credential %@", "Staging API") + waitForText("Command to run") + waitForText("./deploy.sh") + waitForText("If you allow, this command receives") + waitForCardText("%@ · as environment variable STAGING_API_TOKEN", "Staging API") + waitForText("Stated purpose (not verified)") + waitForText("Deploy the staging site") + XCTAssertEqual(app.buttons["approval-allow-once"].label, "Allow Once") + XCTAssertEqual(app.buttons["approval-allow-timed"].label, "Allow for 30 Minutes") + waitForText("For 30 minutes, any agent or command run by this macOS user can read this credential", containing: true) + waitForText("Esc to decide later in Pending requests", containing: true) capture("12-approval-default", approval: true) click("approval-details") + waitForText("Runs in") + waitForText("Claude Code (name provided by the requester; Ask Key can't verify it)") capture("13-approval-details", approval: true) click("approval-details") try sendFixtureCommand("cancel-authentication") @@ -80,6 +93,7 @@ final class ScreenshotE2ETests: E2EBaseCase { let cancelled = try waitForEvidence("authentication-cancelled.json") XCTAssertEqual(cancelled["outcome"] as? String, "cancelled") XCTAssertTrue(app.buttons["approval-retry-authentication"].waitForExistence(timeout: 8)) + XCTAssertEqual(app.buttons["approval-retry-authentication"].label, "Authenticate and Allow Once") try assertTargetExecutionCount(0) capture("14-approval-cancelled-authentication", approval: true) click("approval-deny") @@ -92,9 +106,17 @@ final class ScreenshotE2ETests: E2EBaseCase { app.launch() _ = try waitForEvidence("approval-pending.json") XCTAssertTrue(app.buttons["approval-deny"].waitForExistence(timeout: 8)) + waitForCardText("E2E Agent wants to create the credential %@", "Staging API") + waitForText("Items (1)") + waitForText("token · 24 bytes · given to programs as environment variable STAGING_TOKEN") + waitForText("Value to save (provided by E2E Agent)") + waitForText("Instructions for agents") waitForText("Use only for staging API requests.", containing: true) - waitForText("Staging Services") - waitForText("New group — created when approved") + waitForCardText("%@", "Staging Services") + XCTAssertTrue(app.descendants(matching: .any)["approval-new-group"].waitForExistence(timeout: 8)) + waitForText("After you approve") + waitForText("The credential's agent permission will be Ask every time", containing: true) + XCTAssertEqual(app.buttons["approval-allow-once"].label, "Create Credential") XCTAssertFalse(app.staticTexts["Before"].exists) XCTAssertFalse(app.staticTexts["After"].exists) XCTAssertFalse(app.staticTexts["Unchanged"].exists) @@ -110,20 +132,25 @@ final class ScreenshotE2ETests: E2EBaseCase { app.launch() _ = try waitForEvidence("approval-pending.json") XCTAssertTrue(app.buttons["approval-deny"].waitForExistence(timeout: 8)) - XCTAssertEqual(app.buttons["approval-allow-once"].label, "Approve Organization") - waitForText("Create group “Staging Services”", containing: true) - waitForText("Staging API: Old Services → Staging Services", containing: true) - waitForText("Rename group “Old Services” → “Renamed Services”", containing: true) - waitForText("3 credentials, 2 not visible to agents", containing: true) + XCTAssertEqual(app.buttons["approval-allow-once"].label, "Apply 6 Steps") + waitForText("E2E Agent wants to reorganize your groups (6 steps)") + waitForCardText("Create the group %@", "Staging Services") + waitForCardText("Move the credential %@ from %@ to %@", "Staging API", "Old Services", "Staging Services") + waitForCardText("Rename the group %@ to %@", "Old Services", "Renamed Services") + waitForText("It has 3 credentials (2 hidden from agents).", containing: true) + waitForCardText("Its 3 credentials (2 hidden from agents) won't be deleted and will become %@.", "Ungrouped") XCTAssertFalse(app.buttons["approval-reveal-frozen-material"].exists) XCTAssertFalse(app.buttons["approval-allow-timed"].exists) capture("16-approval-organization", approval: true) let operations = app.scrollViews["approval-organization-operations"] XCTAssertTrue(operations.exists) operations.scroll(byDeltaX: 0, deltaY: -600) - waitForText("Group already exists — no changes. 1 credential, 1 not visible to agents", containing: true) - waitForText("Merge group “Merge Source” into existing group “Existing Merge Services”", containing: true) - waitForText("Source: 2 credentials, 1 not visible to agents. Existing group: 2 credentials, 2 not visible to agents.", containing: true) + waitForCardText("Create the group %@", "Existing Private Services") + waitForText("This group already exists, so nothing is created or changed. It has 1 credential (1 hidden from agents).", + containing: true) + waitForCardText("Merge the group %@ into the existing group %@", "Merge Source", "Existing Merge Services") + waitForCardText("%@ disappears and %@ will have 4 credentials (3 hidden from agents). A merge can't be undone automatically.", + "Merge Source", "Existing Merge Services") XCTAssertTrue(app.buttons["approval-allow-once"].isHittable) XCTAssertTrue(app.buttons["approval-deny"].isHittable) capture("17-approval-organization-existing-targets", approval: true) @@ -180,6 +207,29 @@ final class ScreenshotE2ETests: E2EBaseCase { .waitForExistence(timeout: 8), "Missing content: \(text)") } + /// Approval cards quote names and join their characters with invisible + /// word joiners so a name wraps as a whole; match that form, or the text + /// with the joiners removed. + private func waitForCardText(_ format: String, _ names: String...) { + func fill(_ quote: (String) -> String) -> String { + var text = format + for name in names { + guard let range = text.range(of: "%@") else { break } + text.replaceSubrange(range, with: quote(name)) + } + return text + } + let joined = fill { name in + "“\u{2060}" + name.map { ($0 == " " ? "\u{00A0}" : String($0)) + "\u{2060}" }.joined() + "”" + } + let plain = fill { "“" + $0 + "”" } + let predicate = NSPredicate(format: "label CONTAINS %@ OR value CONTAINS %@", joined, joined) + if app.descendants(matching: .any).matching(predicate).firstMatch.waitForExistence(timeout: 8) { return } + let tree = app.debugDescription.replacingOccurrences(of: "\u{2060}", with: "") + .replacingOccurrences(of: "\u{00A0}", with: " ") + XCTAssertTrue(tree.contains(plain), "Missing card content: \(plain)") + } + private func replaceText(_ text: String, identifier: String) { let field = app.textFields[identifier].firstMatch XCTAssertTrue(field.waitForExistence(timeout: 8)) From 41ecea4c47530a7c90fb7212cd489fb172a59d6d Mon Sep 17 00:00:00 2001 From: sudoHG Date: Fri, 9 Oct 2026 10:51:51 +0800 Subject: [PATCH 2/3] Keep the whole approval card above the Dock on the CI screen At the 680-point cap the panel's top sits under the menu bar and the footer slid behind the Dock on the 768-point CI screen. A 640-point cap keeps the footer visible while staying within the 680-point limit. --- Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift | 5 +++-- Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift index 302f9b1..75d5338 100644 --- a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift +++ b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift @@ -8,8 +8,9 @@ import AskKeyBroker /// each decision's consequence. struct FrozenAgentApprovalPrompt: View { static let width: CGFloat = 300 - /// Keeps the actions on the 768-point CI screen with menu bar and Dock. - static let maximumHeight: CGFloat = 680 + /// Keeps the whole card, footer included, above the Dock on the + /// 768-point CI screen, where the panel's top sits under the menu bar. + static let maximumHeight: CGFloat = 640 static let contentWidth = width - 2 * Theme.Spacing.lg static let commandLineHeight = ceil(NSLayoutManager().defaultLineHeight( for: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular))) diff --git a/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift index 9223918..c3d6bb8 100644 --- a/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift +++ b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift @@ -16,7 +16,8 @@ final class ApprovalCardLayoutTests: AskKeyAppTestCase { return size.height } - func testEveryCardTypeStaysWithin680PointsInBothLanguages() { + func testEveryCardTypeStaysWithinTheCapInBothLanguages() { + XCTAssertLessThanOrEqual(FrozenAgentApprovalPrompt.maximumHeight, 680) Fixtures.withLanguages { language in for card in Fixtures.Card.allCases { XCTAssertLessThanOrEqual(height(Fixtures.prompt(card)), FrozenAgentApprovalPrompt.maximumHeight, From 9670fa8943a346b7a64cb3a396a40cba0405927d Mon Sep 17 00:00:00 2001 From: sudoHG Date: Fri, 9 Oct 2026 11:59:25 +0800 Subject: [PATCH 3/3] Tag approval items exactly and tighten card copy Give each component of the App-side write summary a vault-keyed value digest, computed when the write is frozen. The approval digest, retransmission, consumption and commit are unchanged, and the digest never appears in a Broker response. Modify cards now tag every item as Unchanged, Replaced, New, Changed or Removed and list only new or replaced values. Instruction edits are highlighted word by word with a Removed line. Values merge into the items section, consequences sit under the primary button, overflow lines name the hidden sections, and read cards shrink their icon before scrolling, so short create cards fit without scrolling. Read rows no longer repeat the credential name, and the summary, timed-scope, overwrite, footer, bin and merge copy follow the second review. --- .../App/ApprovalCardComponents.swift | 35 +- Sources/AskKeyAppKit/App/ApprovalCopy.swift | 15 +- .../App/ApprovalPromptContent.swift | 38 +- .../AskKeyAppKit/App/ApprovalScrollArea.swift | 75 ++- .../AskKeyAppKit/App/ApprovalTextDiff.swift | 129 +++++ .../App/FrozenAgentApprovalPrompt.swift | 178 ++++--- .../App/FrozenApprovalActions.swift | 4 +- .../FrozenOrganizationApprovalContent.swift | 14 +- .../App/FrozenWriteApprovalContent.swift | 180 ++++--- .../App/FrozenWriteSummaryContent.swift | 110 +++-- .../Resources/Localizable.xcstrings | 451 ++++++++++-------- .../AskKeyBroker/CredentialComponents.swift | 8 +- .../AskKeyVault/Vault+AgentWriteFreeze.swift | 16 +- .../AskKeyAppTests/ApprovalCardFixtures.swift | 25 +- .../ApprovalCardLayoutTests.swift | 32 +- .../ApprovalPromptContentTests.swift | 75 +-- .../FrozenWriteSummaryContentTests.swift | 160 ++++--- .../LocalizationUnificationTests.swift | 3 + .../OrganizationApprovalContentTests.swift | 24 +- .../WorkspaceInteractionTests.swift | 2 +- Tests/AskKeyE2ETests/ScreenshotE2ETests.swift | 20 +- .../AgentWriteComponentDigestTests.swift | 71 +++ 22 files changed, 1092 insertions(+), 573 deletions(-) create mode 100644 Sources/AskKeyAppKit/App/ApprovalTextDiff.swift create mode 100644 Tests/AskKeyVaultTests/AgentWriteComponentDigestTests.swift diff --git a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift index 87e2224..8578dcc 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift @@ -1,12 +1,25 @@ import AppKit import SwiftUI -/// A fixed card section: its heading, an optional status tag, then content. -struct ApprovalSection: View { +/// A fixed card section: its heading, an optional status tag and trailing +/// control, then content. The overflow line names it while it is hidden. +struct ApprovalSection: View { let title: String - var tag: ApprovalTag? + let tag: ApprovalTag? + let name: String? let identifier: String - @ViewBuilder let content: Content + let accessory: Accessory + let content: Content + + init(title: String, tag: ApprovalTag? = nil, name: String? = nil, identifier: String, + @ViewBuilder accessory: () -> Accessory, @ViewBuilder content: () -> Content) { + self.title = title + self.tag = tag + self.name = name + self.identifier = identifier + self.accessory = accessory() + self.content = content() + } var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.xs) { @@ -14,7 +27,10 @@ struct ApprovalSection: View { Text(verbatim: title) .font(Theme.Fonts.caption.weight(.semibold)) .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) if let tag { ApprovalTagView(tag: tag) } + Spacer(minLength: Theme.Spacing.sm) + accessory } content .font(Theme.Fonts.secondary) @@ -22,12 +38,19 @@ struct ApprovalSection: View { } .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - .approvalScrollMarker() + .approvalScrollMarker(name: name ?? title) .accessibilityElement(children: .contain) .accessibilityIdentifier(identifier) } } +extension ApprovalSection where Accessory == EmptyView { + init(title: String, tag: ApprovalTag? = nil, name: String? = nil, identifier: String, + @ViewBuilder content: () -> Content) { + self.init(title: title, tag: tag, name: name, identifier: identifier, accessory: { EmptyView() }, content: content) + } +} + struct ApprovalTagView: View { let tag: ApprovalTag @@ -51,7 +74,7 @@ extension ApprovalTag { case .changed: return ApprovalTagPalette.blue case .new: return ApprovalTagPalette.green case .newGroup: return Theme.text - case .replaced, .mayBeReplaced, .merge: return ApprovalTagPalette.orange + case .replaced, .merge: return ApprovalTagPalette.orange case .removed: return Theme.warning } } diff --git a/Sources/AskKeyAppKit/App/ApprovalCopy.swift b/Sources/AskKeyAppKit/App/ApprovalCopy.swift index 8c99d0a..6d4e725 100644 --- a/Sources/AskKeyAppKit/App/ApprovalCopy.swift +++ b/Sources/AskKeyAppKit/App/ApprovalCopy.swift @@ -10,7 +10,17 @@ enum ApprovalCopy { } static func quoted(_ name: String) -> String { - appLocalizedFormat("Quoted name: %@", unbreakable(name)) + quote(unbreakable(name)) + } + + /// Quotes free text, such as a removed phrase, without keeping it whole. + static func quote(_ text: String) -> String { + appLocalizedFormat("Quoted name: %@", text) + } + + /// "the value of “X”" in the current language, kept on one line together. + static func credentialValue(_ name: String) -> String { + unbreakable(appLocalizedFormat("Credential value: %@", quote(name))) } /// Word joiners and no-break spaces keep a name on one line when it fits; @@ -92,7 +102,7 @@ struct ApprovalLine: Equatable { /// Small status labels on neutral backgrounds. Color carries risk, not novelty. enum ApprovalTag: Equatable { - case unchanged, changed, new, newGroup, replaced, mayBeReplaced, removed, noChange, merge + case unchanged, changed, new, newGroup, replaced, removed, noChange, merge var title: String { switch self { @@ -101,7 +111,6 @@ enum ApprovalTag: Equatable { case .new: return appLocalized("New") case .newGroup: return appLocalized("New group tag") case .replaced: return appLocalized("Replaced") - case .mayBeReplaced: return appLocalized("May be replaced") case .removed: return appLocalized("Removed") case .noChange: return appLocalized("No change") case .merge: return appLocalized("Merge") diff --git a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift index 5d6c45f..070abb8 100644 --- a/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift +++ b/Sources/AskKeyAppKit/App/ApprovalPromptContent.swift @@ -15,7 +15,9 @@ struct ApprovalPromptContent: Equatable { let title: String /// The whole command line, never shortened. let command: String? - /// What a read hands the command if allowed, one line per variable. + /// What a read hands the command if allowed: a heading naming the + /// credential and how many items, then one line per variable. + let receivesHeading: String let receives: [ApprovalLine] let purpose: String? let detailRows: [Row] @@ -28,12 +30,15 @@ struct ApprovalPromptContent: Equatable { if request.operation == .organize { title = Self.organizationTitle(requester: requester, steps: organizationSteps) } else { + let object = request.operation == .modify && valueOnlyChange + ? ApprovalCopy.credentialValue(credentialName) : ApprovalCopy.quoted(credentialName) title = EmphasizedSentence(format: Self.titleFormat(request.operation, valueOnlyChange: valueOnlyChange), - arguments: [requester, ApprovalCopy.quoted(credentialName)]).plainText + arguments: [requester, object]).plainText } let display = request.operation == .read ? request.display : nil command = display?.commandLine - receives = request.operation == .read ? Self.receives(display, credentialName: credentialName) : [] + (receivesHeading, receives) = request.operation == .read + ? Self.receives(display, credentialName: credentialName) : ("", []) purpose = request.callerPurpose.flatMap { $0.isEmpty ? nil : $0 } var rows: [Row] = [] if let directory = display?.workingDirectory, !directory.isEmpty { @@ -52,7 +57,7 @@ struct ApprovalPromptContent: Equatable { case .create: return appLocalized("%1$@ wants to create the credential %2$@") case .modify: return valueOnlyChange - ? appLocalized("%1$@ wants to replace the value of %2$@") + ? appLocalized("%1$@ wants to replace %2$@") : appLocalized("%1$@ wants to change the credential %2$@") case .delete: return appLocalized("%1$@ wants to delete the credential %2$@") case .organize: return appLocalized("%@ wants to reorganize your groups") @@ -68,25 +73,28 @@ struct ApprovalPromptContent: Equatable { var cancelledAuthenticationNote: String { operation == .read - ? appLocalized("You cancelled authentication. The credential was not delivered, and the request is still pending.") + ? appLocalized("You cancelled authentication. Nothing was given to the command, and the request is still pending.") : appLocalized("You cancelled authentication. Nothing was changed, and the request is still pending.") } /// Variable names only; a multi-item credential's mapping is not opened /// before approval, so its items are described without names. - static func receives(_ display: BrokerApprovalOperationRequest.Display?, credentialName: String) -> [ApprovalLine] { - guard let display else { return [] } + static func receives(_ display: BrokerApprovalOperationRequest.Display?, + credentialName: String) -> (heading: String, rows: [ApprovalLine]) { + let heading = appLocalized("If you allow, this command receives") + guard let display else { return (heading, []) } let name = ApprovalCopy.quoted(credentialName) guard let environment = display.environmentVariables, let files = display.temporaryFileVariables else { - return [ApprovalLine(appLocalizedFormat( - "The items of %@ that are set to be given to programs (names are shown after you approve)", name))] + return (heading, [ApprovalLine(appLocalizedFormat( + "The items of %@ that are set to be given to programs (names are shown after you approve)", name))]) } - let rows = environment.map { - ApprovalLine(format: appLocalized("%1$@ · as environment variable %2$@"), plain: [name], code: [$0]) - } + files.map { - ApprovalLine(format: appLocalized("%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)"), - plain: [name], code: [$0]) + let rows = environment.map { ApprovalLine(format: appLocalized("→ Environment variable %@"), code: [$0]) } + + files.map { ApprovalLine(format: appLocalized("→ Temporary file (path in %@, removed within 5 minutes)"), code: [$0]) } + switch rows.count { + case 0: return (heading, [ApprovalLine(appLocalized("Nothing from this credential is given to the command"))]) + case 1: return (appLocalizedFormat("If you allow, this command receives 1 item from %@", name), rows) + default: + return (appLocalizedFormat("If you allow, this command receives %1$lld items from %2$@", rows.count, name), rows) } - return rows.isEmpty ? [ApprovalLine(appLocalized("Nothing from this credential is given to the command"))] : rows } } diff --git a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift index 8176604..0a70710 100644 --- a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift +++ b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift @@ -8,26 +8,24 @@ enum ApprovalOverflowUnit: Equatable { } enum ApprovalOverflow { - /// The line shown while content is hidden below; nil once the end is visible. - static func hint(unit: ApprovalOverflowUnit, hiddenHeight: CGFloat, hiddenMarkers: Int) -> String? { + /// The line shown while content is hidden below; nil once the end is + /// visible. Steps are counted; other hidden sections are named. + static func hint(unit: ApprovalOverflowUnit, hiddenHeight: CGFloat, hiddenSteps: Int = 0, + hiddenNames: [String] = []) -> String? { guard hiddenHeight > 1 else { return nil } - switch unit { - case .lines(let height): + if case .lines(let height) = unit { let lines = max(1, Int(((hiddenHeight - 1) / max(height, 1)).rounded(.up))) return lines == 1 ? appLocalized("1 more line — scroll to see it") : appLocalizedFormat("%lld more lines — scroll to see them", lines) - case .steps where hiddenMarkers > 0: - return hiddenMarkers == 1 + } + if unit == .steps, hiddenSteps > 0 { + return hiddenSteps == 1 ? appLocalized("1 more step — scroll to see it") - : appLocalizedFormat("%lld more steps — scroll to see them", hiddenMarkers) - case .sections where hiddenMarkers > 0: - return hiddenMarkers == 1 - ? appLocalized("1 more section below — scroll to see it") - : appLocalizedFormat("%lld more sections below — scroll to see them", hiddenMarkers) - case .steps, .sections: - return appLocalized("More below — scroll to see it") + : appLocalizedFormat("%lld more steps — scroll to see them", hiddenSteps) } + guard !hiddenNames.isEmpty else { return appLocalized("More below — scroll to see it") } + return appLocalizedFormat("Below: %@", hiddenNames.joined(separator: appLocalized("List separator"))) } } @@ -43,7 +41,7 @@ struct ApprovalScrollArea: View { @ViewBuilder let content: (ScrollViewProxy) -> Content @State private var contentFrame: CGRect = .zero @State private var viewport: CGFloat = 0 - @State private var markers: [CGFloat] = [] + @State private var markers: [ApprovalScrollMarkerValue] = [] var body: some View { VStack(spacing: Theme.Spacing.xs) { @@ -79,8 +77,11 @@ struct ApprovalScrollArea: View { private var overflows: Bool { contentFrame.height > viewport + 1 && viewport > 0 } private var hint: String? { - ApprovalOverflow.hint(unit: unit, hiddenHeight: contentFrame.maxY - viewport, - hiddenMarkers: markers.filter { $0 > viewport + 1 }.count) + let hidden = markers.filter { $0.maxY > viewport + 1 } + var names: [String] = [] + for name in hidden.compactMap(\.name) where !names.contains(name) { names.append(name) } + return ApprovalOverflow.hint(unit: unit, hiddenHeight: contentFrame.maxY - viewport, + hiddenSteps: hidden.filter(\.step).count, hiddenNames: names) } private var indicator: some View { @@ -100,7 +101,7 @@ struct ApprovalScrollArea: View { private func hintLabel(_ text: String) -> some View { HStack(spacing: Theme.Spacing.xs) { if hint != nil { Image(systemName: "chevron.down").imageScale(.small) } - Text(verbatim: text) + Text(verbatim: text).lineLimit(1).truncationMode(.tail).minimumScaleFactor(0.8) } .font(Theme.Fonts.caption) .foregroundStyle(Theme.textSecondary) @@ -111,17 +112,28 @@ struct ApprovalScrollArea: View { } extension View { - /// Marks a step or section whose bottom edge the overflow line counts. - func approvalScrollMarker() -> some View { modifier(ApprovalScrollMarker()) } + /// Marks a section, named in the overflow line, or a counted step. + func approvalScrollMarker(name: String? = nil, step: Bool = false) -> some View { + modifier(ApprovalScrollMarker(name: name, step: step)) + } +} + +struct ApprovalScrollMarkerValue: Equatable { + let maxY: CGFloat + let name: String? + let step: Bool } private struct ApprovalScrollMarker: ViewModifier { + let name: String? + let step: Bool @Environment(\.approvalScrollSpace) private var space func body(content: Content) -> some View { content.background(GeometryReader { geometry in - Color.clear.preference(key: ApprovalScrollMarkerKey.self, - value: space.map { [$0: [geometry.frame(in: .named($0)).maxY]] } ?? [:]) + Color.clear.preference(key: ApprovalScrollMarkerKey.self, value: space.map { + [$0: [ApprovalScrollMarkerValue(maxY: geometry.frame(in: .named($0)).maxY, name: name, step: step)]] + } ?? [:]) }) } } @@ -153,8 +165,9 @@ private struct ApprovalScrollViewportKey: PreferenceKey { } private struct ApprovalScrollMarkerKey: PreferenceKey { - static let defaultValue: [String: [CGFloat]] = [:] - static func reduce(value: inout [String: [CGFloat]], nextValue: () -> [String: [CGFloat]]) { + static let defaultValue: [String: [ApprovalScrollMarkerValue]] = [:] + static func reduce(value: inout [String: [ApprovalScrollMarkerValue]], + nextValue: () -> [String: [ApprovalScrollMarkerValue]]) { value.merge(nextValue()) { $0 + $1 } } } @@ -179,8 +192,17 @@ struct ApprovalCardLayout: Layout { } } + /// A card that would overflow first shrinks the parts that can be + /// compact, such as the icon, then lets the body scroll. private func heights(width: CGFloat, subviews: Subviews) -> [CGFloat] { - var heights = subviews.map { $0.sizeThatFits(ProposedViewSize(width: width, height: nil)).height } + func measure(compact: Bool) -> [CGFloat] { + subviews.map { subview in + let height = compact ? subview[ApprovalCardCompactHeightKey.self] : nil + return subview.sizeThatFits(ProposedViewSize(width: width, height: height)).height + } + } + var heights = measure(compact: false) + if heights.reduce(0, +) > maximumHeight { heights = measure(compact: true) } let excess = heights.reduce(0, +) - maximumHeight if excess > 0, let index = subviews.firstIndex(where: { $0[ApprovalCardFlexibleKey.self] }) { heights[index] = max(minimumFlexibleHeight, heights[index] - excess) @@ -192,3 +214,8 @@ struct ApprovalCardLayout: Layout { struct ApprovalCardFlexibleKey: LayoutValueKey { static let defaultValue = false } + +/// The height a part takes on a card that would otherwise overflow. +struct ApprovalCardCompactHeightKey: LayoutValueKey { + static let defaultValue: CGFloat? = nil +} diff --git a/Sources/AskKeyAppKit/App/ApprovalTextDiff.swift b/Sources/AskKeyAppKit/App/ApprovalTextDiff.swift new file mode 100644 index 0000000..b9e1c3f --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalTextDiff.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Word-level difference between two texts, used to highlight edits to the +/// instructions for agents. Chinese and Japanese characters count as words. +struct ApprovalTextDiff: Equatable { + enum Kind: Equatable { case same, removed, added } + + struct Run: Equatable { + let text: String + let kind: Kind + } + + /// The old text: unchanged and removed runs. + let before: [Run] + /// The new text: unchanged and added runs. + let after: [Run] + /// Each removed stretch of words without surrounding spaces and + /// punctuation, for the "Removed:" line. + let removedPhrases: [String] + + /// Beyond this many token pairs the changed middle counts as rewritten. + private static let comparisonLimit = 250_000 + + init(before: String, after: String) { + let old = Self.tokens(before) + let new = Self.tokens(after) + var prefix = 0 + while prefix < min(old.count, new.count), old[prefix] == new[prefix] { prefix += 1 } + var suffix = 0 + while suffix < min(old.count, new.count) - prefix, + old[old.count - 1 - suffix] == new[new.count - 1 - suffix] { suffix += 1 } + let oldMiddle = Array(old[prefix..<(old.count - suffix)]) + let newMiddle = Array(new[prefix..<(new.count - suffix)]) + let (oldKept, newKept) = Self.commonTokens(oldMiddle, newMiddle) + let head = Array(old[.. [String] { + var tokens: [String] = [] + var word = "" + var space = "" + func flush() { + if !word.isEmpty { tokens.append(word); word = "" } + if !space.isEmpty { tokens.append(space); space = "" } + } + for character in text { + if character.isWhitespace { + if !word.isEmpty { tokens.append(word); word = "" } + space.append(character) + } else if isIdeograph(character) || !(character.isLetter || character.isNumber || character == "_") { + flush() + tokens.append(String(character)) + } else { + if !space.isEmpty { tokens.append(space); space = "" } + word.append(character) + } + } + flush() + return tokens + } + + private static func isIdeograph(_ character: Character) -> Bool { + character.unicodeScalars.contains { scalar in + switch scalar.value { + case 0x3040...0x30FF, 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF, 0x20000...0x2FA1F: return true + default: return false + } + } + } + + /// Indexes of the heaviest common token subsequence in each side. Words + /// outweigh punctuation and spaces, so shared words line up first. + private static func commonTokens(_ old: [String], _ new: [String]) -> (Set, Set) { + guard !old.isEmpty, !new.isEmpty, old.count * new.count <= comparisonLimit else { return ([], []) } + func weight(_ token: String) -> Int { + if token.allSatisfy(\.isWhitespace) { return 1 } + return token.contains { $0.isLetter || $0.isNumber } ? 3 : 2 + } + var lengths = Array(repeating: Array(repeating: 0, count: new.count + 1), count: old.count + 1) + for i in stride(from: old.count - 1, through: 0, by: -1) { + for j in stride(from: new.count - 1, through: 0, by: -1) { + lengths[i][j] = old[i] == new[j] + ? lengths[i + 1][j + 1] + weight(old[i]) : max(lengths[i + 1][j], lengths[i][j + 1]) + } + } + var oldKept = Set(), newKept = Set() + var i = 0, j = 0 + while i < old.count, j < new.count { + if old[i] == new[j] { + oldKept.insert(i); newKept.insert(j); i += 1; j += 1 + } else if lengths[i + 1][j] >= lengths[i][j + 1] { + i += 1 + } else { + j += 1 + } + } + return (oldKept, newKept) + } + + /// Joins neighbouring tokens of one kind; a lone space between two + /// changed words joins the change so phrases read as one. + private static func merged(_ tokens: [(String, Kind)]) -> [Run] { + var marked = tokens + for index in marked.indices.dropFirst().dropLast() + where marked[index].1 == .same && marked[index].0.allSatisfy(\.isWhitespace) + && marked[index - 1].1 != .same && marked[index - 1].1 == marked[index + 1].1 { + marked[index].1 = marked[index - 1].1 + } + var runs: [Run] = [] + for (text, kind) in marked { + if let last = runs.last, last.kind == kind { + runs[runs.count - 1] = Run(text: last.text + text, kind: kind) + } else { + runs.append(Run(text: text, kind: kind)) + } + } + return runs + } +} diff --git a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift index 75d5338..d56dbe6 100644 --- a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift +++ b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift @@ -39,7 +39,7 @@ struct FrozenAgentApprovalPrompt: View { let _ = AppLanguage.store.resolved let requester = ApprovalCopy.requester(request) let write = isRead || isOrganize ? nil : writeSummary.map { FrozenWriteSummaryContent(summary: $0, requester: requester) } - let organization = isOrganize ? organizationSummary.map(FrozenOrganizationSummaryContent.init) : nil + let organization = isOrganize ? organizationSummary.map { FrozenOrganizationSummaryContent(summary: $0, requester: requester) } : nil let content = ApprovalPromptContent(request: request, credentialName: trustedCredentialName ?? request.credentialName ?? request.targetID, valueOnlyChange: write?.valueOnlyChange ?? false, organizationSteps: organization?.rows.count) @@ -48,7 +48,10 @@ struct FrozenAgentApprovalPrompt: View { destructive: organization?.isDestructive ?? false) let topPadding = compact ? Theme.Spacing.lg : Theme.Spacing.xl ApprovalCardLayout(maximumHeight: Self.maximumHeight - topPadding - Theme.Spacing.lg) { - header(content.title) + icon + .padding(.bottom, Theme.Spacing.md) + .layoutValue(key: ApprovalCardCompactHeightKey.self, value: 40 + Theme.Spacing.md) + title(content.title) cardBody(content, write: write, organization: organization) .layoutValue(key: ApprovalCardFlexibleKey.self, value: true) if cancelledAuthenticationDecision != nil { @@ -60,7 +63,7 @@ struct FrozenAgentApprovalPrompt: View { .padding(.top, Theme.Spacing.md) .accessibilityIdentifier("approval-authentication-cancelled") } - actions(primary) + actions(primary, consequence: write?.consequence) .padding(.top, Theme.Spacing.lg) footer .padding(.top, Theme.Spacing.md) @@ -74,24 +77,28 @@ struct FrozenAgentApprovalPrompt: View { .environment(\.locale, AppLanguage.store.locale) } - private func header(_ title: String) -> some View { - VStack(spacing: Theme.Spacing.md) { - Image(nsImage: AppIcon.load()) - .resizable() - .interpolation(.high) - .frame(width: compact ? 40 : 64, height: compact ? 40 : 64) - .accessibilityHidden(true) - Text(verbatim: title) - .font(Theme.Fonts.body.bold()) - .foregroundStyle(Theme.text) - .multilineTextAlignment(.center) - .lineLimit(5) - .fixedSize(horizontal: false, vertical: true) - .help(Text(verbatim: title)) - .accessibilityIdentifier("approval-title") - } - .frame(maxWidth: .infinity) - .padding(.bottom, Theme.Spacing.md) + /// 64 points on a read card that fits; the layout shrinks it to 40 when + /// the card would overflow. Write and organize cards always use 40. + private var icon: some View { + Image(nsImage: AppIcon.load()) + .resizable() + .interpolation(.high) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: compact ? 40 : 64, maxHeight: compact ? 40 : 64) + .accessibilityHidden(true) + } + + private func title(_ title: String) -> some View { + Text(verbatim: title) + .font(Theme.Fonts.body.bold()) + .foregroundStyle(Theme.text) + .multilineTextAlignment(.center) + .lineLimit(5) + .fixedSize(horizontal: false, vertical: true) + .help(Text(verbatim: title)) + .frame(maxWidth: .infinity) + .padding(.bottom, Theme.Spacing.md) + .accessibilityIdentifier("approval-title") } private func cardBody(_ content: ApprovalPromptContent, write: FrozenWriteSummaryContent?, @@ -102,7 +109,7 @@ struct FrozenAgentApprovalPrompt: View { if isRead { if let command = content.command { commandSection(command) } if !content.receives.isEmpty { - ApprovalSection(title: appLocalized("If you allow, this command receives"), + ApprovalSection(title: content.receivesHeading, name: appLocalized("What the command receives"), identifier: "approval-delivers") { VStack(alignment: .leading, spacing: 2) { ForEach(Array(content.receives.enumerated()), id: \.offset) { _, line in @@ -113,7 +120,8 @@ struct FrozenAgentApprovalPrompt: View { } } if let purpose = content.purpose { - ApprovalSection(title: appLocalized("Stated purpose (not verified)"), identifier: "approval-purpose") { + ApprovalSection(title: appLocalized("Stated purpose (not verified)"), name: appLocalized("Stated purpose"), + identifier: "approval-purpose") { Text(verbatim: purpose).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) } } @@ -143,73 +151,76 @@ struct FrozenAgentApprovalPrompt: View { } } - @ViewBuilder private func details(_ rows: [ApprovalPromptContent.Row], proxy: ScrollViewProxy) -> some View { let expanded = showsDetails ?? detailsExpanded - Button { - showsDetails = !expanded - guard !expanded else { return } - DispatchQueue.main.async { - withAnimation { proxy.scrollTo("approval-details-rows", anchor: .bottom) } - } - } label: { - HStack(spacing: Theme.Spacing.xs) { - Text(appLocalized("Details")).font(Theme.Fonts.secondary) - Image(systemName: expanded ? "chevron.down" : "chevron.right") - .font(Theme.Fonts.caption.weight(.semibold)) - .imageScale(.small) + return VStack(alignment: .leading, spacing: Theme.Spacing.md) { + Button { + showsDetails = !expanded + guard !expanded else { return } + DispatchQueue.main.async { + withAnimation { proxy.scrollTo("approval-details-rows", anchor: .bottom) } + } + } label: { + HStack(spacing: Theme.Spacing.xs) { + Text(appLocalized("Details")).font(Theme.Fonts.secondary) + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(Theme.Fonts.caption.weight(.semibold)) + .imageScale(.small) + } + .foregroundStyle(Theme.accent) + .contentShape(Rectangle()) } - .foregroundStyle(Theme.accent) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("approval-details") - if expanded { - Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.md, verticalSpacing: Theme.Spacing.sm) { - ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - GridRow { - Text(row.label) - .foregroundStyle(Theme.textSecondary) - .fixedSize() - Text(verbatim: row.value) - .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.secondary) - .foregroundStyle(Theme.text) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .accessibilityIdentifier("approval-details") + if expanded { + Grid(alignment: .topLeading, horizontalSpacing: Theme.Spacing.md, verticalSpacing: Theme.Spacing.sm) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + GridRow { + Text(row.label) + .foregroundStyle(Theme.textSecondary) + .fixedSize() + Text(verbatim: row.value) + .font(row.monospaced ? Theme.Fonts.mono : Theme.Fonts.secondary) + .foregroundStyle(Theme.text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } } } + .font(Theme.Fonts.secondary) + .id("approval-details-rows") + .accessibilityIdentifier("approval-details-rows") } - .font(Theme.Fonts.secondary) - .approvalScrollMarker() - .id("approval-details-rows") - .accessibilityIdentifier("approval-details-rows") } + .approvalScrollMarker(name: appLocalized("Details")) } - private func actions(_ primary: FrozenApprovalActions.Primary) -> some View { + /// Each approving button states its consequence underneath, including + /// the retry after a cancelled authentication. + private func actions(_ primary: FrozenApprovalActions.Primary, consequence: String?) -> some View { let unavailable = isOrganize && organizationSummary == nil + let timedScope = FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes) return VStack(spacing: Theme.Spacing.sm) { if let retry = cancelledAuthenticationDecision { - ApprovalPromptButton(title: FrozenApprovalActions.retry(retry, primary: primary, minutes: timedAllowanceMinutes), - role: primary.role) { finish(retry) } - .disabled(unavailable) - .accessibilityIdentifier("approval-retry-authentication") + let retriesTimed: Bool = { if case .timedAllow = retry { return true } else { return false } }() + explained(retriesTimed ? timedScope : consequence) { + ApprovalPromptButton(title: FrozenApprovalActions.retry(retry, primary: primary, minutes: timedAllowanceMinutes), + role: primary.role) { finish(retry) } + .disabled(unavailable) + .accessibilityIdentifier("approval-retry-authentication") + } } else { - ApprovalPromptButton(title: primary.title, role: primary.role) { finish(.once) } - .disabled(unavailable) - .accessibilityIdentifier("approval-allow-once") + explained(consequence) { + ApprovalPromptButton(title: primary.title, role: primary.role) { finish(.once) } + .disabled(unavailable) + .accessibilityIdentifier("approval-allow-once") + } if isRead, timedAllowanceEnabled { - VStack(spacing: Theme.Spacing.xs) { + explained(timedScope) { ApprovalPromptButton(title: FrozenApprovalActions.timed(minutes: timedAllowanceMinutes), role: .secondary) { finish(.timedAllow(duration: nil)) } .accessibilityIdentifier("approval-allow-timed") - Text(FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes)) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .accessibilityIdentifier("approval-timed-scope") } } } @@ -218,11 +229,26 @@ struct FrozenAgentApprovalPrompt: View { } } + private func explained(_ line: String?, @ViewBuilder button: () -> some View) -> some View { + VStack(spacing: Theme.Spacing.xs) { + button() + if let line { + Text(verbatim: line) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("approval-consequence") + } + } + } + private var footer: some View { Button { finish(nil) } label: { TimelineView(.periodic(from: .now, by: 1)) { context in - Text(verbatim: appLocalizedFormat("Expires in %@", FrozenCountdown.format(deadline: expiresAt, now: context.date)) - + " · " + appLocalized("Esc to decide later in Pending requests")) + Text(verbatim: appLocalizedFormat("Expires in %@ and nothing is handed over", + FrozenCountdown.format(deadline: expiresAt, now: context.date)) + + " · " + appLocalized("Esc to hide; decide in Pending requests before it expires")) .monospacedDigit() .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) @@ -232,7 +258,7 @@ struct FrozenAgentApprovalPrompt: View { .keyboardShortcut(.cancelAction) .font(Theme.Fonts.caption) .foregroundStyle(Theme.textTertiary) - .help(appLocalized("When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires.")) + .help(appLocalized("When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires.")) .accessibilityIdentifier("approval-decide-later") } } diff --git a/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift b/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift index 3b4bd73..6c88099 100644 --- a/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift +++ b/Sources/AskKeyAppKit/App/FrozenApprovalActions.swift @@ -15,7 +15,7 @@ enum FrozenApprovalActions { case .modify: return Primary(title: valueOnlyChange ? appLocalized("Replace Value") : appLocalized("Save Changes"), role: .primary) - case .delete: return Primary(title: appLocalized("Move to Trash"), role: .destructive) + case .delete: return Primary(title: appLocalized("Move to Recycle Bin"), role: .destructive) case .organize: let title: String switch steps { @@ -33,7 +33,7 @@ enum FrozenApprovalActions { /// What the timed allowance covers, stated under its button. static func timedScope(minutes: Int) -> String { - appLocalizedFormat("For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.", minutes) + appLocalizedFormat("For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.", minutes) } /// Retries the decision the user chose before cancelling authentication. diff --git a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift index 522522e..650cdf5 100644 --- a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift @@ -15,7 +15,7 @@ struct FrozenOrganizationSummaryContent: Equatable { /// Deleting or merging groups cannot be undone in one step. let isDestructive: Bool - init(summary: BrokerOrganizationSummary) { + init(summary: BrokerOrganizationSummary, requester: String) { rows = summary.operations.enumerated().map { index, operation in let tag: ApprovalTag? let title: String @@ -40,11 +40,15 @@ struct FrozenOrganizationSummaryContent: Equatable { case .renameGroup(let from, let to, let members, let hidden): tag = nil title = appLocalizedFormat("Rename the group %1$@ to %2$@", ApprovalCopy.quoted(from), ApprovalCopy.quoted(to)) - detail = Self.contents(members: members, hidden: hidden) + detail = members == 0 + ? appLocalized("When it's renamed, the group is empty.") + : appLocalizedFormat("When it's renamed, the group has %@.", ApprovalCopy.members(members, hidden: hidden)) case .mergeGroup(let from, let to, let members, let hidden, let targetMembers, let targetHidden): + // The requester asked for a rename; the target exists but is + // hidden from it, so the batch merges the two groups. tag = .merge - title = appLocalizedFormat("Merge the group %1$@ into the existing group %2$@", - ApprovalCopy.quoted(from), ApprovalCopy.quoted(to)) + title = appLocalizedFormat("%1$@ asked to rename %2$@ to %3$@; %3$@ already exists and is hidden from it, so the groups merge.", + requester, ApprovalCopy.quoted(from), ApprovalCopy.quoted(to)) detail = ApprovalCopy.sentences([ appLocalizedFormat("%1$@ disappears and %2$@ will have %3$@.", ApprovalCopy.quoted(from), ApprovalCopy.quoted(to), ApprovalCopy.members(members + targetMembers, hidden: hidden + targetHidden)), @@ -100,7 +104,7 @@ struct FrozenOrganizationApprovalContent: View { } } .textSelection(.enabled) - .approvalScrollMarker() + .approvalScrollMarker(step: true) .accessibilityElement(children: .combine) .accessibilityIdentifier("approval-organization-step-\(row.number)") } diff --git a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift index f275541..dbf53f8 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteApprovalContent.swift @@ -25,44 +25,99 @@ struct FrozenWriteApprovalContent: View { .accessibilityIdentifier("approval-change-summary") } if !content.components.isEmpty { items(content) } - if !content.valueComponents.isEmpty { - valueBox(heading: content.valueHeading, components: content.valueComponents) - } instructions(content) group(content) - if let after = content.afterApproval { - ApprovalSection(title: appLocalized("After you approve"), identifier: "approval-after-approval") { - Text(verbatim: after).fixedSize(horizontal: false, vertical: true) - } - } } else if operation == .create || operation == .modify { - valueBox(heading: appLocalizedFormat("New value (provided by %@)", requester), components: []) + // Without the summary the value can still be viewed before deciding. + ApprovalSection(title: appLocalized("Items"), identifier: "approval-components", + accessory: { revealButton }) { valueDetails } } } .onDisappear { revealTask?.cancel(); revealTask = nil; revealedMaterial = nil } } private func items(_ content: FrozenWriteSummaryContent) -> some View { - ApprovalSection(title: appLocalizedFormat("Items (%lld)", content.itemCount), tag: content.componentsTag, - identifier: "approval-components") { + let hasValues = !content.valueComponents.isEmpty + return ApprovalSection(title: content.itemsHeading, name: appLocalized("Items"), identifier: "approval-components", + accessory: { if hasValues { revealButton } }) { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { ForEach(Array(content.components.enumerated()), id: \.offset) { _, component in - VStack(alignment: .leading, spacing: 2) { - ApprovalLineText(line: component.line, tag: component.tag) - ForEach(Array(component.notes.enumerated()), id: \.offset) { _, note in - ApprovalLineText(line: note) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { + VStack(alignment: .leading, spacing: 2) { + ApprovalLineText(line: component.line, tag: component.tag) + ForEach(Array(component.notes.enumerated()), id: \.offset) { _, note in + ApprovalLineText(line: note) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + } + if component.overwrites { + Text(appLocalized("If you approve, the old value is overwritten and can't be recovered.")) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.warning) + .fixedSize(horizontal: false, vertical: true) + } + } + if component.carriesValue { + Text(verbatim: "••••••").foregroundStyle(Theme.textSecondary).fixedSize() + .accessibilityLabel(Text(appLocalized("Hidden value"))) } } } - if content.overwritesValues { - Text(appLocalized("A replaced value overwrites the old one. There is no history.")) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) + if hasValues { valueDetails } + } + } + } + + @ViewBuilder + private var revealButton: some View { + if revealedMaterial != nil { + Button(appLocalized("Hide")) { revealedMaterial = nil } + .buttonStyle(.plain) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.accent) + .fixedSize() + } else { + Button(appLocalized("Authenticate to View"), action: reveal) + .buttonStyle(.plain) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.accent) + .fixedSize() + .disabled(revealMaterial == nil || revealing) + .accessibilityIdentifier("approval-reveal-frozen-material") + } + } + + @ViewBuilder + private var valueDetails: some View { + if let revealedMaterial { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + HStack { + Text(revealedMaterial.title).lineLimit(1) + Spacer() + Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary) + } + .font(Theme.Fonts.caption) + ApprovalScrollArea(space: "approval-revealed-value", unit: .lines(height: 15), maxHeight: 85, + indicatorOffset: Theme.Spacing.sm - 2) { _ in + Text(verbatim: revealedMaterial.content) + .font(Theme.Fonts.mono) + .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } + .padding(Theme.Spacing.sm) + .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.control)) + .accessibilityIdentifier("approval-revealed-value") + } else { + Text(appLocalized("Saved as is if you approve. Viewing needs another authentication; it doesn't approve.")) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + if revealFailed { + Text(appLocalized("Unable to view: authentication was not completed or the request is no longer valid.")) + .font(Theme.Fonts.caption).foregroundStyle(Theme.warning) + .fixedSize(horizontal: false, vertical: true) } } @@ -78,11 +133,39 @@ struct FrozenWriteApprovalContent: View { case .changed(let before, let after): ApprovalSection(title: appLocalized("Instructions for agents"), tag: content.instructionsTag, identifier: "approval-usage-instructions") { - comparison(before: Text(verbatim: before), after: Text(verbatim: after)) + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + comparison(before: diffText(content.instructionsDiff?.before, placeholder: before), + after: diffText(content.instructionsDiff?.after, placeholder: after)) + if let removed = content.instructionsDiff?.removedPhrases, !removed.isEmpty { + Text(appLocalizedFormat("Removed phrases: %@", + removed.map(ApprovalCopy.quote).joined(separator: appLocalized("List separator")))) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.warning) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("approval-instructions-removed") + } + } } } } + /// Removed words in red with a line through them, added words in green. + private func diffText(_ runs: [ApprovalTextDiff.Run]?, placeholder: String) -> Text { + guard let runs, !runs.isEmpty else { return Text(verbatim: placeholder) } + return Text(runs.reduce(into: AttributedString()) { result, run in + var part = AttributedString(run.text) + switch run.kind { + case .same: break + case .removed: + part.foregroundColor = Theme.warning + part.strikethroughStyle = .single + case .added: + part.foregroundColor = ApprovalTag.new.color + } + result += part + }) + } + @ViewBuilder private func group(_ content: FrozenWriteSummaryContent) -> some View { switch content.group { @@ -105,7 +188,7 @@ struct FrozenWriteApprovalContent: View { if createsGroup { HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { ApprovalTagView(tag: .newGroup) - Text(appLocalized("created when you approve")) + Text(appLocalized("Created when you approve")) .font(Theme.Fonts.caption) .foregroundStyle(Theme.textSecondary) } @@ -130,57 +213,6 @@ struct FrozenWriteApprovalContent: View { } } - private func valueBox(heading: String, components: [FrozenWriteSummaryContent.Component]) -> some View { - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Text(verbatim: heading) - .font(Theme.Fonts.caption.weight(.semibold)) - .fixedSize(horizontal: false, vertical: true) - if let revealedMaterial { - HStack { - Text(revealedMaterial.title).lineLimit(1) - Spacer() - Text(revealedMaterial.encoding).foregroundStyle(Theme.textSecondary) - Button(appLocalized("Hide")) { self.revealedMaterial = nil } - }.font(Theme.Fonts.caption) - ApprovalScrollArea(space: "approval-revealed-value", unit: .lines(height: 15), maxHeight: 85, - indicatorOffset: Theme.Spacing.sm) { _ in - Text(verbatim: revealedMaterial.content) - .font(Theme.Fonts.mono) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } else { - ForEach(Array(components.enumerated()), id: \.offset) { _, component in - HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { - Text(verbatim: component.name).lineLimit(1).truncationMode(.middle) - Text(verbatim: ApprovalCopy.bytes(component.byteCount)).foregroundStyle(Theme.textSecondary).fixedSize() - Spacer(minLength: Theme.Spacing.xs) - Text(verbatim: "••••••••").foregroundStyle(Theme.textSecondary).fixedSize() - } - } - Button(appLocalized("Authenticate to View"), action: reveal) - .disabled(revealMaterial == nil || revealing) - .accessibilityIdentifier("approval-reveal-frozen-material") - Text(appLocalized("This is the value saved if you approve. Viewing needs another authentication and does not approve the request.")) - .font(Theme.Fonts.caption) - .foregroundStyle(Theme.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - if revealFailed { - Text(appLocalized("Unable to view: authentication was not completed or the request is no longer valid.")) - .font(Theme.Fonts.caption).foregroundStyle(Theme.warning) - .fixedSize(horizontal: false, vertical: true) - } - } - .font(Theme.Fonts.secondary) - .padding(Theme.Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.neutralSubtle, in: .rect(cornerRadius: Theme.Radius.group)) - .approvalScrollMarker() - .accessibilityElement(children: .contain) - .accessibilityIdentifier("approval-value-box") - } - private func reveal() { guard let revealMaterial, !revealing else { return } revealing = true diff --git a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift index c59b6f3..bd950ee 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift @@ -9,9 +9,13 @@ struct FrozenWriteSummaryContent: Equatable { let tag: ApprovalTag? let line: ApprovalLine let notes: [ApprovalLine] - /// Listed in the value box: its value is new or may have been replaced. + /// Its value is new: the row shows masked dots and can be viewed. let carriesValue: Bool + /// A replaced value: the row warns that the old one is overwritten. + let overwrites: Bool let byteCount: Int + /// How the change summary names this item's change; nil when unchanged. + var change: String? = nil } enum Change: Equatable { @@ -23,21 +27,21 @@ struct FrozenWriteSummaryContent: Equatable { } let operation: BrokerApprovalOperation - /// Modify only: "Changes: … · Unchanged: …". + /// Modify only: "Changes: the value of TOKEN · Unchanged: …". let changeSummary: String? /// Empty when a modify leaves every item as it was. let components: [Component] let itemCount: Int - let componentsTag: ApprovalTag? - let overwritesValues: Bool + let itemsHeading: String let instructions: Change let instructionsTag: ApprovalTag? + let instructionsDiff: ApprovalTextDiff? let group: Change let groupTag: ApprovalTag? let createsGroup: Bool - let afterApproval: String? + /// What approving does, stated under the primary button. + let consequence: String? let valueOnlyChange: Bool - let valueHeading: String var valueComponents: [Component] { components.filter(\.carriesValue) } @@ -48,13 +52,19 @@ struct FrozenWriteSummaryContent: Equatable { ? Self.modifiedComponents(summary, requester: requester) : (summary.operation == .delete ? summary.before : summary.after).map { Component(name: $0.name, tag: nil, line: Self.line($0), notes: [], - carriesValue: summary.operation == .create, byteCount: $0.byteCount) + carriesValue: summary.operation == .create, overwrites: false, byteCount: $0.byteCount) } let componentsChanged = !modify || components.contains { $0.tag != .unchanged } self.components = componentsChanged ? components : [] itemCount = (summary.operation == .delete ? summary.before : summary.after).count - componentsTag = modify && componentsChanged ? .changed : nil - overwritesValues = components.contains { [.replaced, .mayBeReplaced, .changed].contains($0.tag) } + switch summary.operation { + case .create: + itemsHeading = appLocalizedFormat("Items (%1$lld, values provided by %2$@)", itemCount, requester) + case .modify where components.contains(where: \.carriesValue): + itemsHeading = appLocalizedFormat("Items (%1$lld, new values provided by %2$@)", itemCount, requester) + case .read, .modify, .delete, .organize: + itemsHeading = appLocalizedFormat("Items (%lld)", itemCount) + } let beforeInstructions = summary.beforeUsageInstructions ?? "" let afterInstructions = summary.afterUsageInstructions ?? "" @@ -76,48 +86,52 @@ struct FrozenWriteSummaryContent: Equatable { group = .current(Self.groupText(summary.afterGroup)) } instructionsTag = modify && instructionsChanged ? .changed : nil + instructionsDiff = modify && instructionsChanged + ? ApprovalTextDiff(before: beforeInstructions, after: afterInstructions) : nil groupTag = modify && groupChanged ? .changed : nil createsGroup = summary.createsGroup && (summary.operation == .create || summary.operation == .modify) - changeSummary = modify ? Self.changeSummary(items: componentsChanged, - instructions: instructionsChanged, group: groupChanged) : nil + changeSummary = modify ? Self.changeSummary(components, instructions: instructionsChanged, group: groupChanged) : nil valueOnlyChange = modify && componentsChanged && !instructionsChanged && !groupChanged - && components.allSatisfy { [.unchanged, .replaced, .mayBeReplaced].contains($0.tag) } + && components.allSatisfy { $0.tag == .unchanged || $0.tag == .replaced } switch summary.operation { case .create: - afterApproval = appLocalized("The credential's agent permission will be Ask every time: agents need your approval each time they use it.") + consequence = appLocalized("Its agent permission will be Ask every time, so agents need your approval for each use.") case .delete: - afterApproval = appLocalized("It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.") + consequence = appLocalized("It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile.") case .read, .modify, .organize: - afterApproval = nil + consequence = nil } - valueHeading = summary.operation == .create - ? appLocalizedFormat("Value to save (provided by %@)", requester) - : appLocalizedFormat("New value (provided by %@)", requester) } - /// The summary carries one digest over all items, so a same-size item can - /// be proven unchanged or replaced only when nothing else differs. + /// Each item is matched by name and tagged from its own value digest. private static func modifiedComponents(_ summary: BrokerCredentialWriteSummary, requester: String) -> [Component] { let digestsEqual = summary.beforeDigest != nil && summary.beforeDigest == summary.afterDigest - let soleReplacement = !digestsEqual && summary.beforeDigest != nil && summary.afterDigest != nil - && summary.before == summary.after && summary.after.count == 1 let before = Dictionary(summary.before.map { (normalized($0.name), $0) }, uniquingKeysWith: { first, _ in first }) let afterNames = Set(summary.after.map { normalized($0.name) }) var rows = summary.after.map { after -> Component in guard let old = before[normalized(after.name)] else { return Component(name: after.name, tag: .new, line: line(after), notes: [], - carriesValue: true, byteCount: after.byteCount) + carriesValue: true, overwrites: false, byteCount: after.byteCount, + change: appLocalizedFormat("%@ added", after.name)) + } + let valueChanged: Bool + if let oldDigest = old.valueDigest, let newDigest = after.valueDigest { + valueChanged = oldDigest != newDigest + } else { + // Without per-item digests, claim a replacement unless nothing changed. + valueChanged = old.byteCount != after.byteCount || old.payloadKind != after.payloadKind || !digestsEqual } - if old.byteCount != after.byteCount || old.payloadKind != after.payloadKind { - var notes = [ApprovalLine(appLocalizedFormat("old value %1$@ → new value %2$@ (new value from %3$@)", + if valueChanged { + var notes = [ApprovalLine(appLocalizedFormat("Old value %1$@ → new value %2$@ (new value from %3$@)", ApprovalCopy.bytes(old.byteCount), ApprovalCopy.bytes(after.byteCount), requester))] if old.delivery != after.delivery { notes.append(previous(ApprovalCopy.delivery(old.delivery))) } return Component(name: after.name, tag: .replaced, line: ApprovalLine(after.name + " · ") + ApprovalCopy.delivery(after.delivery), - notes: notes, carriesValue: true, byteCount: after.byteCount) + notes: notes, carriesValue: true, overwrites: true, byteCount: after.byteCount, + change: appLocalizedFormat("the value of %@", after.name)) } if old.delivery != after.delivery || old.masked != after.masked || old.name != after.name { var notes = [previous(line(old))] @@ -126,27 +140,18 @@ struct FrozenWriteSummaryContent: Equatable { ? appLocalized("Now masked when shown in Ask Key") : appLocalized("Now shown in full in Ask Key"))) } - notes.append(ApprovalLine(appLocalized("Same size; Ask Key can't tell whether the value was replaced. Authenticate to view."))) return Component(name: after.name, tag: .changed, line: line(after), notes: notes, - carriesValue: true, byteCount: after.byteCount) - } - if digestsEqual { - return Component(name: after.name, tag: .unchanged, line: line(after), notes: [], - carriesValue: false, byteCount: after.byteCount) - } - if soleReplacement { - return Component(name: after.name, tag: .replaced, - line: ApprovalLine(after.name + " · ") + ApprovalCopy.delivery(after.delivery), - notes: [ApprovalLine(appLocalizedFormat("old value %1$@ → new value %2$@ (new value from %3$@)", - ApprovalCopy.bytes(old.byteCount), ApprovalCopy.bytes(after.byteCount), requester))], - carriesValue: true, byteCount: after.byteCount) + carriesValue: false, overwrites: false, byteCount: after.byteCount, + change: old.delivery != after.delivery + ? appLocalizedFormat("how %@ is given to programs", after.name) + : appLocalizedFormat("the settings of %@", after.name)) } - return Component(name: after.name, tag: .mayBeReplaced, line: line(after), - notes: [ApprovalLine(appLocalized("Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view."))], - carriesValue: true, byteCount: after.byteCount) + return Component(name: after.name, tag: .unchanged, line: line(after), notes: [], + carriesValue: false, overwrites: false, byteCount: after.byteCount) } rows += summary.before.filter { !afterNames.contains(normalized($0.name)) }.map { - Component(name: $0.name, tag: .removed, line: line($0), notes: [], carriesValue: false, byteCount: $0.byteCount) + Component(name: $0.name, tag: .removed, line: line($0), notes: [], carriesValue: false, + overwrites: false, byteCount: $0.byteCount, change: appLocalizedFormat("%@ removed", $0.name)) } return rows } @@ -161,15 +166,20 @@ struct FrozenWriteSummaryContent: Equatable { return ApprovalLine(parts.first ?? "") + line + ApprovalLine(parts.dropFirst().joined()) } - private static func changeSummary(items: Bool, instructions: Bool, group: Bool) -> String { - let sections = [(appLocalized("items"), items), (appLocalized("instructions"), instructions), - (appLocalized("group"), group)] + /// Names what changes and what stays, item by item; never a section name + /// standing in for the items. + private static func changeSummary(_ components: [Component], instructions: Bool, group: Bool) -> String { + var changed = components.compactMap(\.change) + var unchanged = components.filter { $0.change == nil }.map(\.name) + let instructionsName = appLocalized("Change summary: instructions") + let groupName = appLocalized("group") + if instructions { changed.append(instructionsName) } else { unchanged.append(instructionsName) } + if group { changed.append(groupName) } else { unchanged.append(groupName) } let separator = appLocalized("List separator") - let changed = sections.filter(\.1).map(\.0).joined(separator: separator) - let unchanged = sections.filter { !$0.1 }.map(\.0).joined(separator: separator) if changed.isEmpty { return appLocalized("Nothing changes") } - if unchanged.isEmpty { return appLocalizedFormat("Changes: %@", changed) } - return appLocalizedFormat("Changes: %1$@ · Unchanged: %2$@", changed, unchanged) + if unchanged.isEmpty { return appLocalizedFormat("Changes: %@", changed.joined(separator: separator)) } + return appLocalizedFormat("Changes: %1$@ · Unchanged: %2$@", + changed.joined(separator: separator), unchanged.joined(separator: separator)) } private static func instructionsText(_ value: String) -> String { diff --git a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings index f0193f5..8d249ba 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -1,6 +1,254 @@ { "sourceLanguage": "en", "strings": { + "%1$@ asked to rename %2$@ to %3$@; %3$@ already exists and is hidden from it, so the groups merge.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ asked to rename %2$@ to %3$@; %3$@ already exists and is hidden from it, so the groups merge." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 请求的是把%2$@改名为%3$@;%3$@已存在且对它隐藏,所以实际会合并。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ asked to rename %2$@ to %3$@; %3$@ already exists and is hidden from it, so the groups merge.!!]" } } + } + }, + "%1$@ wants to replace %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to replace %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想替换凭证%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to replace %2$@!!]" } } + } + }, + "%@ added": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%@ added" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增 %@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ added!!]" } } + } + }, + "%@ removed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%@ removed" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除 %@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ removed!!]" } } + } + }, + "Below: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Below: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Below: %@!!]" } } + } + }, + "Created when you approve": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Created when you approve" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后创建" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Created when you approve!!]" } } + } + }, + "Credential value: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "the value of %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the value of %@!!]" } } + } + }, + "Esc to hide; decide in Pending requests before it expires": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Esc to hide; decide in Pending requests before it expires" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Esc 先收起,过期前可在「待处理请求」里决定" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Esc to hide; decide in Pending requests before it expires!!]" } } + } + }, + "Expires in %@ and nothing is handed over": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Expires in %@ and nothing is handed over" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 后自动作废,不会交出任何东西" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Expires in %@ and nothing is handed over!!]" } } + } + }, + "For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!For %lld minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.!!]" } } + } + }, + "Hidden value": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Hidden value" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已隐藏的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hidden value!!]" } } + } + }, + "If you allow, this command receives %1$lld items from %2$@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "If you allow, this command receives %1$lld items from %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后这个命令会拿到%2$@里的 %1$lld 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you allow, this command receives %1$lld items from %2$@!!]" } } + } + }, + "If you allow, this command receives 1 item from %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "If you allow, this command receives 1 item from %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后这个命令会拿到%@里的 1 项" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you allow, this command receives 1 item from %@!!]" } } + } + }, + "If you approve, the old value is overwritten and can't be recovered.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "If you approve, the old value is overwritten and can't be recovered." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后旧值会被覆盖,无法找回。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!If you approve, the old value is overwritten and can't be recovered.!!]" } } + } + }, + "Items": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items!!]" } } + } + }, + "Items (%1$lld, new values provided by %2$@)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, new values provided by %2$@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,新值由 %2$@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, new values provided by %2$@)!!]" } } + } + }, + "Items (%1$lld, values provided by %2$@)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Items (%1$lld, values provided by %2$@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容(%1$lld 项,值由 %2$@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items (%1$lld, values provided by %2$@)!!]" } } + } + }, + "Old value %1$@ → new value %2$@ (new value from %3$@)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Old value %1$@ → new value %2$@ (new value from %3$@)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值 %1$@ → 新值 %2$@(新值由 %3$@ 提供)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Old value %1$@ → new value %2$@ (new value from %3$@)!!]" } } + } + }, + "Removed phrases: %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Removed: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "删掉了:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Removed: %@!!]" } } + } + }, + "Stated purpose": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Stated purpose" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "对方说的用途" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Stated purpose!!]" } } + } + }, + "What the command receives": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "What the command receives" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令会拿到的内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!What the command receives!!]" } } + } + }, + "When it's renamed, the group has %@.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "When it's renamed, the group has %@." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "改名时组里有 %@。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When it's renamed, the group has %@.!!]" } } + } + }, + "When it's renamed, the group is empty.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "When it's renamed, the group is empty." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "改名时组里没有凭证。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When it's renamed, the group is empty.!!]" } } + } + }, + "When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "倒计时结束后请求作废,不会交出或写入任何东西,Agent 会收到已过期的结果。按 Esc 只是先收起卡片,请求会留在「待处理请求」里,直到过期。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When time runs out, the request is void and nothing is handed over or written; the agent is told it expired. Esc hides this card; the request stays in Pending requests until it expires.!!]" } } + } + }, + "You cancelled authentication. Nothing was given to the command, and the request is still pending.": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "You cancelled authentication. Nothing was given to the command, and the request is still pending." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "你取消了验证,命令没有拿到任何东西,请求仍在等待。" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!You cancelled authentication. Nothing was given to the command, and the request is still pending.!!]" } } + } + }, + "how %@ is given to programs": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "how %@ is given to programs" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 交给程序的方式" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!how %@ is given to programs!!]" } } + } + }, + "Change summary: instructions": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "instructions for agents" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "给 Agent 的使用说明" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!instructions for agents!!]" } } + } + }, + "the settings of %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "the settings of %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 的设置" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the settings of %@!!]" } } + } + }, + "the value of %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "the value of %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the value of %@!!]" } } + } + }, + "→ Environment variable %@": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "→ Environment variable %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "→ 环境变量 %@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!→ Environment variable %@!!]" } } + } + }, + "→ Temporary file (path in %@, removed within 5 minutes)": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "→ Temporary file (path in %@, removed within 5 minutes)" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "→ 临时文件(路径在 %@,最多 5 分钟后删除)" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!→ Temporary file (path in %@, removed within 5 minutes)!!]" } } + } + }, "Reached the end": { "extractionState": "manual", "localizations": { @@ -57,14 +305,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to reorganize your groups (%2$lld steps)!!]" } } } }, - "%1$@ wants to replace the value of %2$@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ wants to replace the value of %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想替换凭证%2$@的值" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to replace the value of %2$@!!]" } } - } - }, "%1$@ wants to use the credential %2$@": { "extractionState": "manual", "localizations": { @@ -81,22 +321,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to use the credential %2$@ to run %3$@!!]" } } } }, - "%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@· 作为临时文件(路径在 %2$@,最多 5 分钟后删除)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ · as a temporary file (path in %2$@, removed within 5 minutes)!!]" } } - } - }, - "%1$@ · as environment variable %2$@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%1$@ · as environment variable %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@· 作为环境变量 %2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ · as environment variable %2$@!!]" } } - } - }, "%@ (name provided by the requester; Ask Key can't verify it)": { "extractionState": "manual", "localizations": { @@ -137,14 +361,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld more lines — scroll to see them!!]" } } } }, - "%lld more sections below — scroll to see them": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%lld more sections below — scroll to see them" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有 %lld 部分,向下滚动查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%lld more sections below — scroll to see them!!]" } } - } - }, "%lld more steps — scroll to see them": { "extractionState": "manual", "localizations": { @@ -169,14 +385,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 more line — scroll to see it!!]" } } } }, - "1 more section below — scroll to see it": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "1 more section below — scroll to see it" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有 1 部分,向下滚动查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!1 more section below — scroll to see it!!]" } } - } - }, "1 more step — scroll to see it": { "extractionState": "manual", "localizations": { @@ -193,22 +401,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!A merge can't be undone automatically.!!]" } } } }, - "A replaced value overwrites the old one. There is no history.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "A replaced value overwrites the old one. There is no history." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "替换会覆盖旧值,没有历史记录。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!A replaced value overwrites the old one. There is no history.!!]" } } - } - }, - "After you approve": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "After you approve" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!After you approve!!]" } } - } - }, "Allow for %lld Minutes": { "extractionState": "manual", "localizations": { @@ -313,22 +505,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Delete the group %@!!]" } } } }, - "Esc to decide later in Pending requests": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Esc to decide later in Pending requests" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "Esc 先收起,稍后在「待处理请求」里决定" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Esc to decide later in Pending requests!!]" } } - } - }, - "For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "%lld 分钟内,这个 macOS 用户下的任何 Agent 或命令读取这个凭证都不再询问;不包括修改。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!For %lld minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.!!]" } } - } - }, "If you allow, this command receives": { "extractionState": "manual", "localizations": { @@ -377,14 +553,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Its %1$@ won't be deleted and will become %2$@.!!]" } } } }, - "May be replaced": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "May be replaced" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "可能已替换" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!May be replaced!!]" } } - } - }, "Merge": { "extractionState": "manual", "localizations": { @@ -393,14 +561,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Merge!!]" } } } }, - "Merge the group %1$@ into the existing group %2$@": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Merge the group %1$@ into the existing group %2$@" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "把分组%1$@合并到已有分组%2$@" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Merge the group %1$@ into the existing group %2$@!!]" } } - } - }, "More below — scroll to see it": { "extractionState": "manual", "localizations": { @@ -417,14 +577,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Move the credential %1$@ from %2$@ to %3$@!!]" } } } }, - "Move to Trash": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Move to Trash" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到回收站" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Move to Trash!!]" } } - } - }, "New": { "extractionState": "manual", "localizations": { @@ -441,14 +593,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New group!!]" } } } }, - "New value (provided by %@)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "New value (provided by %@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "新值(由 %@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!New value (provided by %@)!!]" } } - } - }, "No change": { "extractionState": "manual", "localizations": { @@ -529,22 +673,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Runs in!!]" } } } }, - "Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "大小和交付方式没变,无法确认值是否被替换;可验证后查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view.!!]" } } - } - }, - "Same size; Ask Key can't tell whether the value was replaced. Authenticate to view.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Same size; Ask Key can't tell whether the value was replaced. Authenticate to view." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "大小没变,无法确认值是否被替换;可验证后查看" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Same size; Ask Key can't tell whether the value was replaced. Authenticate to view.!!]" } } - } - }, "Sentence separator": { "extractionState": "manual", "localizations": { @@ -561,12 +689,12 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Stated purpose (not verified)!!]" } } } }, - "The credential's agent permission will be Ask every time: agents need your approval each time they use it.": { + "Its agent permission will be Ask every time, so agents need your approval for each use.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "The credential's agent permission will be Ask every time: agents need your approval each time they use it." } }, + "en": { "stringUnit": { "state": "translated", "value": "Its agent permission will be Ask every time, so agents need your approval for each use." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "这个凭证的 Agent 权限是「每次询问」:Agent 每次使用都要你批准。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!The credential's agent permission will be Ask every time: agents need your approval each time they use it.!!]" } } + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Its agent permission will be Ask every time, so agents need your approval for each use.!!]" } } } }, "The group is empty.": { @@ -593,36 +721,12 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This group already exists, so nothing is created or changed.!!]" } } } }, - "This is the value saved if you approve. Viewing needs another authentication and does not approve the request.": { + "Saved as is if you approve. Viewing needs another authentication; it doesn't approve.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "This is the value saved if you approve. Viewing needs another authentication and does not approve the request." } }, + "en": { "stringUnit": { "state": "translated", "value": "Saved as is if you approve. Viewing needs another authentication; it doesn't approve." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后保存的就是这个值。查看要再验证一次,查看不等于批准。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!This is the value saved if you approve. Viewing needs another authentication and does not approve the request.!!]" } } - } - }, - "Value to save (provided by %@)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Value to save (provided by %@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "要保存的值(由 %@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Value to save (provided by %@)!!]" } } - } - }, - "When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires.": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires." } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "倒计时结束后请求失效:不会交付或写入任何内容,Agent 会收到已失效的结果。按 Esc 只是先收起卡片,请求会留在「待处理请求」里,直到失效。" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!When time runs out, the request expires: nothing is delivered or written, and the agent is told it expired. Esc closes this card; the request stays in Pending requests until it expires.!!]" } } - } - }, - "created when you approve": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "created when you approve" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后创建" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!created when you approve!!]" } } + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Saved as is if you approve. Viewing needs another authentication; it doesn't approve.!!]" } } } }, "given to programs as a temporary file (path in %@)": { @@ -649,22 +753,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!group!!]" } } } }, - "instructions": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "instructions" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "使用说明" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!instructions!!]" } } - } - }, - "items": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "items" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!items!!]" } } - } - }, "kept in Ask Key only, never given to agents": { "extractionState": "manual", "localizations": { @@ -673,14 +761,6 @@ "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!kept in Ask Key only, never given to agents!!]" } } } }, - "old value %1$@ → new value %2$@ (new value from %3$@)": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "old value %1$@ → new value %2$@ (new value from %3$@)" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "旧值 %1$@ → 新值 %2$@(新值由 %3$@ 提供)" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!old value %1$@ → new value %2$@ (new value from %3$@)!!]" } } - } - }, "Quoted name: %@": { "extractionState": "manual", "localizations": { @@ -14432,29 +14512,6 @@ } } }, - "You cancelled authentication. The credential was not delivered, and the request is still pending.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "You cancelled authentication. The credential was not delivered, and the request is still pending." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "你取消了验证,凭证没有交付,请求仍在等待。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!You cancelled authentication. The credential was not delivered, and the request is still pending.!!]" - } - } - } - }, "← Back": { "extractionState": "extracted_with_value", "localizations": { diff --git a/Sources/AskKeyBroker/CredentialComponents.swift b/Sources/AskKeyBroker/CredentialComponents.swift index a4c231a..0553907 100644 --- a/Sources/AskKeyBroker/CredentialComponents.swift +++ b/Sources/AskKeyBroker/CredentialComponents.swift @@ -100,13 +100,19 @@ public struct BrokerCredentialComponentSummary: Equatable, Sendable { public let byteCount: Int public let delivery: BrokerComponentDelivery public let masked: Bool + /// App-side only: a vault-keyed digest of this component's value, so an + /// approval card can tell exactly which values change. Never part of a + /// Broker response and never logged. + public let valueDigest: String? - public init(name: String, payloadKind: BrokerCatalogPayloadKind, byteCount: Int, delivery: BrokerComponentDelivery, masked: Bool) { + public init(name: String, payloadKind: BrokerCatalogPayloadKind, byteCount: Int, delivery: BrokerComponentDelivery, + masked: Bool, valueDigest: String? = nil) { self.name = name self.payloadKind = payloadKind self.byteCount = byteCount self.delivery = delivery self.masked = masked + self.valueDigest = valueDigest } } diff --git a/Sources/AskKeyVault/Vault+AgentWriteFreeze.swift b/Sources/AskKeyVault/Vault+AgentWriteFreeze.swift index 6d1fc18..fdf59c4 100644 --- a/Sources/AskKeyVault/Vault+AgentWriteFreeze.swift +++ b/Sources/AskKeyVault/Vault+AgentWriteFreeze.swift @@ -228,6 +228,20 @@ extension Vault { before: [CredentialComponentInput], after: [CredentialComponentInput], beforeRecord: CredentialRecord?, afterRecord: CredentialRecord?, createsGroup: Bool, key: SymmetricKey) throws -> BrokerCredentialWriteSummary { + // Presentation only: lets the App tag each component exactly. The + // approval digest below still covers the components as a whole. + let digestKey = HKDF.deriveKey(inputKeyMaterial: key, salt: Data(), + info: Data("AskKey approval component value digest v1".utf8), outputByteCount: 32) + func valueDigest(_ value: CredentialComponentValue) -> String { + func field(_ bytes: Data) -> Data { withUnsafeBytes(of: UInt64(bytes.count).bigEndian) { Data($0) } + bytes } + let material: Data + switch value { + case .text(let text): material = Data("text".utf8) + field(Data(text.utf8)) + case .file(let filename, let bytes): material = Data("file".utf8) + field(Data(filename.utf8)) + field(bytes) + } + return Data(HMAC.authenticationCode(for: material, using: digestKey)) + .map { String(format: "%02x", $0) }.joined() + } func projections(_ inputs: [CredentialComponentInput]) -> [BrokerCredentialComponentSummary] { inputs.map { input in let kind: BrokerCatalogPayloadKind @@ -237,7 +251,7 @@ extension Vault { case .file(_, let bytes): kind = .file; count = bytes.count } return .init(name: input.name, payloadKind: kind, byteCount: count, - delivery: input.delivery, masked: input.masked) + delivery: input.delivery, masked: input.masked, valueDigest: valueDigest(input.value)) } } let encoder = JSONEncoder() diff --git a/Tests/AskKeyAppTests/ApprovalCardFixtures.swift b/Tests/AskKeyAppTests/ApprovalCardFixtures.swift index 85cde52..d42dead 100644 --- a/Tests/AskKeyAppTests/ApprovalCardFixtures.swift +++ b/Tests/AskKeyAppTests/ApprovalCardFixtures.swift @@ -8,7 +8,7 @@ import AskKeyBroker enum ApprovalCardFixtures { enum Card: String, CaseIterable { case readDefault, readDetails, readFile, readCancelled, readWithoutTimed, readLongCommand, readBundle - case create, modifyMetadata, modifyValue, modifyAdded, delete + case create, createOneItem, createTwoItems, modifyMetadata, modifyValue, modifyAdded, delete case organize, organizeExistingAndMerge, organizeTwelveSteps } @@ -35,9 +35,11 @@ enum ApprovalCardFixtures { environmentVariables: environment, temporaryFileVariables: files) } + /// The digest stands in for the vault-keyed value digest; equal digests mean an equal value. static func component(_ name: String, bytes: Int, _ delivery: BrokerComponentDelivery, - kind: BrokerCatalogPayloadKind = .text) -> BrokerCredentialComponentSummary { - .init(name: name, payloadKind: kind, byteCount: bytes, delivery: delivery, masked: true) + kind: BrokerCatalogPayloadKind = .text, value: String? = nil) -> BrokerCredentialComponentSummary { + .init(name: name, payloadKind: kind, byteCount: bytes, delivery: delivery, masked: true, + valueDigest: "digest-" + (value ?? name)) } static let token = component("TOKEN", bytes: 28, .environmentVariable("RELEASE_CHECK_TOKEN")) @@ -50,6 +52,17 @@ enum ApprovalCardFixtures { beforeUsageInstructions: nil, afterUsageInstructions: "Use only for the release smoke check. Keep values out of logs.", beforeGroup: nil, afterGroup: "Release Tools", createsGroup: true) + static let createOneItemSummary = BrokerCredentialWriteSummary(credentialName: "Deploy Host", operation: .create, + before: [], after: [component("DEPLOY_HOST", bytes: 18, .environmentVariable("DEPLOY_HOST"))], + beforeDigest: nil, afterDigest: "after", beforeUsageInstructions: nil, + afterUsageInstructions: "Use only for the staging deploy script.", beforeGroup: nil, afterGroup: "Staging", createsGroup: true) + + static let createTwoItemsSummary = BrokerCredentialWriteSummary(credentialName: "Staging SSH", operation: .create, + before: [], after: [component("SSH_USER", bytes: 7, .environmentVariable("SSH_USER")), + component("SSH_KEY", bytes: 411, .temporaryFile("SSH_KEY_FILE"), kind: .file)], + beforeDigest: nil, afterDigest: "after", beforeUsageInstructions: nil, + afterUsageInstructions: "Use for SSH to the staging host only.", beforeGroup: nil, afterGroup: "Staging", createsGroup: false) + static let metadataSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, before: [token, user], after: [token, user], beforeDigest: "same", afterDigest: "same", beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use only for the nightly release check.", @@ -57,7 +70,7 @@ enum ApprovalCardFixtures { static let valueSummary = BrokerCredentialWriteSummary(credentialName: "Release Check", operation: .modify, before: [component("TOKEN", bytes: 40, .environmentVariable("RELEASE_CHECK_TOKEN"))], - after: [component("TOKEN", bytes: 52, .environmentVariable("RELEASE_CHECK_TOKEN"))], + after: [component("TOKEN", bytes: 52, .environmentVariable("RELEASE_CHECK_TOKEN"), value: "rotated")], beforeDigest: "old", afterDigest: "new", beforeUsageInstructions: "Use for release checks.", afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Release Tools") @@ -118,6 +131,10 @@ enum ApprovalCardFixtures { timedAllowanceEnabled: true, finish: { _ in }) case .create: return write(.create, createSummary) + case .createOneItem: + return write(.create, createOneItemSummary, purpose: "Save the staging deploy host") + case .createTwoItems: + return write(.create, createTwoItemsSummary, purpose: "Save the staging SSH login") case .modifyMetadata: return write(.modify, metadataSummary) case .modifyValue: diff --git a/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift index c3d6bb8..1173822 100644 --- a/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift +++ b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift @@ -93,8 +93,38 @@ final class ApprovalCardLayoutTests: AskKeyAppTestCase { Fixtures.withLanguages { language in let collapsed = height(Fixtures.prompt(.readDefault)) XCTAssertGreaterThan(height(Fixtures.prompt(.readDetails)), collapsed, "\(language): Details expand in place") - XCTAssertLessThan(height(Fixtures.prompt(.readCancelled)), collapsed, "\(language): one retry replaces two allow buttons") + let retryOnce = FrozenAgentApprovalPrompt(request: Fixtures.request(.read, display: Fixtures.display()), + timedAllowanceEnabled: true, cancelledAuthenticationDecision: .once, finish: { _ in }) + XCTAssertLessThan(height(retryOnce), collapsed, "\(language): one retry replaces two allow buttons") + XCTAssertGreaterThan(height(Fixtures.prompt(.readCancelled)), height(retryOnce), + "\(language): the timed retry keeps its scope line") XCTAssertLessThan(height(Fixtures.prompt(.readWithoutTimed)), collapsed) } } + + func testShortCreateCardsFitWithoutScrolling() { + Fixtures.withLanguages { language in + for card in [Fixtures.Card.createOneItem, .createTwoItems] { + XCTAssertLessThan(height(Fixtures.prompt(card)), FrozenAgentApprovalPrompt.maximumHeight - 1, + "\(language) \(card.rawValue): one or two items, short instructions and a group fit") + } + } + } + + func testOverflowingCardsShrinkTheIconBeforeScrolling() { + func height(_ maximum: CGFloat) -> CGFloat { + NSHostingView(rootView: ApprovalCardLayout(maximumHeight: maximum) { + Color.clear.frame(minHeight: 40, idealHeight: 64, maxHeight: 64) + .layoutValue(key: ApprovalCardCompactHeightKey.self, value: 40) + Color.clear.frame(height: 100) + ScrollView { Color.clear.frame(height: 300) } + .layoutValue(key: ApprovalCardFlexibleKey.self, value: true) + } + .frame(width: FrozenAgentApprovalPrompt.contentWidth) + .fixedSize(horizontal: false, vertical: true)).fittingSize.height + } + XCTAssertEqual(height(500), 464, accuracy: 1, "a card that fits keeps the large icon") + XCTAssertEqual(height(450), 440, accuracy: 1, "a smaller icon can avoid scrolling") + XCTAssertEqual(height(300), 300, accuracy: 1, "then the body scrolls inside the cap") + } } diff --git a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift index 8a840b0..babd03e 100644 --- a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift +++ b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift @@ -56,6 +56,9 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { XCTAssertFalse(quoted.contains(" "), "spaces inside a name never break") XCTAssertEqual(quoted.filter { $0 == "\u{2060}" }.count, "Staging API".count + 1) XCTAssertEqual(plain(ApprovalCopy.group(nil)), language == "en" ? "“Ungrouped”" : "「未分组」") // i18n-literal: Chinese ungrouped name. + // A wrapped value-only title keeps the name together with its "value of" wording. + let valueTitle = content(.modify, valueOnly: true).title + XCTAssertTrue(valueTitle.contains(language == "en" ? "f\u{2060}\u{00A0}\u{2060}“" : "」\u{2060}的\u{2060}值")) // i18n-literal: Chinese value suffix. } } @@ -65,9 +68,12 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { let read = content(.read, display: Fixtures.display(command: longCommand)) XCTAssertEqual(read.command, longCommand, "the command is never shortened in the middle") XCTAssertEqual(read.purpose, "Deploy the staging site") - XCTAssertEqual(read.receives.map { plain($0.plainText) }, [language == "en" - ? "“Staging API” · as environment variable STAGING_API_TOKEN" - : "「Staging API」· 作为环境变量 STAGING_API_TOKEN"]) // i18n-literal: Assert the Simplified Chinese delivery row. + XCTAssertEqual(plain(read.receivesHeading), language == "en" + ? "If you allow, this command receives 1 item from “Staging API”" + : "批准后这个命令会拿到「Staging API」里的 1 项") // i18n-literal: Assert the Simplified Chinese receives heading. + XCTAssertEqual(read.receives.map(\.plainText), [language == "en" + ? "→ Environment variable STAGING_API_TOKEN" + : "→ 环境变量 STAGING_API_TOKEN"]) // i18n-literal: Assert the Simplified Chinese delivery row. XCTAssertEqual(read.receives.first?.segments.filter(\.code).map(\.text), ["STAGING_API_TOKEN"]) XCTAssertEqual(read.detailRows.map(\.label), language == "en" ? ["Runs in", "Requested by"] : ["运行目录", "请求方"]) // i18n-literal: Assert Simplified Chinese detail labels. @@ -75,34 +81,39 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { ? "Claude Code (name provided by the requester; Ask Key can't verify it)" : "Claude Code(名称由请求方提供,请旨无法核实)"]) // i18n-literal: Assert the Simplified Chinese requester row. XCTAssertEqual(read.detailRows.first?.monospaced, true) - let headings = ["Command to run", "If you allow, this command receives", "Stated purpose (not verified)", "Details"] - .map { appLocalized($0) } + let headings = ["Command to run", "Stated purpose (not verified)", "Details"].map { appLocalized($0) } XCTAssertEqual(headings, language == "en" - ? ["Command to run", "If you allow, this command receives", "Stated purpose (not verified)", "Details"] : [ - "要运行的命令", "批准后这个命令会拿到", "对方说的用途(未核实)", "详细信息", // i18n-literal: Assert Simplified Chinese read headings. + ? ["Command to run", "Stated purpose (not verified)", "Details"] : [ + "要运行的命令", "对方说的用途(未核实)", "详细信息", // i18n-literal: Assert Simplified Chinese read headings. ]) } } func testReadDeliveryRowsCoverFilesMultiItemCredentialsAndNoDelivery() { Fixtures.withLanguages { language in - let file = content(.read, display: Fixtures.display(environment: [], files: ["STAGING_CERT_FILE"])) + let file = content(.read, display: Fixtures.display(environment: ["DEPLOY_HOST"], files: ["SSH_KEY_FILE"])) let bundle = content(.read, display: Fixtures.display(environment: nil, files: nil)) let nothing = content(.read, display: Fixtures.display(environment: [], files: [])) + let headings = [file, bundle, nothing].map { plain($0.receivesHeading) } let rows = [file, bundle, nothing].map { $0.receives.map { plain($0.plainText) } } if language == "en" { + XCTAssertEqual(headings, ["If you allow, this command receives 2 items from “Staging API”", + "If you allow, this command receives", "If you allow, this command receives"]) XCTAssertEqual(rows, [ - ["“Staging API” · as a temporary file (path in STAGING_CERT_FILE, removed within 5 minutes)"], + ["→ Environment variable DEPLOY_HOST", "→ Temporary file (path in SSH_KEY_FILE, removed within 5 minutes)"], ["The items of “Staging API” that are set to be given to programs (names are shown after you approve)"], ["Nothing from this credential is given to the command"], ]) } else { + XCTAssertEqual(headings, ["批准后这个命令会拿到「Staging API」里的 2 项", // i18n-literal: Assert the Simplified Chinese receives heading. + "批准后这个命令会拿到", "批准后这个命令会拿到"]) // i18n-literal: Assert the Simplified Chinese receives heading. XCTAssertEqual(rows, [ - ["「Staging API」· 作为临时文件(路径在 STAGING_CERT_FILE,最多 5 分钟后删除)"], // i18n-literal: Assert the Simplified Chinese file row. + ["→ 环境变量 DEPLOY_HOST", "→ 临时文件(路径在 SSH_KEY_FILE,最多 5 分钟后删除)"], // i18n-literal: Assert Simplified Chinese delivery rows. ["「Staging API」里设为交给程序的所有项(具体名称批准后才能看到)"], // i18n-literal: Assert the Simplified Chinese bundle row. ["不交给这个命令任何值"], // i18n-literal: Assert the Simplified Chinese no-delivery row. ]) } + XCTAssertFalse(rows[0].joined().contains("Staging API"), "rows never repeat the credential name") XCTAssertTrue(content(.delete, display: Fixtures.display()).receives.isEmpty) XCTAssertNil(content(.create, display: Fixtures.display()).command, "write cards never show a run target") } @@ -124,10 +135,10 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { XCTAssertEqual(titles, [ ["Allow Once", "Allow for 30 Minutes", "Deny"], ["Allow Once", "Deny"], ["Create Credential", "Deny"], ["Save Changes", "Deny"], ["Replace Value", "Deny"], - ["Move to Trash", "Deny"], ["Apply 4 Steps", "Deny"], ["Apply 1 Step", "Deny"], + ["Move to Recycle Bin", "Deny"], ["Apply 4 Steps", "Deny"], ["Apply 1 Step", "Deny"], ]) XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), - "For 30 minutes, any agent or command run by this macOS user can read this credential without asking. Changes are never included.") + "For 30 minutes, any agent or command in your Mac account can read this credential without asking. Changing or deleting it still needs your approval.") } else { XCTAssertEqual(titles, [ ["允许本次", "30 分钟内都允许", "拒绝"], ["允许本次", "拒绝"], // i18n-literal: Assert Simplified Chinese read buttons. @@ -135,7 +146,7 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { ["移到回收站", "拒绝"], ["执行这 4 步", "拒绝"], ["执行这 1 步", "拒绝"], // i18n-literal: Assert Simplified Chinese write buttons. ]) XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), - "30 分钟内,这个 macOS 用户下的任何 Agent 或命令读取这个凭证都不再询问;不包括修改。") // i18n-literal: Assert the Simplified Chinese timed scope. + "30 分钟内,你这个 Mac 账户下的任何 Agent 或命令读取这个凭证都不再询问;修改或删除它仍要你批准。") // i18n-literal: Assert the Simplified Chinese timed scope. } XCTAssertEqual(FrozenApprovalActions.primary(operation: .delete).role, .destructive) XCTAssertEqual(FrozenApprovalActions.primary(operation: .organize, steps: 2, destructive: true).role, .destructive) @@ -154,36 +165,40 @@ final class ApprovalPromptContentTests: AskKeyAppTestCase { FrozenApprovalActions.retry(.once, primary: trash, minutes: 30), ] XCTAssertEqual(retries, language == "en" - ? ["Authenticate and Allow Once", "Authenticate and Allow for 30 Minutes", "Authenticate and Move to Trash"] + ? ["Authenticate and Allow Once", "Authenticate and Allow for 30 Minutes", "Authenticate and Move to Recycle Bin"] : ["重新验证,允许本次", "重新验证,30 分钟内都允许", "重新验证,移到回收站"]) // i18n-literal: Assert Simplified Chinese retry buttons. XCTAssertEqual(content(.read).cancelledAuthenticationNote, language == "en" - ? "You cancelled authentication. The credential was not delivered, and the request is still pending." - : "你取消了验证,凭证没有交付,请求仍在等待。") // i18n-literal: Assert the Simplified Chinese cancelled note. + ? "You cancelled authentication. Nothing was given to the command, and the request is still pending." + : "你取消了验证,命令没有拿到任何东西,请求仍在等待。") // i18n-literal: Assert the Simplified Chinese cancelled note. } } func testFooterAndOverflowCopyInBothLanguages() { Fixtures.withLanguages { language in - let footer = appLocalizedFormat("Expires in %@", "4:59") + " · " + appLocalized("Esc to decide later in Pending requests") + let footer = appLocalizedFormat("Expires in %@ and nothing is handed over", "4:59") + " · " + + appLocalized("Esc to hide; decide in Pending requests before it expires") let hints = [ - ApprovalOverflow.hint(unit: .steps, hiddenHeight: 120, hiddenMarkers: 3), - ApprovalOverflow.hint(unit: .steps, hiddenHeight: 20, hiddenMarkers: 1), - ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 46, hiddenMarkers: 0), - ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 10, hiddenMarkers: 0), - ApprovalOverflow.hint(unit: .sections, hiddenHeight: 80, hiddenMarkers: 2), - ApprovalOverflow.hint(unit: .sections, hiddenHeight: 8, hiddenMarkers: 0), - ApprovalOverflow.hint(unit: .steps, hiddenHeight: 0.5, hiddenMarkers: 0), + ApprovalOverflow.hint(unit: .steps, hiddenHeight: 120, hiddenSteps: 3, hiddenNames: [appLocalized("Details")]), + ApprovalOverflow.hint(unit: .steps, hiddenHeight: 20, hiddenSteps: 1), + ApprovalOverflow.hint(unit: .steps, hiddenHeight: 20, hiddenNames: [appLocalized("Details")]), + ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 46), + ApprovalOverflow.hint(unit: .lines(height: 15), hiddenHeight: 10), + ApprovalOverflow.hint(unit: .sections, hiddenHeight: 80, + hiddenNames: [appLocalized("Group"), appLocalized("Details")]), + ApprovalOverflow.hint(unit: .sections, hiddenHeight: 8), + ApprovalOverflow.hint(unit: .steps, hiddenHeight: 0.5), ] if language == "en" { - XCTAssertEqual(footer, "Expires in 4:59 · Esc to decide later in Pending requests") - XCTAssertEqual(hints, ["3 more steps — scroll to see them", "1 more step — scroll to see it", + XCTAssertEqual(footer, "Expires in 4:59 and nothing is handed over · Esc to hide; decide in Pending requests before it expires") + XCTAssertEqual(appLocalized("Pending requests"), "Pending requests", "the footer uses the sidebar's item name") + XCTAssertEqual(hints, ["3 more steps — scroll to see them", "1 more step — scroll to see it", "Below: Details", "3 more lines — scroll to see them", "1 more line — scroll to see it", - "2 more sections below — scroll to see them", "More below — scroll to see it", nil]) + "Below: Group, Details", "More below — scroll to see it", nil]) } else { - XCTAssertEqual(footer, "4:59 后自动失效 · Esc 先收起,稍后在「待处理请求」里决定") // i18n-literal: Assert the Simplified Chinese footer. - XCTAssertEqual(hints, ["还有 3 步,向下滚动查看", "还有 1 步,向下滚动查看", // i18n-literal: Assert Simplified Chinese overflow hints. + XCTAssertEqual(footer, "4:59 后自动作废,不会交出任何东西 · Esc 先收起,过期前可在「待处理请求」里决定") // i18n-literal: Assert the Simplified Chinese footer. + XCTAssertEqual(hints, ["还有 3 步,向下滚动查看", "还有 1 步,向下滚动查看", "下面还有:详细信息", // i18n-literal: Assert Simplified Chinese overflow hints. "还有 3 行,向下滚动查看", "还有 1 行,向下滚动查看", // i18n-literal: Assert Simplified Chinese overflow hints. - "下面还有 2 部分,向下滚动查看", "下面还有内容,向下滚动查看", nil]) // i18n-literal: Assert Simplified Chinese overflow hints. + "下面还有:分组、详细信息", "下面还有内容,向下滚动查看", nil]) // i18n-literal: Assert Simplified Chinese overflow hints. } } } diff --git a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift index 87265a3..6cd69b5 100644 --- a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift +++ b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift @@ -1,9 +1,11 @@ +import CryptoKit import XCTest import AskKeyBroker @testable import AskKeyAppKit +@testable import AskKeyVault -/// Write cards: plain-language item rows, status tags, the change summary, -/// the value box and what happens after approval. +/// Write cards: plain-language item rows with exact status tags, change +/// summaries that name what changes, and what happens after approval. @MainActor final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { private typealias Fixtures = ApprovalCardFixtures @@ -23,28 +25,26 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { afterUsageInstructions: "Use for release checks.", beforeGroup: "Release Tools", afterGroup: "Release Tools") } - func testCreateListsEveryItemInPlainWordsAndStatesThePermission() { + func testCreateListsEveryItemWithItsValueAndStatesThePermission() { Fixtures.withLanguages { language in let create = content(Fixtures.createSummary) if language == "en" { + XCTAssertEqual(create.itemsHeading, "Items (3, values provided by Claude Code)") XCTAssertEqual(lines(create), [ "TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN", "CERT · 1,234 bytes · given to programs as a temporary file (path in RELEASE_CERT_FILE)", "RECOVERY_CODE · 16 bytes · kept in Ask Key only, never given to agents", ]) - XCTAssertEqual(create.afterApproval, - "The credential's agent permission will be Ask every time: agents need your approval each time they use it.") - XCTAssertEqual(create.valueHeading, "Value to save (provided by Claude Code)") - XCTAssertEqual(appLocalizedFormat("Items (%lld)", create.itemCount), "Items (3)") + XCTAssertEqual(create.consequence, + "Its agent permission will be Ask every time, so agents need your approval for each use.") } else { + XCTAssertEqual(create.itemsHeading, "凭证内容(3 项,值由 Claude Code 提供)") // i18n-literal: Assert the Simplified Chinese items heading. XCTAssertEqual(lines(create), [ "TOKEN · 28 字节 · 使用时作为环境变量 RELEASE_CHECK_TOKEN 交给程序", // i18n-literal: Assert the Simplified Chinese item row. "CERT · 1,234 字节 · 使用时作为临时文件交给程序(路径在 RELEASE_CERT_FILE)", // i18n-literal: Assert the Simplified Chinese item row. "RECOVERY_CODE · 16 字节 · 只存在请旨里,不交给任何 Agent", // i18n-literal: Assert the Simplified Chinese item row. ]) - XCTAssertEqual(create.afterApproval, "这个凭证的 Agent 权限是「每次询问」:Agent 每次使用都要你批准。") // i18n-literal: Assert the Simplified Chinese permission statement. - XCTAssertEqual(create.valueHeading, "要保存的值(由 Claude Code 提供)") // i18n-literal: Assert the Simplified Chinese value heading. - XCTAssertEqual(appLocalizedFormat("Items (%lld)", create.itemCount), "凭证内容(3 项)") // i18n-literal: Assert the Simplified Chinese items heading. + XCTAssertEqual(create.consequence, "这个凭证的 Agent 权限是「每次询问」:Agent 每次使用都要你批准。") // i18n-literal: Assert the Simplified Chinese permission statement. } XCTAssertEqual(create.components.map(\.tag), [nil, nil, nil], "create rows carry no status tags") XCTAssertEqual(create.components.first?.line.segments.filter(\.code).map(\.text), ["RELEASE_CHECK_TOKEN"]) @@ -56,88 +56,127 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { } } - func testMetadataOnlyChangeFoldsUnchangedSectionsIntoTheSummary() { + func testMetadataOnlyChangeNamesWhatChangesAndFoldsTheItems() { Fixtures.withLanguages { language in let modify = content(Fixtures.metadataSummary) - XCTAssertTrue(modify.components.isEmpty, "unchanged items collapse into the summary line") - XCTAssertTrue(modify.valueComponents.isEmpty, "no value box without a new or replaced value") + XCTAssertTrue(modify.components.isEmpty, "unchanged items fold into the summary line") + XCTAssertTrue(modify.valueComponents.isEmpty) XCTAssertEqual(modify.instructionsTag, .changed) XCTAssertEqual(modify.group, .unchanged) XCTAssertFalse(modify.valueOnlyChange) - if language == "en" { - XCTAssertEqual(modify.changeSummary, "Changes: instructions · Unchanged: items, group") - XCTAssertEqual(modify.instructions, .changed(before: "Use for release checks.", after: "Use only for the nightly release check.")) - XCTAssertEqual([appLocalized("Before"), appLocalized("After")], ["Before", "After"]) - } else { - XCTAssertEqual(modify.changeSummary, "会改动:使用说明 · 不变:凭证内容、分组") // i18n-literal: Assert the Simplified Chinese change summary. - XCTAssertEqual([appLocalized("Before"), appLocalized("After")], ["原来", "改为"]) // i18n-literal: Assert Simplified Chinese comparison labels. - } + XCTAssertNil(modify.consequence) + XCTAssertEqual(modify.changeSummary, language == "en" + ? "Changes: instructions for agents · Unchanged: TOKEN, USER, group" + : "会改动:给 Agent 的使用说明 · 不变:TOKEN、USER、分组") // i18n-literal: Assert the Simplified Chinese change summary. } } - func testValueOnlyChangeTagsTheReplacementAndNamesWhoProvidedIt() { + func testValueOnlyChangeTagsTheReplacementAndWarnsAboutTheOldValue() { Fixtures.withLanguages { language in let modify = content(Fixtures.valueSummary) XCTAssertTrue(modify.valueOnlyChange) XCTAssertEqual(modify.components.map(\.tag), [.replaced]) - XCTAssertEqual(modify.componentsTag, .changed) - XCTAssertTrue(modify.overwritesValues) + XCTAssertEqual(modify.components.map(\.overwrites), [true]) XCTAssertEqual(modify.valueComponents.map(\.byteCount), [52]) XCTAssertEqual(modify.instructions, .unchanged) let replacement = modify.components.first?.notes.map(\.plainText) if language == "en" { XCTAssertEqual(lines(modify), ["TOKEN · given to programs as environment variable RELEASE_CHECK_TOKEN"]) - XCTAssertEqual(replacement, ["old value 40 bytes → new value 52 bytes (new value from Claude Code)"]) - XCTAssertEqual(modify.changeSummary, "Changes: items · Unchanged: instructions, group") - XCTAssertEqual(modify.valueHeading, "New value (provided by Claude Code)") - XCTAssertEqual(ApprovalTag.replaced.title, "Replaced") + XCTAssertEqual(replacement, ["Old value 40 bytes → new value 52 bytes (new value from Claude Code)"]) + XCTAssertEqual(modify.changeSummary, "Changes: the value of TOKEN · Unchanged: instructions for agents, group") + XCTAssertEqual(modify.itemsHeading, "Items (1, new values provided by Claude Code)") + XCTAssertEqual(appLocalized("If you approve, the old value is overwritten and can't be recovered."), + "If you approve, the old value is overwritten and can't be recovered.") } else { XCTAssertEqual(replacement, ["旧值 40 字节 → 新值 52 字节(新值由 Claude Code 提供)"]) // i18n-literal: Assert the Simplified Chinese replacement row. - XCTAssertEqual(modify.changeSummary, "会改动:凭证内容 · 不变:使用说明、分组") // i18n-literal: Assert the Simplified Chinese change summary. - XCTAssertEqual(ApprovalTag.replaced.title, "替换") // i18n-literal: Assert the Simplified Chinese tag. + XCTAssertEqual(modify.changeSummary, "会改动:TOKEN 的值 · 不变:给 Agent 的使用说明、分组") // i18n-literal: Assert the Simplified Chinese change summary. + XCTAssertEqual(modify.itemsHeading, "凭证内容(1 项,新值由 Claude Code 提供)") // i18n-literal: Assert the Simplified Chinese items heading. + XCTAssertEqual(appLocalized("If you approve, the old value is overwritten and can't be recovered."), + "批准后旧值会被覆盖,无法找回。") // i18n-literal: Assert the Simplified Chinese overwrite line. } } } - func testAddedItemMarksSameSizeItemsAsPossiblyReplaced() { + func testAddedItemLeavesTheOthersUnchanged() { Fixtures.withLanguages { language in let modify = content(Fixtures.addedSummary) - XCTAssertEqual(modify.components.map(\.tag), [.mayBeReplaced, .mayBeReplaced, .new]) - XCTAssertEqual(modify.valueComponents.map(\.name), ["TOKEN", "USER", "CERT"]) + XCTAssertEqual(modify.components.map(\.tag), [.unchanged, .unchanged, .new]) + XCTAssertEqual(modify.valueComponents.map(\.name), ["CERT"], "only the new value is listed") XCTAssertFalse(modify.valueOnlyChange) - XCTAssertEqual(modify.components.first?.notes.map(\.plainText), [language == "en" - ? "Same size and delivery; Ask Key can't tell whether the value was replaced. Authenticate to view." - : "大小和交付方式没变,无法确认值是否被替换;可验证后查看"]) // i18n-literal: Assert the Simplified Chinese uncertainty line. - XCTAssertEqual([ApprovalTag.mayBeReplaced, .new].map(\.title), language == "en" - ? ["May be replaced", "New"] : ["可能已替换", "新增"]) // i18n-literal: Assert Simplified Chinese tags. + XCTAssertEqual(modify.changeSummary, language == "en" + ? "Changes: CERT added · Unchanged: TOKEN, USER, instructions for agents, group" + : "会改动:新增 CERT · 不变:TOKEN、USER、给 Agent 的使用说明、分组") // i18n-literal: Assert the Simplified Chinese change summary. + XCTAssertEqual([ApprovalTag.unchanged, .new, .replaced].map(\.title), language == "en" + ? ["Unchanged", "New", "Replaced"] : ["不变", "新增", "替换"]) // i18n-literal: Assert Simplified Chinese tags. } } - func testOnlyProvableStatesAreClaimed() { + func testEachItemIsTaggedFromItsOwnValueDigest() { let previous = AppLanguage.current defer { AppLanguage.current = previous } AppLanguage.current = "en" let token = Fixtures.token let user = Fixtures.user - // Equal digests prove nothing changed; the items section collapses. - XCTAssertTrue(content(modification(before: [token], after: [token], beforeDigest: "same", afterDigest: "same")).components.isEmpty) - // A lone same-size item whose digest changed was replaced. - XCTAssertEqual(content(modification(before: [token], after: [token])).components.map(\.tag), [.replaced]) - // Missing digests prove nothing. - XCTAssertEqual(content(modification(before: [token], after: [token], beforeDigest: nil, afterDigest: nil)) - .components.map(\.tag), [.mayBeReplaced]) - // Two same-size items: either may be the replaced one. - XCTAssertEqual(content(modification(before: [token, user], after: [token, user])).components.map(\.tag), - [.mayBeReplaced, .mayBeReplaced]) + let rotated = Fixtures.component("TOKEN", bytes: 28, .environmentVariable("RELEASE_CHECK_TOKEN"), value: "rotated") + XCTAssertEqual(content(modification(before: [token, user], after: [rotated, user])).components.map(\.tag), + [.replaced, .unchanged], "same size and delivery, different value") + XCTAssertTrue(content(modification(before: [token, user], after: [token, user], + beforeDigest: "same", afterDigest: "same")).components.isEmpty) let moved = Fixtures.component("TOKEN", bytes: 28, .temporaryFile("RELEASE_CHECK_FILE")) let changed = content(modification(before: [token, user], after: [moved])) XCTAssertEqual(changed.components.map(\.tag), [.changed, .removed]) - XCTAssertEqual(changed.components.first?.notes.map(\.plainText), [ - "Before: TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN", - "Same size; Ask Key can't tell whether the value was replaced. Authenticate to view.", - ]) - XCTAssertEqual(changed.valueComponents.map(\.name), ["TOKEN"], "a re-sent value may differ, so it can be viewed") - XCTAssertEqual([ApprovalTag.changed, .removed].map(\.title), ["Changed", "Removed"]) + XCTAssertEqual(changed.components.first?.notes.map(\.plainText), + ["Before: TOKEN · 28 bytes · given to programs as environment variable RELEASE_CHECK_TOKEN"]) + XCTAssertTrue(changed.valueComponents.isEmpty, "a new delivery for the same value adds no value to view") + XCTAssertEqual(changed.changeSummary, + "Changes: how TOKEN is given to programs, USER removed · Unchanged: instructions for agents, group") + // Summaries without per-item digests claim a replacement rather than "unchanged". + let legacy = BrokerCredentialComponentSummary(name: "TOKEN", payloadKind: .text, byteCount: 28, + delivery: .environmentVariable("RELEASE_CHECK_TOKEN"), masked: true) + XCTAssertEqual(content(modification(before: [legacy], after: [legacy])).components.map(\.tag), [.replaced]) + } + + func testVaultRotationOfOneSameLengthItemShowsReplacedAndUnchanged() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AskKeyCardDigest-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let vault = Vault(store: try VaultStore(path: directory.appendingPathComponent("vault.db").path), + key: SymmetricKey(data: Data(repeating: 0x42, count: 32))) + try vault.beginManagementSession(using: .allow) + _ = try vault.createBundleCredential(.init(name: "Release Check", components: [ + .init(name: "USER", value: .text("synthetic-user"), delivery: .environmentVariable("RELEASE_USER")), + .init(name: "TOKEN", value: .text("synthetic-token-aaaa"), delivery: .environmentVariable("RELEASE_TOKEN")), + ], usageInstructions: "Use for release checks.", groupName: nil, permission: .ask), using: .allow) + let request = AgentTextWriteRequest(operationID: "rotate", action: .modifyBundle(name: "Release Check", changes: [ + .upsert(.init(name: "TOKEN", value: .text("synthetic-token-bbbb"), delivery: .environmentVariable("RELEASE_TOKEN"))), + ])) + guard case .submitted(let ticket) = try vault.requestAgentTextWrite(request) else { return XCTFail("Expected a pending write") } + let summary = try vault.frozenAgentWriteSummary(operationID: request.operationID, + requestID: ticket.requestID, capability: ticket.capability) + let card = content(summary) + XCTAssertEqual(card.components.map(\.name), ["USER", "TOKEN"]) + XCTAssertEqual(card.components.map(\.tag), [.unchanged, .replaced]) + XCTAssertEqual(card.valueComponents.map(\.name), ["TOKEN"]) + XCTAssertTrue(card.valueOnlyChange) + } + + func testInstructionEditsAreHighlightedWordByWord() { + let diff = ApprovalTextDiff(before: "Use for release checks. Keep values out of logs.", + after: "Use only for the nightly release checks.") + XCTAssertEqual(diff.before.map(\.text).joined(), "Use for release checks. Keep values out of logs.") + XCTAssertEqual(diff.after.map(\.text).joined(), "Use only for the nightly release checks.") + XCTAssertEqual(diff.after.filter { $0.kind == .added }.map(\.text), ["only ", "the nightly "]) + XCTAssertEqual(diff.removedPhrases, ["Keep values out of logs"]) + let chinese = ApprovalTextDiff(before: "只用于发布检查,不要写进日志。", after: "只用于夜间发布检查。") // i18n-literal: Synthetic Chinese instructions. + XCTAssertEqual(chinese.after.filter { $0.kind == .added }.map(\.text), ["夜间"]) // i18n-literal: Synthetic Chinese diff. + XCTAssertEqual(chinese.removedPhrases, ["不要写进日志"]) // i18n-literal: Synthetic Chinese diff. + Fixtures.withLanguages { language in + XCTAssertEqual(appLocalizedFormat("Removed phrases: %@", ApprovalCopy.quote("Keep values out of logs.")), language == "en" + ? "Removed: “Keep values out of logs.”" : "删掉了:「Keep values out of logs.」") // i18n-literal: Assert the Simplified Chinese removed line. + let modify = content(Fixtures.metadataSummary) + XCTAssertEqual(modify.instructionsDiff?.removedPhrases, ["checks"]) + XCTAssertEqual(modify.instructionsDiff?.after.filter { $0.kind == .added }.map(\.text), ["only ", "the nightly ", "check"]) + } } func testDeleteShowsWhatIsDeletedAndWhereItGoes() { @@ -145,21 +184,18 @@ final class FrozenWriteSummaryContentTests: AskKeyAppTestCase { let delete = content(Fixtures.deleteSummary) XCTAssertEqual(delete.components.map(\.tag), [nil, nil]) XCTAssertTrue(delete.valueComponents.isEmpty) - XCTAssertEqual(delete.itemCount, 2) + XCTAssertEqual(delete.itemsHeading, language == "en" ? "Items (2)" : "凭证内容(2 项)") // i18n-literal: Assert the Simplified Chinese items heading. XCTAssertEqual(delete.instructions, .current("Use for release checks.")) - XCTAssertEqual(delete.afterApproval, language == "en" + XCTAssertEqual(delete.consequence, language == "en" ? "It moves to the Recycle Bin for 30 days and can be restored there, then it is removed permanently. Agents can't use or see it meanwhile." : "凭证会移到回收站保留 30 天,期间可以在回收站恢复,之后永久删除。这段时间里 Agent 不能使用,也看不到它。") // i18n-literal: Assert the Simplified Chinese delete statement. - XCTAssertEqual([appLocalized("Instructions for agents"), appLocalized("Group"), appLocalized("After you approve")], - language == "en" ? ["Instructions for agents", "Group", "After you approve"] - : ["给 Agent 的使用说明", "分组", "批准后"]) // i18n-literal: Assert Simplified Chinese section headings. } } func testNewGroupTagIsNeutralAndExplained() { Fixtures.withLanguages { language in - XCTAssertEqual([ApprovalTag.newGroup.title, appLocalized("created when you approve")], language == "en" - ? ["New group", "created when you approve"] : ["新分组", "批准后创建"]) // i18n-literal: Assert the Simplified Chinese new-group tag. + XCTAssertEqual([ApprovalTag.newGroup.title, appLocalized("Created when you approve")], language == "en" + ? ["New group", "Created when you approve"] : ["新分组", "批准后创建"]) // i18n-literal: Assert the Simplified Chinese new-group tag. XCTAssertEqual(ApprovalTag.newGroup.color, Theme.text, "a new group is not a warning") XCTAssertEqual(ApprovalTag.removed.color, Theme.warning) XCTAssertEqual(ApprovalTag.unchanged.color, Theme.textSecondary) diff --git a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift index 61c1397..6de54ad 100644 --- a/Tests/AskKeyAppTests/LocalizationUnificationTests.swift +++ b/Tests/AskKeyAppTests/LocalizationUnificationTests.swift @@ -186,6 +186,9 @@ final class LocalizationUnificationTests: AskKeyAppTestCase { "Quoted name: %@": "“%@”", "Sentence separator": " ", "New group tag": "New group", + "Credential value: %@": "the value of %@", + "Removed phrases: %@": "Removed: %@", + "Change summary: instructions": "instructions for agents", ] XCTAssertEqual(english, allowedAliases[key], "English value must match the key or a declared alias: \(key)") } diff --git a/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift b/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift index 41bc456..0c4ba9d 100644 --- a/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift +++ b/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift @@ -14,7 +14,7 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { func testRegularStepsReadAsSentences() { Fixtures.withLanguages { language in - let content = FrozenOrganizationSummaryContent(summary: Fixtures.regularOrganization) + let content = FrozenOrganizationSummaryContent(summary: Fixtures.regularOrganization, requester: "Claude Code") XCTAssertEqual(content.rows.map(\.number), [1, 2, 3, 4]) XCTAssertEqual(content.rows.map(\.tag), [nil, nil, nil, nil]) XCTAssertNil(content.rows[0].detail, "a new group needs no second line") @@ -28,7 +28,7 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { "Rename the group “Old Services” to “Renamed Services”", "Delete the group “Temp”", ]) - XCTAssertEqual(details, [nil, nil, "It has 3 credentials (2 hidden from agents).", + XCTAssertEqual(details, [nil, nil, "When it's renamed, the group has 3 credentials (2 hidden from agents).", "Its 1 credential won't be deleted and will become “Ungrouped”."]) } else { XCTAssertEqual(titles, [ @@ -37,7 +37,7 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { "把分组「Old Services」改名为「Renamed Services」", // i18n-literal: Assert the Simplified Chinese organize step. "删除分组「Temp」", // i18n-literal: Assert the Simplified Chinese organize step. ]) - XCTAssertEqual(details, [nil, nil, "组里有 3 个凭证(其中 2 个对 Agent 隐藏)。", // i18n-literal: Assert the Simplified Chinese step detail. + XCTAssertEqual(details, [nil, nil, "改名时组里有 3 个凭证(其中 2 个对 Agent 隐藏)。", // i18n-literal: Assert the Simplified Chinese step detail. "组里 1 个凭证不会被删除,会变成「未分组」。"]) // i18n-literal: Assert the Simplified Chinese step detail. } } @@ -45,7 +45,7 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { func testExistingGroupAndMergeStartWithTheirTagsAndStateTheResult() { Fixtures.withLanguages { language in - let content = FrozenOrganizationSummaryContent(summary: Fixtures.existingAndMerge) + let content = FrozenOrganizationSummaryContent(summary: Fixtures.existingAndMerge, requester: "Claude Code") XCTAssertEqual(content.rows.map(\.tag), [.noChange, .merge]) XCTAssertTrue(content.isDestructive, "a merge cannot be undone automatically") if language == "en" { @@ -53,14 +53,16 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { XCTAssertEqual(plain(content.rows[0].title), "Create the group “Existing Private Services”") XCTAssertEqual(content.rows[0].detail, "This group already exists, so nothing is created or changed. It has 1 credential (1 hidden from agents).") - XCTAssertEqual(plain(content.rows[1].title), "Merge the group “Merge Source” into the existing group “Existing Merge Services”") + XCTAssertEqual(plain(content.rows[1].title), + "Claude Code asked to rename “Merge Source” to “Existing Merge Services”; “Existing Merge Services” already exists and is hidden from it, so the groups merge.") XCTAssertEqual(plain(content.rows[1].detail), "“Merge Source” disappears and “Existing Merge Services” will have 4 credentials (3 hidden from agents). A merge can't be undone automatically.") } else { XCTAssertEqual(content.rows.map(\.tag?.title), ["无变化", "合并"]) // i18n-literal: Assert Simplified Chinese organize tags. XCTAssertEqual(content.rows[0].detail, "这个分组已经存在,不会新建或改动任何内容。组里有 1 个凭证(其中 1 个对 Agent 隐藏)。") // i18n-literal: Assert the Simplified Chinese no-op detail. - XCTAssertEqual(plain(content.rows[1].title), "把分组「Merge Source」合并到已有分组「Existing Merge Services」") // i18n-literal: Assert the Simplified Chinese merge step. + XCTAssertEqual(plain(content.rows[1].title), + "Claude Code 请求的是把「Merge Source」改名为「Existing Merge Services」;「Existing Merge Services」已存在且对它隐藏,所以实际会合并。") // i18n-literal: Assert the Simplified Chinese merge step. XCTAssertEqual(plain(content.rows[1].detail), "合并后「Merge Source」消失,「Existing Merge Services」共有 4 个凭证(其中 3 个对 Agent 隐藏)。合并无法自动撤销。") // i18n-literal: Assert the Simplified Chinese merge result. } @@ -74,20 +76,20 @@ final class OrganizationApprovalContentTests: AskKeyAppTestCase { .deleteGroup(name: "Unused", members: 0, nonvisible: 0), .existingGroup(name: "Shared", members: 0, nonvisible: 0), ]) - let details = FrozenOrganizationSummaryContent(summary: summary).rows.map { plain($0.detail) } + let details = FrozenOrganizationSummaryContent(summary: summary, requester: "Claude Code").rows.map { plain($0.detail) } XCTAssertEqual(details, language == "en" - ? ["It has 2 credentials.", "The group is empty.", + ? ["When it's renamed, the group has 2 credentials.", "The group is empty.", "This group already exists, so nothing is created or changed. The group is empty."] - : ["组里有 2 个凭证。", "组里没有凭证。", "这个分组已经存在,不会新建或改动任何内容。组里没有凭证。"]) // i18n-literal: Assert Simplified Chinese member counts. + : ["改名时组里有 2 个凭证。", "组里没有凭证。", "这个分组已经存在,不会新建或改动任何内容。组里没有凭证。"]) // i18n-literal: Assert Simplified Chinese member counts. XCTAssertFalse(FrozenOrganizationSummaryContent(summary: BrokerOrganizationSummary(operations: [ .createGroup("New"), .move(credential: "API", from: nil, to: "New"), - ])).isDestructive) + ]), requester: "Claude Code").isDestructive) } } func testTwelveStepsAreNumberedAndCountedInTitleAndButton() { Fixtures.withLanguages { language in - let content = FrozenOrganizationSummaryContent(summary: Fixtures.twelveSteps) + let content = FrozenOrganizationSummaryContent(summary: Fixtures.twelveSteps, requester: "Claude Code") XCTAssertEqual(content.rows.map(\.number), Array(1...12)) let title = ApprovalPromptContent(request: Fixtures.request(.organize), credentialName: "credential-library", organizationSteps: content.rows.count).title diff --git a/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift b/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift index e7a3e4e..7cd71dd 100644 --- a/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift +++ b/Tests/AskKeyAppTests/WorkspaceInteractionTests.swift @@ -112,7 +112,7 @@ final class WorkspaceInteractionTests: WorkspaceVisualContractTestSupport { let writeActions: [(BrokerApprovalOperation, String, String)] = [ (.create, "新建凭证", "Create Credential"), (.modify, "保存修改", "Save Changes"), - (.delete, "移到回收站", "Move to Trash"), + (.delete, "移到回收站", "Move to Recycle Bin"), ] for (operation, chinese, _) in writeActions { XCTAssertEqual(FrozenApprovalActions.titles(operation: operation, timedAllowanceEnabled: true), diff --git a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift index d680c32..b0f29cf 100644 --- a/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift +++ b/Tests/AskKeyE2ETests/ScreenshotE2ETests.swift @@ -74,14 +74,14 @@ final class ScreenshotE2ETests: E2EBaseCase { waitForCardText("Claude Code wants to use the credential %@", "Staging API") waitForText("Command to run") waitForText("./deploy.sh") - waitForText("If you allow, this command receives") - waitForCardText("%@ · as environment variable STAGING_API_TOKEN", "Staging API") + waitForCardText("If you allow, this command receives 1 item from %@", "Staging API") + waitForText("→ Environment variable STAGING_API_TOKEN") waitForText("Stated purpose (not verified)") waitForText("Deploy the staging site") XCTAssertEqual(app.buttons["approval-allow-once"].label, "Allow Once") XCTAssertEqual(app.buttons["approval-allow-timed"].label, "Allow for 30 Minutes") - waitForText("For 30 minutes, any agent or command run by this macOS user can read this credential", containing: true) - waitForText("Esc to decide later in Pending requests", containing: true) + waitForText("For 30 minutes, any agent or command in your Mac account can read this credential", containing: true) + waitForText("Esc to hide; decide in Pending requests before it expires", containing: true) capture("12-approval-default", approval: true) click("approval-details") waitForText("Runs in") @@ -107,15 +107,14 @@ final class ScreenshotE2ETests: E2EBaseCase { _ = try waitForEvidence("approval-pending.json") XCTAssertTrue(app.buttons["approval-deny"].waitForExistence(timeout: 8)) waitForCardText("E2E Agent wants to create the credential %@", "Staging API") - waitForText("Items (1)") + waitForText("Items (1, values provided by E2E Agent)") waitForText("token · 24 bytes · given to programs as environment variable STAGING_TOKEN") - waitForText("Value to save (provided by E2E Agent)") + XCTAssertTrue(app.buttons["approval-reveal-frozen-material"].exists) waitForText("Instructions for agents") waitForText("Use only for staging API requests.", containing: true) waitForCardText("%@", "Staging Services") XCTAssertTrue(app.descendants(matching: .any)["approval-new-group"].waitForExistence(timeout: 8)) - waitForText("After you approve") - waitForText("The credential's agent permission will be Ask every time", containing: true) + waitForText("Its agent permission will be Ask every time", containing: true) XCTAssertEqual(app.buttons["approval-allow-once"].label, "Create Credential") XCTAssertFalse(app.staticTexts["Before"].exists) XCTAssertFalse(app.staticTexts["After"].exists) @@ -137,7 +136,7 @@ final class ScreenshotE2ETests: E2EBaseCase { waitForCardText("Create the group %@", "Staging Services") waitForCardText("Move the credential %@ from %@ to %@", "Staging API", "Old Services", "Staging Services") waitForCardText("Rename the group %@ to %@", "Old Services", "Renamed Services") - waitForText("It has 3 credentials (2 hidden from agents).", containing: true) + waitForText("When it's renamed, the group has 3 credentials (2 hidden from agents).", containing: true) waitForCardText("Its 3 credentials (2 hidden from agents) won't be deleted and will become %@.", "Ungrouped") XCTAssertFalse(app.buttons["approval-reveal-frozen-material"].exists) XCTAssertFalse(app.buttons["approval-allow-timed"].exists) @@ -148,7 +147,8 @@ final class ScreenshotE2ETests: E2EBaseCase { waitForCardText("Create the group %@", "Existing Private Services") waitForText("This group already exists, so nothing is created or changed. It has 1 credential (1 hidden from agents).", containing: true) - waitForCardText("Merge the group %@ into the existing group %@", "Merge Source", "Existing Merge Services") + waitForCardText("E2E Agent asked to rename %@ to %@; %@ already exists and is hidden from it, so the groups merge.", + "Merge Source", "Existing Merge Services", "Existing Merge Services") waitForCardText("%@ disappears and %@ will have 4 credentials (3 hidden from agents). A merge can't be undone automatically.", "Merge Source", "Existing Merge Services") XCTAssertTrue(app.buttons["approval-allow-once"].isHittable) diff --git a/Tests/AskKeyVaultTests/AgentWriteComponentDigestTests.swift b/Tests/AskKeyVaultTests/AgentWriteComponentDigestTests.swift new file mode 100644 index 0000000..cee08f8 --- /dev/null +++ b/Tests/AskKeyVaultTests/AgentWriteComponentDigestTests.swift @@ -0,0 +1,71 @@ +import CryptoKit +import XCTest +import AskKeyBroker +@testable import AskKeyVault + +/// The App-side write summary carries one keyed value digest per component so +/// an approval card can tag each item exactly; it never leaves the App. +final class AgentWriteComponentDigestTests: AgentTextWriteTestSupport { + private func createReleaseCheck(_ harness: Harness) throws { + _ = try harness.vault.createBundleCredential(.init(name: "Release Check", components: [ + .init(name: "USER", value: .text("synthetic-user"), delivery: .environmentVariable("RELEASE_USER")), + .init(name: "TOKEN", value: .text("synthetic-token-aaaa"), delivery: .environmentVariable("RELEASE_TOKEN")), + .init(name: "CERT", value: .file(filename: "cert.pem", bytes: Data("synthetic-certificate".utf8)), + delivery: .temporaryFile("RELEASE_CERT_FILE")), + ], usageInstructions: "Use for release checks.", groupName: "Release Tools", permission: .ask), using: .allow) + } + + func testRotatingOneSameLengthComponentChangesOnlyItsDigest() throws { + let harness = try makeHarness { _ in true } + try createReleaseCheck(harness) + let rotated = "synthetic-token-bbbb" + let request = AgentTextWriteRequest(operationID: "rotate-token", action: .modifyBundle(name: "Release Check", changes: [ + .upsert(.init(name: "TOKEN", value: .text(rotated), delivery: .environmentVariable("RELEASE_TOKEN"))), + ])) + let ticket = try submitted(harness.vault.requestAgentTextWrite(request)) + let summary = try harness.vault.frozenAgentWriteSummary(operationID: request.operationID, + requestID: ticket.requestID, capability: ticket.capability) + + // Size and delivery alone cannot tell the rotated item apart. + XCTAssertEqual(summary.before.map(\.name), ["USER", "TOKEN", "CERT"]) + XCTAssertEqual(summary.after.map(\.name), ["USER", "TOKEN", "CERT"]) + XCTAssertEqual(summary.before.map(\.byteCount), summary.after.map(\.byteCount)) + XCTAssertEqual(summary.before.map(\.delivery), summary.after.map(\.delivery)) + XCTAssertNotEqual(summary.beforeDigest, summary.afterDigest) + + let before = summary.before.map(\.valueDigest) + let after = summary.after.map(\.valueDigest) + XCTAssertTrue((before + after).allSatisfy { $0?.count == 64 }) + XCTAssertEqual(before[0], after[0], "USER is unchanged") + XCTAssertNotEqual(before[1], after[1], "TOKEN is replaced") + XCTAssertEqual(before[2], after[2], "CERT is unchanged") + + // Keyed by the vault, so a leaked digest would not confirm a guessed value. + let plain = SHA256.hash(data: Data(rotated.utf8)).map { String(format: "%02x", $0) }.joined() + XCTAssertFalse(after.contains(plain)) + // The Broker reply carries the ticket only. + let reply = try encoded(BrokerResponse.success(.textWriteRequest(.submitted(ticket)))) + for digest in (before + after).compactMap({ $0 }) { + XCTAssertFalse(reply.contains(digest)) + } + } + + func testDigestsAreStableAcrossFreezesAndIgnoreDeliveryChanges() throws { + let harness = try makeHarness { _ in true } + try createReleaseCheck(harness) + func summary(_ operationID: String, delivery: BrokerComponentDelivery) throws -> BrokerCredentialWriteSummary { + let request = AgentTextWriteRequest(operationID: operationID, action: .modifyBundle(name: "Release Check", changes: [ + .upsert(.init(name: "USER", value: .text("synthetic-user"), delivery: delivery)), + ])) + let ticket = try submitted(harness.vault.requestAgentTextWrite(request)) + return try harness.vault.frozenAgentWriteSummary(operationID: operationID, + requestID: ticket.requestID, capability: ticket.capability) + } + let same = try summary("same-user", delivery: .environmentVariable("RELEASE_USER")) + let moved = try summary("moved-user", delivery: .environmentVariable("RELEASE_LOGIN")) + XCTAssertEqual(same.before.map(\.valueDigest), same.after.map(\.valueDigest), "re-sending a value keeps its digest") + XCTAssertEqual(same.before.map(\.valueDigest), moved.before.map(\.valueDigest), "digests are stable across freezes") + XCTAssertEqual(moved.before[0].valueDigest, moved.after[0].valueDigest, "a new delivery does not change the value") + XCTAssertNotEqual(moved.before[0].delivery, moved.after[0].delivery) + } +}