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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Sources/AskKeyAppKit/App/AppDelegate+Approval.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ extension AppDelegate {
) {
var finished = false
var privacyTimer: Timer?
var resizeObserver: NSObjectProtocol?
let contentSize = NSSize(width: FrozenAgentApprovalPrompt.width, height: 360)
let panel = AgentApprovalPanelFactory.make(contentSize: contentSize)
panel.titleVisibility = .hidden
Expand All @@ -156,6 +157,8 @@ extension AppDelegate {
finished = true
privacyTimer?.invalidate()
privacyTimer = nil
resizeObserver.map(NotificationCenter.default.removeObserver)
resizeObserver = nil
panel.orderOut(nil)
panel.contentViewController = nil
let valid = Self.screenState() == .unlocked && expiresAt.map({ $0 > Date() }) != false
Expand Down Expand Up @@ -236,6 +239,14 @@ extension AppDelegate {
panel.contentViewController = hosting
panel.setContentSize(hosting.view.fittingSize)
panel.center()
// Expanding Details makes the card taller; keep its buttons on screen.
resizeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didResizeNotification, object: panel, queue: .main
) { [weak panel] _ in
MainActor.assumeIsolated {
if let panel { Self.keepInsideVisibleFrame(panel) }
}
}
// Common modes keep the check running during menus, drags and modal pickers.
privacyTimer = Timer(timeInterval: 0.25, repeats: true) { _ in
MainActor.assumeIsolated {
Expand All @@ -253,6 +264,15 @@ extension AppDelegate {
panel.makeKeyAndOrderFront(nil)
}

/// Moves the panel just enough to fit the screen's visible frame, keeping
/// its top visible when it is taller than the screen.
static func keepInsideVisibleFrame(_ panel: NSPanel) {
guard let visible = (panel.screen ?? NSScreen.main)?.visibleFrame else { return }
var origin = panel.frame.origin
origin.y = min(max(origin.y, visible.minY), visible.maxY - panel.frame.height)
if origin != panel.frame.origin { panel.setFrameOrigin(origin) }
}

nonisolated private static func screenState() -> AgentApprovalScreenState {
AgentApprovalScreenSession.current()
}
Expand Down
73 changes: 4 additions & 69 deletions Sources/AskKeyAppKit/App/ApprovalCardComponents.swift
Original file line number Diff line number Diff line change
@@ -1,79 +1,14 @@
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<Content: View, Accessory: View>: 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.
/// replaced values and merges, red for removals.
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
}
Expand Down Expand Up @@ -110,7 +45,7 @@ struct ApprovalLineText: View {
var result = AttributedString()
if let tag {
var label = AttributedString("\u{2009}" + tag.title + "\u{2009}")
label.font = Theme.Fonts.caption.weight(.semibold)
label.font = Theme.Fonts.secondary.weight(.semibold)
label.foregroundColor = tag.color
label.backgroundColor = Theme.neutralSubtle
result += label + AttributedString(" ")
Expand All @@ -129,8 +64,8 @@ enum ApprovalButtonRole: Equatable {
}

/// 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.
/// bordered secondary action, or a bordered red action whose effect can't be
/// undone, which is never the default. "Deny" is secondary, never red.
struct ApprovalPromptButton: View {
let title: String
let role: ApprovalButtonRole
Expand Down
59 changes: 24 additions & 35 deletions Sources/AskKeyAppKit/App/ApprovalCopy.swift
Original file line number Diff line number Diff line change
@@ -1,50 +1,21 @@
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.
/// Shared wording for approval cards and the pending list. Names are quoted
/// with “ ” in both languages, as on the v0.2 card.
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
"“" + name + "”"
}

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 {
Expand All @@ -68,6 +39,24 @@ enum ApprovalCopy {
static func sentences(_ parts: [String]) -> String {
parts.joined(separator: appLocalized("Sentence separator"))
}

/// "A, B and C" in the current language.
static func list(_ items: [String]) -> String {
guard let last = items.last else { return "" }
guard items.count > 1 else { return last }
return appLocalizedFormat("%1$@ and %2$@", items.dropLast().joined(separator: appLocalized("List separator")), last)
}

/// Starts English text with a capital letter; Chinese is unchanged.
static func capitalized(_ text: String) -> String {
text.prefix(1).uppercased() + text.dropFirst()
}

/// Names that the vault treats as the same item or group.
static func matchKey(_ name: String) -> String {
name.precomposedStringWithCanonicalMapping
.folding(options: [.caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
}
}

/// One line of card text in which variable names render as code.
Expand Down Expand Up @@ -100,16 +89,16 @@ struct ApprovalLine: Equatable {
}
}

/// Small status labels on neutral backgrounds. Color carries risk, not novelty.
/// Status labels in Details, at the text size on a neutral background.
/// Color carries risk, not novelty.
enum ApprovalTag: Equatable {
case unchanged, changed, new, newGroup, replaced, removed, noChange, merge
case unchanged, changed, new, 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")
Expand Down
133 changes: 133 additions & 0 deletions Sources/AskKeyAppKit/App/ApprovalDetailsContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import AppKit
import AskKeyBroker

/// The rows under Details: a label and a value each, in a fixed order per
/// card. Nothing here repeats the card's title or subtitle.
struct ApprovalDetailsContent: Equatable {
struct Line: Equatable {
var tag: ApprovalTag? = nil
let text: ApprovalLine
/// A second line in the secondary color, such as a step's result.
var note: String? = nil
}

enum Value: Equatable {
case text(String)
case code(String)
case lines([Line])
/// Removed words struck through, added words highlighted, then the
/// removed phrases.
case diff(ApprovalTextDiff)
/// New values, masked until viewed with another authentication.
case revealableValue
}

struct Row: Equatable {
let label: String
let value: Value
}

let rows: [Row]

init(request: BrokerApprovalOperationRequest, credentialName: String, write: FrozenWriteSummaryContent? = nil,
organization: FrozenOrganizationSummaryContent? = nil, timedAllowanceEnabled: Bool = false,
timedAllowanceMinutes: Int = 30) {
var rows = [Row(label: appLocalized("Requested by"),
value: .text(appLocalizedFormat("%@ (not verified)", ApprovalCopy.requester(request))))]
if let purpose = request.callerPurpose, !purpose.isEmpty {
rows.append(Row(label: appLocalized("Purpose"), value: .text(appLocalizedFormat("%@ (not verified)", purpose))))
}
switch request.operation {
case .read:
if let display = request.display {
rows.append(Row(label: appLocalized("Command"), value: .code(display.commandLine)))
if let directory = display.workingDirectory, !directory.isEmpty {
rows.append(Row(label: appLocalized("Runs in"), value: .code(directory)))
}
rows.append(Row(label: appLocalized("Hands over"),
value: .lines(Self.handsOver(display, credentialName: credentialName))))
}
if timedAllowanceEnabled {
rows.append(Row(label: appLocalizedFormat("%lld minutes", timedAllowanceMinutes),
value: .text(FrozenApprovalActions.timedScope(minutes: timedAllowanceMinutes))))
}
case .create, .modify, .delete:
if let write {
rows += Self.writeRows(write)
} else if request.operation != .delete {
rows.append(Row(label: appLocalized("Value"), value: .revealableValue))
}
case .organize:
rows += (organization?.rows ?? []).map { step in
Row(label: appLocalizedFormat("Step %lld", step.number),
value: .lines([Line(tag: step.tag, text: ApprovalLine(step.title), note: step.detail)]))
}
}
self.rows = rows
}

/// One line per variable the command gets. A credential with a variable
/// has one item, named after the credential; a multi-item credential's
/// mapping is not opened before approval, so its items stay unnamed.
private static func handsOver(_ display: BrokerApprovalOperationRequest.Display, credentialName: String) -> [Line] {
guard let environment = display.environmentVariables, let files = display.temporaryFileVariables else {
return [Line(text: ApprovalLine(appLocalizedFormat(
"The items of %@ that are set to be given to programs (names are shown after you approve)",
ApprovalCopy.quoted(credentialName))))]
}
let lines = environment.map {
Line(text: ApprovalLine(format: appLocalized("%1$@ → environment variable %2$@"), plain: [credentialName], code: [$0]))
} + files.map {
Line(text: ApprovalLine(format: appLocalized("%1$@ → temporary file %2$@"), plain: [credentialName], code: [$0]))
}
return lines.isEmpty ? [Line(text: ApprovalLine(appLocalized("Nothing from this credential is given to the command")))] : lines
}

private static func writeRows(_ write: FrozenWriteSummaryContent) -> [Row] {
var rows: [Row] = []
switch write.operation {
case .create:
rows.append(Row(label: appLocalized("Contents"), value: .lines(write.components.map { component in
Line(text: ApprovalLine(format: "%@", code: [component.name]) + ApprovalLine(" · ")
+ ApprovalCopy.delivery(component.delivery))
})))
if !write.instructions.isEmpty {
rows.append(Row(label: appLocalized("Instructions"), value: .text(write.instructions)))
}
case .modify:
let changed = write.changedComponents
if !changed.isEmpty {
rows.append(Row(label: appLocalized("Changes"), value: .lines(changed.map {
Line(tag: $0.tag, text: ApprovalLine(format: "%@", code: [$0.name]))
})))
}
if let diff = write.instructionsDiff {
rows.append(Row(label: appLocalized("Instructions"), value: .diff(diff)))
}
if let group = write.groupChange {
let after = group.createsGroup ? appLocalizedFormat("%@ (new group)", group.after) : group.after
rows.append(Row(label: appLocalized("Group"), value: .text(group.before + " → " + after)))
}
case .delete:
rows.append(Row(label: appLocalized("Contents"), value: .lines(write.components.map {
Line(text: ApprovalLine(format: "%@", code: [$0.name]))
})))
case .read, .organize:
break
}
if !write.valueComponents.isEmpty {
rows.append(Row(label: appLocalized("Value"), value: .revealableValue))
}
return rows
}

/// One label column for every card in the current language, wide enough
/// for the longest label.
static func labelWidth() -> CGFloat {
let labels = ["Requested by", "Purpose", "Command", "Runs in", "Hands over", "Contents", "Instructions",
"Changes", "Group", "Value"].map { appLocalized($0) }
+ [appLocalizedFormat("%lld minutes", 999), appLocalizedFormat("Step %lld", 64)]
let font = NSFont.systemFont(ofSize: 12)
return ceil(labels.map { ($0 as NSString).size(withAttributes: [.font: font]).width }.max() ?? 0) + 1
}
}
Loading
Loading