diff --git a/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift new file mode 100644 index 0000000..8578dcc --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalCardComponents.swift @@ -0,0 +1,169 @@ +import AppKit +import SwiftUI + +/// 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 + let tag: ApprovalTag? + let name: String? + let identifier: String + 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) { + HStack(alignment: .firstTextBaseline, spacing: Theme.Spacing.xs) { + 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) + .foregroundStyle(Theme.text) + } + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .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 + + 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, .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..6d4e725 --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalCopy.swift @@ -0,0 +1,119 @@ +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 { + 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; + /// 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, 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 .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..070abb8 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,93 +11,90 @@ 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: 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] - 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 { + let object = request.operation == .modify && valueOnlyChange + ? ApprovalCopy.credentialValue(credentialName) : ApprovalCopy.quoted(credentialName) + title = EmphasizedSentence(format: Self.titleFormat(request.operation, valueOnlyChange: valueOnlyChange), + arguments: [requester, object]).plainText } let display = request.operation == .read ? request.display : nil - commandSummary = display?.commandSummary - fullCommand = display?.commandLine + command = display?.commandLine + (receivesHeading, 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 %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 { 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.") } - 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) -> (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 (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("→ 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) + } } } diff --git a/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift new file mode 100644 index 0000000..0a70710 --- /dev/null +++ b/Sources/AskKeyAppKit/App/ApprovalScrollArea.swift @@ -0,0 +1,221 @@ +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. 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 } + 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) + } + if unit == .steps, hiddenSteps > 0 { + return hiddenSteps == 1 + ? appLocalized("1 more step — 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"))) + } +} + +/// 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: [ApprovalScrollMarkerValue] = [] + + 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? { + 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 { + 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).lineLimit(1).truncationMode(.tail).minimumScaleFactor(0.8) + } + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("approval-overflow-hint") + } +} + +extension View { + /// 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: [ApprovalScrollMarkerValue(maxY: geometry.frame(in: .named($0)).maxY, name: name, step: step)]] + } ?? [:]) + }) + } +} + +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: [ApprovalScrollMarkerValue]] = [:] + static func reduce(value: inout [String: [ApprovalScrollMarkerValue]], + nextValue: () -> [String: [ApprovalScrollMarkerValue]]) { + 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 + } + } + + /// 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] { + 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) + } + return heights + } +} + +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 da5f261..d56dbe6 100644 --- a/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift +++ b/Sources/AskKeyAppKit/App/FrozenAgentApprovalPrompt.swift @@ -2,10 +2,18 @@ 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 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))) let request: BrokerApprovalOperationRequest var trustedCredentialName: String? = nil @@ -15,47 +23,37 @@ 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(summary: $0, requester: requester) } : 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) { + 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 { Text(content.cancelledAuthenticationNote) .font(Theme.Fonts.secondary) @@ -65,32 +63,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, consequence: write?.consequence) .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,126 +77,188 @@ struct FrozenAgentApprovalPrompt: View { .environment(\.locale, AppLanguage.store.locale) } - private var appIcon: some View { + /// 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) - .frame(width: 64, height: 64) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: compact ? 40 : 64, maxHeight: compact ? 40 : 64) .accessibilityHidden(true) } - private func detailsToggle(expanded: Bool) -> some View { - Button { - showsDetails = !expanded - } label: { - HStack(spacing: Theme.Spacing.xs) { - Text(appLocalized("Details")).font(Theme.Fonts.body) - Image(systemName: expanded ? "chevron.down" : "chevron.right") - .font(Theme.Fonts.caption.weight(.semibold)) - .imageScale(.small) + 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?, + 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: 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 + ApprovalLineText(line: line) + } + } + } + } + } + if let purpose = content.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) + } + } + 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) } - .foregroundStyle(Theme.accent) - .contentShape(Rectangle()) } - .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) - 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.body) - .foregroundStyle(Theme.text) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + 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)) + } + } + + private func details(_ rows: [ApprovalPromptContent.Row], proxy: ScrollViewProxy) -> some View { + let expanded = showsDetails ?? detailsExpanded + 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()) + } + .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.body) } + .approvalScrollMarker(name: appLocalized("Details")) } - private var actions: some View { - VStack(spacing: Theme.Spacing.sm) { + /// 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: content.retryTitle, primary: true) { finish(retry) } - .disabled(request.operation == .organize && organizationSummary == nil) - .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: primaryTitle, primary: true) { finish(.once) } - .disabled(request.operation == .organize && organizationSummary == nil) - .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") + explained(consequence) { + ApprovalPromptButton(title: primary.title, role: primary.role) { finish(.once) } + .disabled(unavailable) + .accessibilityIdentifier("approval-allow-once") + } + if isRead, timedAllowanceEnabled { + explained(timedScope) { + ApprovalPromptButton(title: FrozenApprovalActions.timed(minutes: timedAllowanceMinutes), + role: .secondary) { finish(.timedAllow(duration: nil)) } + .accessibilityIdentifier("approval-allow-timed") + } } } - ApprovalPromptButton(title: appLocalized("Deny"), primary: false) { finish(.deny) } + ApprovalPromptButton(title: appLocalized("Deny"), role: .secondary) { finish(.deny) } .accessibilityIdentifier("approval-deny") } } + 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 { - 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 %@ 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) } - 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 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 8475e4a..6c88099 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 Recycle Bin"), 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 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. + 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..650cdf5 100644 --- a/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenOrganizationApprovalContent.swift @@ -1,88 +1,115 @@ 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) { + init(summary: BrokerOrganizationSummary, requester: String) { 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 = 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("%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)), + 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(step: true) + .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..dbf53f8 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,113 +13,204 @@ 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) } + instructions(content) + group(content) + } else if operation == .create || operation == .modify { + // Without the summary the value can still be viewed before deciding. + ApprovalSection(title: appLocalized("Items"), identifier: "approval-components", + accessory: { revealButton }) { valueDetails } } } - .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")) - .font(Theme.Fonts.caption.weight(.semibold)) - if let revealedMaterial { + private func items(_ content: FrozenWriteSummaryContent) -> some View { + 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 + 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 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) - Button(appLocalized("Hide")) { self.revealedMaterial = nil } - }.font(Theme.Fonts.caption) - ScrollView { + } + .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) - .frame(maxWidth: .infinity, alignment: .leading) - }.frame(height: 85) - } else { - HStack { - Text("••••••••").foregroundStyle(Theme.textSecondary) - Spacer() - Button(appLocalized("Authenticate and View"), action: reveal) - .disabled(revealMaterial == nil || revealing) - .accessibilityIdentifier("approval-reveal-frozen-material") + .fixedSize(horizontal: false, vertical: true) } - Text(appLocalized("Viewing requires separate authentication and does not approve this request.")) - .font(Theme.Fonts.caption).foregroundStyle(Theme.textSecondary) } - 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) + .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) + } + } + + @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") { + 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") + } + } } } - .padding(Theme.Spacing.md) - .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) + /// 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 } - .frame(maxHeight: 60) - .accessibilityIdentifier("approval-usage-instructions") - Text(appLocalized("Group")) - .font(Theme.Fonts.caption.weight(.semibold)) - ScrollView { - summaryRows(content.group) + result += part + }) + } + + @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) } - .frame(maxHeight: 30) - .accessibilityIdentifier("approval-group-changes") - if content.createsGroup { - Text(appLocalized("New group — created when approved")) - .foregroundStyle(Theme.warning) - .accessibilityIdentifier("approval-new-group") + 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)) } } - .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) + 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") } - }.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) + 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 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..bd950ee 100644 --- a/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift +++ b/Sources/AskKeyAppKit/App/FrozenWriteSummaryContent.swift @@ -1,61 +1,197 @@ +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] + /// 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 } - 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: 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 itemsHeading: String + let instructions: Change + let instructionsTag: ApprovalTag? + let instructionsDiff: ApprovalTextDiff? + let group: Change + let groupTag: ApprovalTag? let createsGroup: Bool + /// What approving does, stated under the primary button. + let consequence: String? + let valueOnlyChange: Bool + + 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, 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 + 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) + } - 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) + 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 + 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(components, instructions: instructionsChanged, group: groupChanged) : nil + valueOnlyChange = modify && componentsChanged && !instructionsChanged && !groupChanged + && components.allSatisfy { $0.tag == .unchanged || $0.tag == .replaced } + switch summary.operation { + case .create: + consequence = appLocalized("Its agent permission will be Ask every time, so agents need your approval for each use.") + case .delete: + 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: + consequence = nil + } } - 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)] + /// 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 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, 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 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, 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))] + 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"))) + } + return Component(name: after.name, tag: .changed, line: line(after), notes: notes, + 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 [.init(label: appLocalized("Before"), values: before), - .init(label: appLocalized("After"), values: after)] + 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, + overwrites: false, byteCount: $0.byteCount, change: appLocalizedFormat("%@ removed", $0.name)) + } + 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()) + } + + /// 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") + if changed.isEmpty { return appLocalized("Nothing changes") } + 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 { + 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..8d249ba 100644 --- a/Sources/AskKeyAppKit/Resources/Localizable.xcstrings +++ b/Sources/AskKeyAppKit/Resources/Localizable.xcstrings @@ -1,141 +1,796 @@ { "sourceLanguage": "en", "strings": { - "Group already exists — no changes. %@": { + "%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": "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": "%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.!!]" } } } }, - "Merge group “%@” into existing group “%@”": { + "%1$@ wants to replace %2$@": { "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$@ wants to replace %2$@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@ 想替换凭证%2$@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%1$@ wants to replace %2$@!!]" } } } }, - "Source: %@. Existing group: %@.": { + "%@ added": { "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": "%@ added" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "新增 %@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ added!!]" } } } }, - "“%@” wants to organize credentials": { + "%@ removed": { "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": "%@ removed" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "移除 %@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ removed!!]" } } } }, - "%@ wants to organize credentials": { + "Below: %@": { "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": "Below: %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下面还有:%@" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Below: %@!!]" } } } }, - "Approve Organization": { + "Created when you approve": { "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": "Created when you approve" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "批准后创建" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Created when you approve!!]" } } } }, - "Proposed organization": { + "Credential value: %@": { "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": "the value of %@" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!the value of %@!!]" } } } }, - "Move %@: %@ → %@": { + "Esc to hide; decide in Pending requests before it expires": { "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": "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!!]" } } } }, - "Create group “%@”": { + "Expires in %@ and nothing is handed over": { "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": "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!!]" } } } }, - "Rename group “%@” → “%@”": { + "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": "Rename group “%@” → “%@”" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "重命名分组“%@” → “%@”" } }, - "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Rename group “%@” → “%@”!!]" } } + "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.!!]" } } } }, - "Delete group “%@”": { + "Hidden value": { "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": "Hidden value" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已隐藏的值" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Hidden value!!]" } } } }, - "%lld credential": { + "If you allow, this command receives %1$lld items from %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": "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$@!!]" } } } }, - "%lld credentials": { + "If you allow, this command receives 1 item from %@": { "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": "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 %@!!]" } } } }, - "%lld credential, %lld not visible to agents": { + "If you approve, the old value is overwritten and can't be recovered.": { "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": "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.!!]" } } } }, - "%lld credentials, %lld not visible to agents": { + "Items": { "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": "Items" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "凭证内容" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Items!!]" } } } }, - "Unchanged": { + "Items (%1$lld, new values provided by %2$@)": { "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": "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": { + "en": { "stringUnit": { "state": "translated", "value": "Reached the end" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已经到底" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Reached the end!!]" } } + } + }, + "%1$@ (%2$lld hidden from agents)": { + "extractionState": "manual", + "localizations": { + "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)!!]" } } + } + }, + "%1$@ disappears and %2$@ will have %3$@.": { + "extractionState": "manual", + "localizations": { + "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$@.!!]" } } + } + }, + "%1$@ wants to change the credential %2$@": { + "extractionState": "manual", + "localizations": { + "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$@!!]" } } + } + }, + "%1$@ wants to create the credential %2$@": { + "extractionState": "manual", + "localizations": { + "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$@!!]" } } + } + }, + "%1$@ wants to delete the credential %2$@": { + "extractionState": "manual", + "localizations": { + "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$@!!]" } } + } + }, + "%1$@ wants to reorganize your groups (%2$lld steps)": { + "extractionState": "manual", + "localizations": { + "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)!!]" } } + } + }, + "%1$@ wants to use the credential %2$@": { + "extractionState": "manual", + "localizations": { + "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$@!!]" } } + } + }, + "%1$@ wants to use the credential %2$@ to run %3$@": { + "extractionState": "manual", + "localizations": { + "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$@!!]" } } + } + }, + "%@ (name provided by the requester; Ask Key can't verify it)": { + "extractionState": "manual", + "localizations": { + "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)!!]" } } + } + }, + "%@ bytes": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%@ bytes" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%@ 字节" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!%@ bytes!!]" } } + } + }, + "%@ wants to reorganize your groups": { + "extractionState": "manual", + "localizations": { + "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!!]" } } + } + }, + "%@ wants to reorganize your groups (1 step)": { + "extractionState": "manual", + "localizations": { + "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 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 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.!!]" } } + } + }, + "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: %@!!]" } } } }, - "New group — created when approved": { + "Command to run": { "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": "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 %@!!]" } } + } + }, + "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$@.!!]" } } + } + }, + "Merge": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Merge" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "合并" } }, + "qps-ploc": { "stringUnit": { "state": "translated", "value": "[!!Merge!!]" } } + } + }, + "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$@!!]" } } + } + }, + "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!!]" } } + } + }, + "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!!]" } } + } + }, + "Replaced": { + "extractionState": "manual", + "localizations": { + "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!!]" } } + } + }, + "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)!!]" } } + } + }, + "Its agent permission will be Ask every time, so agents need your approval for each use.": { + "extractionState": "manual", + "localizations": { + "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": "[!!Its agent permission will be Ask every time, so agents need your approval for each use.!!]" } } + } + }, + "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.!!]" } } + } + }, + "Saved as is if you approve. Viewing needs another authentication; it doesn't approve.": { + "extractionState": "manual", + "localizations": { + "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": "[!!Saved as is if you approve. Viewing needs another authentication; it doesn't 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!!]" } } + } + }, + "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!!]" } } + } + }, + "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 +1299,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 +2113,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 +2121,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 +2769,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "修改后" + "value": "改为" } }, "qps-ploc": { @@ -2657,7 +3223,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Allow once" + "value": "Allow Once" } }, "zh-Hans": { @@ -2743,209 +3309,117 @@ } } }, - "App": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "App" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!App!!]" - } - } - } - }, - "App ID, client secret, and private key file": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "App ID, client secret, and private key file" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "App ID、客户端密钥和私钥文件" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!App ID, client secret, and private key file!!]" - } - } - } - }, - "Appearance": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Appearance" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "外观" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Appearance!!]" - } - } - } - }, - "Apple Signing": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Apple Signing" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Apple 发布签名" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Apple Signing!!]" - } - } - } - }, - "Apple signing": { + "App": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Apple signing" + "value": "App" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Apple 签名" + "value": "应用" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!Apple signing!!]" + "value": "[!!App!!]" } } } }, - "Applies to all local callers for this credential · Revocable anytime": { + "App ID, client secret, and private key file": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Applies to all local callers for this credential · Revocable anytime" + "value": "App ID, client secret, and private key file" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "对这份凭证的所有本地调用方生效 · 可随时收回" + "value": "App ID、客户端密钥和私钥文件" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!Applies to all local callers for this credential · Revocable anytime!!]" + "value": "[!!App ID, client secret, and private key file!!]" } } } }, - "Approve Change": { + "Appearance": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Approve Change" + "value": "Appearance" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "批准修改" + "value": "外观" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!Approve Change!!]" + "value": "[!!Appearance!!]" } } } }, - "Approve Creation": { + "Apple Signing": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Approve Creation" + "value": "Apple Signing" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "批准创建" + "value": "Apple 发布签名" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!Approve Creation!!]" + "value": "[!!Apple Signing!!]" } } } }, - "Approve Deletion": { + "Apple signing": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Approve Deletion" + "value": "Apple signing" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "批准删除" + "value": "Apple 签名" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!Approve Deletion!!]" + "value": "[!!Apple signing!!]" } } } @@ -3686,29 +4160,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 +4287,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "修改前" + "value": "原来" } }, "qps-ploc": { @@ -5101,7 +5552,7 @@ "zh-Hans": { "stringUnit": { "state": "translated", - "value": "创建凭证" + "value": "新建凭证" } }, "qps-ploc": { @@ -6262,29 +6713,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 +7633,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 +11292,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 +13548,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": { @@ -13534,29 +13893,6 @@ } } }, - "Viewing requires separate authentication and does not approve this request.": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Viewing requires separate authentication and does not approve this request." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "查看需要独立验证,不会批准本次请求。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!Viewing requires separate authentication and does not approve this request.!!]" - } - } - } - }, "Webhook": { "extractionState": "extracted_with_value", "localizations": { @@ -13906,387 +14242,272 @@ "en": { "stringUnit": { "state": "translated", - "value": "denied" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已拒绝" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!denied!!]" - } - } - } - }, - "env.import.duplicateKey": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The .env file contains a duplicate variable name." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": ".env 文件中存在重复的变量名。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!The .env file contains a duplicate variable name.!!]" - } - } - } - }, - "env.import.invalidEscape": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The .env file contains an unsupported escape sequence." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": ".env 文件中存在不支持的转义写法。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!The .env file contains an unsupported escape sequence.!!]" - } - } - } - }, - "env.import.invalidKey": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The .env file contains an invalid variable name." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": ".env 文件中存在无效的变量名。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!The .env file contains an invalid variable name.!!]" - } - } - } - }, - "env.import.invalidLine": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The .env file contains an invalid assignment." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": ".env 文件中存在无效的赋值语句。" - } - }, - "qps-ploc": { - "stringUnit": { - "state": "translated", - "value": "[!!The .env file contains an invalid assignment.!!]" - } - } - } - }, - "env.import.multilineValue": { - "extractionState": "extracted_with_value", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Multiline quoted values are not supported in .env imports." + "value": "denied" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": ".env 导入暂不支持跨行的引号值。" + "value": "已拒绝" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!Multiline quoted values are not supported in .env imports.!!]" + "value": "[!!denied!!]" } } } }, - "env.import.trailingCharacters": { + "env.import.duplicateKey": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The .env file contains unexpected text after a quoted value." + "value": "The .env file contains a duplicate variable name." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": ".env 文件的引号后存在多余内容。" + "value": ".env 文件中存在重复的变量名。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!The .env file contains unexpected text after a quoted value.!!]" + "value": "[!!The .env file contains a duplicate variable name.!!]" } } } }, - "env.import.unterminatedQuote": { + "env.import.invalidEscape": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The .env file contains an unterminated quoted value." + "value": "The .env file contains an unsupported escape sequence." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": ".env 文件中存在未闭合的引号。" + "value": ".env 文件中存在不支持的转义写法。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!The .env file contains an unterminated quoted value.!!]" + "value": "[!!The .env file contains an unsupported escape sequence.!!]" } } } }, - "read": { + "env.import.invalidKey": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "read" + "value": "The .env file contains an invalid variable name." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "读取" + "value": ".env 文件中存在无效的变量名。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!read!!]" + "value": "[!!The .env file contains an invalid variable name.!!]" } } } }, - "the AskKey CLI": { + "env.import.invalidLine": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "the Ask Key CLI" + "value": "The .env file contains an invalid assignment." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Ask Key 命令行" + "value": ".env 文件中存在无效的赋值语句。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!the Ask Key CLI!!]" + "value": "[!!The .env file contains an invalid assignment.!!]" } } } }, - "updated": { + "env.import.multilineValue": { "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "updated" + "value": "Multiline quoted values are not supported in .env imports." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "更新" + "value": ".env 导入暂不支持跨行的引号值。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!updated!!]" + "value": "[!!Multiline quoted values are not supported in .env imports.!!]" } } } }, - "You cancelled authentication. Nothing was changed, and the request is still pending.": { - "extractionState": "manual", + "env.import.trailingCharacters": { + "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You cancelled authentication. Nothing was changed, and the request is still pending." + "value": "The .env file contains unexpected text after a quoted value." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "你取消了验证,没有做任何修改,请求仍在等待。" + "value": ".env 文件的引号后存在多余内容。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!You cancelled authentication. Nothing was changed, and the request is still pending.!!]" + "value": "[!!The .env file contains unexpected text after a quoted value.!!]" } } } }, - "You cancelled authentication. The credential was not delivered, and the request is still pending.": { - "extractionState": "manual", + "env.import.unterminatedQuote": { + "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You cancelled authentication. The credential was not delivered, and the request is still pending." + "value": "The .env file contains an unterminated quoted value." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "你取消了验证,凭证没有交付,请求仍在等待。" + "value": ".env 文件中存在未闭合的引号。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!You cancelled authentication. The credential was not delivered, and the request is still pending.!!]" + "value": "[!!The .env file contains an unterminated quoted value.!!]" } } } }, - "“%@” wants to create “%@”": { - "extractionState": "manual", + "read": { + "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "“%@” wants to create “%@”" + "value": "read" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "“%@”想创建“%@”" + "value": "读取" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!“%@” wants to create “%@”!!]" + "value": "[!!read!!]" } } } }, - "“%@” wants to delete “%@”": { - "extractionState": "manual", + "the AskKey CLI": { + "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "“%@” wants to delete “%@”" + "value": "the Ask Key CLI" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "“%@”想删除“%@”" + "value": "Ask Key 命令行" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!“%@” wants to delete “%@”!!]" + "value": "[!!the Ask Key CLI!!]" } } } }, - "“%@” wants to modify “%@”": { - "extractionState": "manual", + "updated": { + "extractionState": "extracted_with_value", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "“%@” wants to modify “%@”" + "value": "updated" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "“%@”想修改“%@”" + "value": "更新" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!“%@” wants to modify “%@”!!]" + "value": "[!!updated!!]" } } } }, - "“%@” wants to use “%@”": { + "You cancelled authentication. Nothing was changed, and the request is still pending.": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "“%@” wants to use “%@”" + "value": "You cancelled authentication. Nothing was changed, and the request is still pending." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "“%@”想使用“%@”" + "value": "你取消了验证,没有做任何修改,请求仍在等待。" } }, "qps-ploc": { "stringUnit": { "state": "translated", - "value": "[!!“%@” wants to use “%@”!!]" + "value": "[!!You cancelled authentication. Nothing was changed, and the request is still pending.!!]" } } } @@ -14567,29 +14788,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 +14995,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 +15041,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 +15064,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 +15076,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/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 new file mode 100644 index 0000000..d42dead --- /dev/null +++ b/Tests/AskKeyAppTests/ApprovalCardFixtures.swift @@ -0,0 +1,190 @@ +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, createOneItem, createTwoItems, 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) + } + + /// 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, 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")) + 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 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.", + 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"), value: "rotated")], + 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 .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: + 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..1173822 --- /dev/null +++ b/Tests/AskKeyAppTests/ApprovalCardLayoutTests.swift @@ -0,0 +1,130 @@ +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 testEveryCardTypeStaysWithinTheCapInBothLanguages() { + XCTAssertLessThanOrEqual(FrozenAgentApprovalPrompt.maximumHeight, 680) + 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") + 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/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..babd03e 100644 --- a/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift +++ b/Tests/AskKeyAppTests/ApprovalPromptContentTests.swift @@ -1,160 +1,205 @@ -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. + // 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. } } - 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(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. + 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", "Stated purpose (not verified)", "Details"].map { appLocalized($0) } + XCTAssertEqual(headings, language == "en" + ? ["Command to run", "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: ["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, [ + ["→ 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, [ + ["→ 环境变量 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") } - 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 Recycle Bin", "Deny"], ["Apply 4 Steps", "Deny"], ["Apply 1 Step", "Deny"], + ]) + XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), + "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. + ["新建凭证", "拒绝"], ["保存修改", "拒绝"], ["替换值", "拒绝"], // i18n-literal: Assert Simplified Chinese write buttons. + ["移到回收站", "拒绝"], ["执行这 4 步", "拒绝"], ["执行这 1 步", "拒绝"], // i18n-literal: Assert Simplified Chinese write buttons. + ]) + XCTAssertEqual(FrozenApprovalActions.timedScope(minutes: 30), + "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) + 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 Recycle Bin"] + : ["重新验证,允许本次", "重新验证,30 分钟内都允许", "重新验证,移到回收站"]) // i18n-literal: Assert Simplified Chinese retry buttons. + XCTAssertEqual(content(.read).cancelledAuthenticationNote, language == "en" + ? "You cancelled authentication. Nothing was given to the command, 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 %@ 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, 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 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", + "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. + "还有 3 行,向下滚动查看", "还有 1 行,向下滚动查看", // i18n-literal: Assert Simplified Chinese overflow hints. + "下面还有:分组、详细信息", "下面还有内容,向下滚动查看", 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..6cd69b5 100644 --- a/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift +++ b/Tests/AskKeyAppTests/FrozenWriteSummaryContentTests.swift @@ -1,116 +1,225 @@ +import CryptoKit import XCTest import AskKeyBroker @testable import AskKeyAppKit +@testable import AskKeyVault +/// 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 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 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.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.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"]) + 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 testMetadataOnlyChangeNamesWhatChangesAndFoldsTheItems() { + Fixtures.withLanguages { language in + let modify = content(Fixtures.metadataSummary) + 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) + 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 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 testValueOnlyChangeTagsTheReplacementAndWarnsAboutTheOldValue() { + Fixtures.withLanguages { language in + let modify = content(Fixtures.valueSummary) + XCTAssertTrue(modify.valueOnlyChange) + XCTAssertEqual(modify.components.map(\.tag), [.replaced]) + 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: 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, "会改动: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 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 testAddedItemLeavesTheOthersUnchanged() { + Fixtures.withLanguages { language in + let modify = content(Fixtures.addedSummary) + 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.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 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 testEachItemIsTaggedFromItsOwnValueDigest() { + let previous = AppLanguage.current + defer { AppLanguage.current = previous } + AppLanguage.current = "en" + let token = Fixtures.token + let user = Fixtures.user + 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"]) + 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 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 testDeleteShowsWhatIsDeletedAndWhereItGoes() { + Fixtures.withLanguages { language in + let delete = content(Fixtures.deleteSummary) + XCTAssertEqual(delete.components.map(\.tag), [nil, nil]) + XCTAssertTrue(delete.valueComponents.isEmpty) + 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.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. } } - 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 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 testMissingValueDigestsDoNotClaimComponentsAreUnchanged() { - withLanguages { copy in - let content = FrozenWriteSummaryContent(summary: modification(beforeDigest: nil, afterDigest: nil)) - XCTAssertEqual(content.components.map(\.label), [copy.before, copy.after]) + 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. + } + } +} + +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..6de54ad 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,12 @@ 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", + "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 3193b6a..0c4ba9d 100644 --- a/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift +++ b/Tests/AskKeyAppTests/OrganizationApprovalContentTests.swift @@ -1,137 +1,137 @@ -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, 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") + 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, "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(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, requester: "Claude Code") + 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), + "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), + "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. } - 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, requester: "Claude Code").rows.map { plain($0.detail) } + XCTAssertEqual(details, language == "en" + ? ["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. + XCTAssertFalse(FrozenOrganizationSummaryContent(summary: BrokerOrganizationSummary(operations: [ + .createGroup("New"), .move(credential: "API", from: nil, to: "New"), + ]), requester: "Claude Code").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, 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 + 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..7cd71dd 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 Recycle Bin"), ] 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..b0f29cf 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") + 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 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") + 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,16 @@ 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, values provided by E2E Agent)") + waitForText("token · 24 bytes · given to programs as environment variable STAGING_TOKEN") + XCTAssertTrue(app.buttons["approval-reveal-frozen-material"].exists) + 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("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) XCTAssertFalse(app.staticTexts["Unchanged"].exists) @@ -110,20 +131,26 @@ 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("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) 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("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) 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)) 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) + } +}