diff --git a/CHANGELOG.md b/CHANGELOG.md index d9398f23d5..9923e3e9f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.59.1 — Unreleased +### Fixed +- Codex accounts: honor Hide Personal Info in switcher labels and tooltips, redact embedded workspace emails, and preserve distinct account numbers in narrow menus (#3551). Thanks @zenibako! +- Codex spend: keep waiting history files ahead of repeated migration revisits so large histories can finish bounded catch-up, preserving stored rows and checkpoints (#3548, related to #3411). Thanks @SergeiNikolenko! + ## 0.59.0 — 2026-09-10 ### Highlights diff --git a/Sources/CodexBar/CodexAccountSwitcherLabeling.swift b/Sources/CodexBar/CodexAccountSwitcherLabeling.swift new file mode 100644 index 0000000000..d3681ec105 --- /dev/null +++ b/Sources/CodexBar/CodexAccountSwitcherLabeling.swift @@ -0,0 +1,40 @@ +import CodexBarCore +import Foundation + +enum CodexAccountSwitcherLabeling { + static func ordinals(for accounts: [CodexVisibleAccount]) -> [String: Int] { + // The visible ID changes when a managed account becomes live; its persisted slot ID does not. + let ordered = accounts.sorted { lhs, rhs in + let left = lhs.storedAccountID?.uuidString ?? lhs.id + let right = rhs.storedAccountID?.uuidString ?? rhs.id + return left == right ? lhs.id < rhs.id : left < right + } + var ordinals: [String: Int] = [:] + for (index, account) in ordered.enumerated() { + ordinals[account.id] = index + 1 + } + return ordinals + } + + static func labels(for accounts: [CodexVisibleAccount], hidePersonalInfo: Bool) -> [String: String] { + let ordinals = self.ordinals(for: accounts) + return accounts.reduce(into: [:]) { labels, account in + labels[account.id] = self.label( + for: account, ordinal: ordinals[account.id], hidePersonalInfo: hidePersonalInfo) + } + } + + static func accountLabel(ordinal: Int?) -> String { + L("Account %@", String(ordinal ?? 1)) + } + + static func label(for account: CodexVisibleAccount, ordinal: Int?, hidePersonalInfo: Bool) -> String { + guard hidePersonalInfo else { return account.menuDisplayName } + let number = self.accountLabel(ordinal: ordinal) + guard let workspace = PersonalInfoRedactor.redactEmails(in: account.menuWorkspaceLabel, isEnabled: true), + !workspace.isEmpty, !workspace.contains("@") + else { return number } + // A number on every private label also prevents collisions with user-supplied workspace names. + return "\(number) · \(workspace)" + } +} diff --git a/Sources/CodexBar/CodexAccountSwitcherView.swift b/Sources/CodexBar/CodexAccountSwitcherView.swift new file mode 100644 index 0000000000..0b45f855b9 --- /dev/null +++ b/Sources/CodexBar/CodexAccountSwitcherView.swift @@ -0,0 +1,396 @@ +import AppKit +import CodexBarCore + +final class CodexAccountSwitcherView: NSView { + private let accounts: [CodexVisibleAccount] + private let hidePersonalInfo: Bool + private let switcherLabels: [String: String] + private let accountOrdinals: [String: Int] + private let onSelect: (CodexVisibleAccount) -> Void + private var selectedAccountID: String + private var pressedAccountID: String? + private var buttons: [NSButton] = [] + private let preferredSize: NSSize + private let rowSpacing: CGFloat = 4 + private let rowHeight: CGFloat = 26 + private let selectedBackground = NSColor.controlAccentColor.cgColor + private let unselectedBackground = NSColor.clear.cgColor + private let selectedTextColor = NSColor.white + private let unselectedTextColor = NSColor.secondaryLabelColor + private let buttonFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + private let buttonHorizontalPadding: CGFloat = 14 + private let buttonSideInset: CGFloat = 6 + + init( + accounts: [CodexVisibleAccount], + selectedAccountID: String?, + width: CGFloat, + hidePersonalInfo: Bool = false, + onSelect: @escaping (CodexVisibleAccount) -> Void) + { + self.accounts = accounts + self.hidePersonalInfo = hidePersonalInfo + self.accountOrdinals = CodexAccountSwitcherLabeling.ordinals(for: accounts) + self.switcherLabels = CodexAccountSwitcherLabeling.labels( + for: accounts, hidePersonalInfo: hidePersonalInfo) + self.onSelect = onSelect + self.selectedAccountID = selectedAccountID ?? accounts.first?.id ?? "" + var columns = max(1, accounts.count > 3 ? Int(ceil(Double(accounts.count) / 2)) : accounts.count) + let font = self.buttonFont + let discriminatorWidth = accounts.compactMap(\.displayDiscriminator).map { + ceil(($0 as NSString).size(withAttributes: [.font: font]).width) + }.max() + if let discriminatorWidth { + let contentWidth = max(0, width - self.buttonSideInset * 2) + let minimumButtonWidth = discriminatorWidth + self.buttonHorizontalPadding + let fittingColumns = Int((contentWidth + self.rowSpacing) / (minimumButtonWidth + self.rowSpacing)) + columns = min(columns, max(1, fittingColumns)) + } + let rows = max(1, Int(ceil(Double(accounts.count) / Double(columns)))) + let height = self.rowHeight * CGFloat(rows) + self.rowSpacing * CGFloat(rows - 1) + self.preferredSize = NSSize(width: width, height: height) + super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) + self.wantsLayer = true + self.buildButtons(columns: columns) + self.updateButtonStyles() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override var intrinsicContentSize: NSSize { + self.preferredSize + } + + override var fittingSize: NSSize { + self.preferredSize + } + + private func buildButtons(columns: Int) { + let rows: [[CodexVisibleAccount]] = self.accounts.isEmpty ? [[]] : stride( + from: 0, to: self.accounts.count, by: columns).map { start in + Array(self.accounts[start.. String { + self.switcherLabels[account.id] ?? CodexAccountSwitcherLabeling + .accountLabel(ordinal: self.accountOrdinals[account.id]) + } + + private func buttonWidth(for count: Int) -> CGFloat { + let contentWidth = self.bounds.width - (self.buttonSideInset * 2) + let spacing = self.rowSpacing * CGFloat(max(0, count - 1)) + guard count > 0 else { return contentWidth } + return max(44, floor((contentWidth - spacing) / CGFloat(count))) + } + + private func compactButtonTitle(for account: CodexVisibleAccount, buttonWidth: CGFloat) -> String { + let availableTextWidth = max(24, buttonWidth - self.buttonHorizontalPadding) + if self.hidePersonalInfo { + let label = self.resolvedLabel(for: account) + if self.textWidth(label) <= availableTextWidth { return label } + let ordinal = self.accountOrdinals[account.id] ?? 1 + let short = CodexAccountSwitcherLabeling.accountLabel(ordinal: ordinal) + return self.textWidth(short) <= availableTextWidth ? short : String(ordinal) + } + if self.textWidth(account.menuDisplayName) <= availableTextWidth { + return account.menuDisplayName + } + + if let discriminator = account.displayDiscriminator { + let suffix = "|\(discriminator)" + let emailWidth = max(0, availableTextWidth - self.textWidth(suffix)) + guard emailWidth > self.textWidth("…") else { return discriminator } + return "\(self.truncateMiddle(account.email, toFit: emailWidth))\(suffix)" + } + + guard let workspace = account.menuWorkspaceLabel else { + return self.truncateMiddle(account.email, toFit: availableTextWidth) + } + + let separator = "|" + let separatorWidth = self.textWidth(separator) + let contentWidth = max(24, availableTextWidth - separatorWidth) + let minimumEmailWidth = min(contentWidth * 0.45, max(18, contentWidth * 0.3)) + let minimumWorkspaceWidth = min(contentWidth * 0.4, max(18, contentWidth * 0.25)) + var emailWidth = max(minimumEmailWidth, contentWidth * 0.58) + var workspaceWidth = max(minimumWorkspaceWidth, contentWidth - emailWidth) + + func makeTitle() -> String { + let email = self.truncateMiddle(account.email, toFit: emailWidth) + let workspace = self.truncateTail(workspace, toFit: workspaceWidth) + return "\(email)\(separator)\(workspace)" + } + + var title = makeTitle() + var attempts = 0 + while self.textWidth(title) > availableTextWidth, attempts < 16 { + let emailText = self.truncateMiddle(account.email, toFit: emailWidth) + let workspaceText = self.truncateTail(workspace, toFit: workspaceWidth) + let emailRenderedWidth = self.textWidth(emailText) + let workspaceRenderedWidth = self.textWidth(workspaceText) + + if emailRenderedWidth >= workspaceRenderedWidth, emailWidth > minimumEmailWidth { + emailWidth = max(minimumEmailWidth, emailWidth - 6) + } else if workspaceWidth > minimumWorkspaceWidth { + workspaceWidth = max(minimumWorkspaceWidth, workspaceWidth - 6) + } else { + break + } + + title = makeTitle() + attempts += 1 + } + + return title + } + + private func truncateTail(_ text: String, toFit width: CGFloat) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return text } + if self.textWidth(trimmed) <= width { + return trimmed + } + + let ellipsis = "…" + let ellipsisWidth = self.textWidth(ellipsis) + guard ellipsisWidth < width else { return ellipsis } + + var candidate = "" + for character in trimmed { + let next = candidate + String(character) + if self.textWidth(next + ellipsis) > width { + break + } + candidate = next + } + + if candidate.isEmpty { + return ellipsis + } + return candidate + ellipsis + } + + private func truncateMiddle(_ text: String, toFit width: CGFloat) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return text } + if self.textWidth(trimmed) <= width { + return trimmed + } + + let ellipsis = "…" + let ellipsisWidth = self.textWidth(ellipsis) + guard ellipsisWidth < width else { return ellipsis } + + var prefix = "" + var suffix = "" + var prefixIndex = trimmed.startIndex + var suffixIndex = trimmed.endIndex + var best = ellipsis + var takeSuffixNext = true + + while prefixIndex < suffixIndex { + let nextPrefix: String + let nextSuffix: String + if takeSuffixNext { + let previousIndex = trimmed.index(before: suffixIndex) + nextPrefix = prefix + nextSuffix = String(trimmed[previousIndex]) + suffix + suffixIndex = previousIndex + } else { + nextPrefix = prefix + String(trimmed[prefixIndex]) + nextSuffix = suffix + prefixIndex = trimmed.index(after: prefixIndex) + } + + let candidate = nextPrefix + ellipsis + nextSuffix + if self.textWidth(candidate) > width { + break + } + + prefix = nextPrefix + suffix = nextSuffix + best = candidate + takeSuffixNext.toggle() + } + + return best + } + + private func textWidth(_ text: String) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: self.buttonFont] + return ceil((text as NSString).size(withAttributes: attributes).width) + } + + private func updateButtonStyles() { + for button in self.buttons { + let selected = button.identifier?.rawValue == self.selectedAccountID + button.state = selected ? .on : .off + button.layer?.backgroundColor = selected ? self.selectedBackground : self.unselectedBackground + button.contentTintColor = selected ? self.selectedTextColor : self.unselectedTextColor + } + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let descendant = super.hitTest(point) + if descendant != nil, descendant !== self { + self.toolTip = (descendant as? NSButton)?.toolTip + return self + } + self.toolTip = nil + return descendant + } + + override func mouseDown(with event: NSEvent) { + let location = self.convert(event.locationInWindow, from: nil) + self.pressedAccountID = self.accountID(at: location) + } + + override func mouseUp(with event: NSEvent) { + defer { self.pressedAccountID = nil } + guard let pressedAccountID = self.pressedAccountID else { return } + let location = self.convert(event.locationInWindow, from: nil) + guard let releasedAccountID = self.accountID(at: location), + releasedAccountID == pressedAccountID, + let account = self.accounts.first(where: { $0.id == pressedAccountID }) + else { + return + } + self.applySelection(account) + } + + private func accountID(at pointInSelf: NSPoint) -> String? { + self.buttons.first(where: { self.convert($0.bounds, from: $0).contains(pointInSelf) })?.identifier?.rawValue + } + + @objc private func handleSelect(_ sender: NSButton) { + guard let accountID = sender.identifier?.rawValue, + let account = self.accounts.first(where: { $0.id == accountID }) else { return } + self.applySelection(account) + } + + private func applySelection(_ account: CodexVisibleAccount) { + self.selectedAccountID = account.id + self.updateButtonStyles() + self.onSelect(account) + } + + #if DEBUG + func _test_buttonTitles() -> [String] { + self.buttons.map(\.title) + } + + func _test_buttonToolTips() -> [String?] { + self.buttons.map(\.toolTip) + } + + func _test_selectAccount(id: String) { + guard let account = self.accounts.first(where: { $0.id == id }) else { return } + self.applySelection(account) + } + + func _test_simulateRuntimeClick(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + guard let mouseDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1), + let mouseUpEvent = NSEvent.mouseEvent( + with: .leftMouseUp, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 2, + clickCount: 1, + pressure: 0) + else { + return false + } + self.mouseDown(with: mouseDownEvent) + self.mouseUp(with: mouseUpEvent) + return self.selectedAccountID == id + } + + func _test_hitTestSwallowsChildButton(id: String) -> Bool { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + return self.hitTest(point) === self + } + + func _test_toolTipAfterHitTest(id: String) -> String? { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return nil } + self.updateConstraintsForSubtreeIfNeeded() + self.layoutSubtreeIfNeeded() + let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) + _ = self.hitTest(point) + return self.toolTip + } + #endif +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 67d6c13da6..06c2c48503 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1088,6 +1088,7 @@ extension StatusItemController { accounts: display.accounts, selectedAccountID: display.activeVisibleAccountID, width: width, + hidePersonalInfo: self.settings.hidePersonalInfo, onSelect: { [weak self, weak menu] account in guard let self else { return } self.handleCodexVisibleAccountSelection(account, menu: menu) diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 38f5060a8d..10059e968e 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1334,377 +1334,3 @@ final class TokenAccountSwitcherView: NSView { } #endif } - -final class CodexAccountSwitcherView: NSView { - private let accounts: [CodexVisibleAccount] - private let onSelect: (CodexVisibleAccount) -> Void - private var selectedAccountID: String - private var pressedAccountID: String? - private var buttons: [NSButton] = [] - private let preferredSize: NSSize - private let rowSpacing: CGFloat = 4 - private let rowHeight: CGFloat = 26 - private let selectedBackground = NSColor.controlAccentColor.cgColor - private let unselectedBackground = NSColor.clear.cgColor - private let selectedTextColor = NSColor.white - private let unselectedTextColor = NSColor.secondaryLabelColor - private let buttonFont = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - private let buttonHorizontalPadding: CGFloat = 14 - private let buttonSideInset: CGFloat = 6 - - init( - accounts: [CodexVisibleAccount], - selectedAccountID: String?, - width: CGFloat, - onSelect: @escaping (CodexVisibleAccount) -> Void) - { - self.accounts = accounts - self.onSelect = onSelect - self.selectedAccountID = selectedAccountID ?? accounts.first?.id ?? "" - var columns = max(1, accounts.count > 3 ? Int(ceil(Double(accounts.count) / 2)) : accounts.count) - let font = self.buttonFont - let discriminatorWidth = accounts.compactMap(\.displayDiscriminator).map { - ceil(($0 as NSString).size(withAttributes: [.font: font]).width) - }.max() - if let discriminatorWidth { - let contentWidth = max(0, width - self.buttonSideInset * 2) - let minimumButtonWidth = discriminatorWidth + self.buttonHorizontalPadding - let fittingColumns = Int((contentWidth + self.rowSpacing) / (minimumButtonWidth + self.rowSpacing)) - columns = min(columns, max(1, fittingColumns)) - } - let rows = max(1, Int(ceil(Double(accounts.count) / Double(columns)))) - let height = self.rowHeight * CGFloat(rows) + self.rowSpacing * CGFloat(rows - 1) - self.preferredSize = NSSize(width: width, height: height) - super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) - self.wantsLayer = true - self.buildButtons(columns: columns) - self.updateButtonStyles() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - nil - } - - override var intrinsicContentSize: NSSize { - self.preferredSize - } - - override var fittingSize: NSSize { - self.preferredSize - } - - private func buildButtons(columns: Int) { - let rows: [[CodexVisibleAccount]] = self.accounts.isEmpty ? [[]] : stride( - from: 0, to: self.accounts.count, by: columns).map { start in - Array(self.accounts[start.. CGFloat { - let contentWidth = self.bounds.width - (self.buttonSideInset * 2) - let spacing = self.rowSpacing * CGFloat(max(0, count - 1)) - guard count > 0 else { return contentWidth } - return max(44, floor((contentWidth - spacing) / CGFloat(count))) - } - - private func compactButtonTitle(for account: CodexVisibleAccount, buttonWidth: CGFloat) -> String { - let availableTextWidth = max(24, buttonWidth - self.buttonHorizontalPadding) - if self.textWidth(account.menuDisplayName) <= availableTextWidth { - return account.menuDisplayName - } - - if let discriminator = account.displayDiscriminator { - let suffix = "|\(discriminator)" - let emailWidth = max(0, availableTextWidth - self.textWidth(suffix)) - guard emailWidth > self.textWidth("…") else { return discriminator } - return "\(self.truncateMiddle(account.email, toFit: emailWidth))\(suffix)" - } - - guard let workspace = account.menuWorkspaceLabel else { - return self.truncateMiddle(account.email, toFit: availableTextWidth) - } - - let separator = "|" - let separatorWidth = self.textWidth(separator) - let contentWidth = max(24, availableTextWidth - separatorWidth) - let minimumEmailWidth = min(contentWidth * 0.45, max(18, contentWidth * 0.3)) - let minimumWorkspaceWidth = min(contentWidth * 0.4, max(18, contentWidth * 0.25)) - var emailWidth = max(minimumEmailWidth, contentWidth * 0.58) - var workspaceWidth = max(minimumWorkspaceWidth, contentWidth - emailWidth) - - func makeTitle() -> String { - let email = self.truncateMiddle(account.email, toFit: emailWidth) - let workspace = self.truncateTail(workspace, toFit: workspaceWidth) - return "\(email)\(separator)\(workspace)" - } - - var title = makeTitle() - var attempts = 0 - while self.textWidth(title) > availableTextWidth, attempts < 16 { - let emailText = self.truncateMiddle(account.email, toFit: emailWidth) - let workspaceText = self.truncateTail(workspace, toFit: workspaceWidth) - let emailRenderedWidth = self.textWidth(emailText) - let workspaceRenderedWidth = self.textWidth(workspaceText) - - if emailRenderedWidth >= workspaceRenderedWidth, emailWidth > minimumEmailWidth { - emailWidth = max(minimumEmailWidth, emailWidth - 6) - } else if workspaceWidth > minimumWorkspaceWidth { - workspaceWidth = max(minimumWorkspaceWidth, workspaceWidth - 6) - } else { - break - } - - title = makeTitle() - attempts += 1 - } - - return title - } - - private func truncateTail(_ text: String, toFit width: CGFloat) -> String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return text } - if self.textWidth(trimmed) <= width { - return trimmed - } - - let ellipsis = "…" - let ellipsisWidth = self.textWidth(ellipsis) - guard ellipsisWidth < width else { return ellipsis } - - var candidate = "" - for character in trimmed { - let next = candidate + String(character) - if self.textWidth(next + ellipsis) > width { - break - } - candidate = next - } - - if candidate.isEmpty { - return ellipsis - } - return candidate + ellipsis - } - - private func truncateMiddle(_ text: String, toFit width: CGFloat) -> String { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return text } - if self.textWidth(trimmed) <= width { - return trimmed - } - - let ellipsis = "…" - let ellipsisWidth = self.textWidth(ellipsis) - guard ellipsisWidth < width else { return ellipsis } - - var prefix = "" - var suffix = "" - var prefixIndex = trimmed.startIndex - var suffixIndex = trimmed.endIndex - var best = ellipsis - var takeSuffixNext = true - - while prefixIndex < suffixIndex { - let nextPrefix: String - let nextSuffix: String - if takeSuffixNext { - let previousIndex = trimmed.index(before: suffixIndex) - nextPrefix = prefix - nextSuffix = String(trimmed[previousIndex]) + suffix - suffixIndex = previousIndex - } else { - nextPrefix = prefix + String(trimmed[prefixIndex]) - nextSuffix = suffix - prefixIndex = trimmed.index(after: prefixIndex) - } - - let candidate = nextPrefix + ellipsis + nextSuffix - if self.textWidth(candidate) > width { - break - } - - prefix = nextPrefix - suffix = nextSuffix - best = candidate - takeSuffixNext.toggle() - } - - return best - } - - private func textWidth(_ text: String) -> CGFloat { - let attributes: [NSAttributedString.Key: Any] = [.font: self.buttonFont] - return ceil((text as NSString).size(withAttributes: attributes).width) - } - - private func updateButtonStyles() { - for button in self.buttons { - let selected = button.identifier?.rawValue == self.selectedAccountID - button.state = selected ? .on : .off - button.layer?.backgroundColor = selected ? self.selectedBackground : self.unselectedBackground - button.contentTintColor = selected ? self.selectedTextColor : self.unselectedTextColor - } - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func hitTest(_ point: NSPoint) -> NSView? { - let descendant = super.hitTest(point) - if descendant != nil, descendant !== self { - self.toolTip = (descendant as? NSButton)?.toolTip - return self - } - self.toolTip = nil - return descendant - } - - override func mouseDown(with event: NSEvent) { - let location = self.convert(event.locationInWindow, from: nil) - self.pressedAccountID = self.accountID(at: location) - } - - override func mouseUp(with event: NSEvent) { - defer { self.pressedAccountID = nil } - guard let pressedAccountID = self.pressedAccountID else { return } - let location = self.convert(event.locationInWindow, from: nil) - guard let releasedAccountID = self.accountID(at: location), - releasedAccountID == pressedAccountID, - let account = self.accounts.first(where: { $0.id == pressedAccountID }) - else { - return - } - self.applySelection(account) - } - - private func accountID(at pointInSelf: NSPoint) -> String? { - self.buttons.first(where: { self.convert($0.bounds, from: $0).contains(pointInSelf) })?.identifier?.rawValue - } - - @objc private func handleSelect(_ sender: NSButton) { - guard let accountID = sender.identifier?.rawValue, - let account = self.accounts.first(where: { $0.id == accountID }) else { return } - self.applySelection(account) - } - - private func applySelection(_ account: CodexVisibleAccount) { - self.selectedAccountID = account.id - self.updateButtonStyles() - self.onSelect(account) - } - - #if DEBUG - func _test_buttonTitles() -> [String] { - self.buttons.map(\.title) - } - - func _test_buttonToolTips() -> [String?] { - self.buttons.map(\.toolTip) - } - - func _test_selectAccount(id: String) { - guard let account = self.accounts.first(where: { $0.id == id }) else { return } - self.applySelection(account) - } - - func _test_simulateRuntimeClick(id: String) -> Bool { - guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } - self.updateConstraintsForSubtreeIfNeeded() - self.layoutSubtreeIfNeeded() - let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) - guard let mouseDownEvent = NSEvent.mouseEvent( - with: .leftMouseDown, - location: point, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - eventNumber: 1, - clickCount: 1, - pressure: 1), - let mouseUpEvent = NSEvent.mouseEvent( - with: .leftMouseUp, - location: point, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - eventNumber: 2, - clickCount: 1, - pressure: 0) - else { - return false - } - self.mouseDown(with: mouseDownEvent) - self.mouseUp(with: mouseUpEvent) - return self.selectedAccountID == id - } - - func _test_hitTestSwallowsChildButton(id: String) -> Bool { - guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return false } - self.updateConstraintsForSubtreeIfNeeded() - self.layoutSubtreeIfNeeded() - let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) - return self.hitTest(point) === self - } - - func _test_toolTipAfterHitTest(id: String) -> String? { - guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return nil } - self.updateConstraintsForSubtreeIfNeeded() - self.layoutSubtreeIfNeeded() - let point = self.convert(NSPoint(x: button.bounds.midX, y: button.bounds.midY), from: button) - _ = self.hitTest(point) - return self.toolTip - } - #endif -} diff --git a/Tests/CodexBarTests/CodexAccountSwitcherRedactionTests.swift b/Tests/CodexBarTests/CodexAccountSwitcherRedactionTests.swift new file mode 100644 index 0000000000..d88927917a --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountSwitcherRedactionTests.swift @@ -0,0 +1,162 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Hide Personal Info was honored on provider cards but ignored by the Codex account switcher, +/// which rendered raw `email — workspace` titles. These lock in the redacted labels. +@MainActor +struct CodexAccountSwitcherRedactionTests { + private func account( + id: String, + email: String, + workspace: String? = nil) -> CodexVisibleAccount + { + CodexVisibleAccount( + id: id, + email: email, + workspaceLabel: workspace, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + } + + private func label(_ account: CodexVisibleAccount, ordinal: Int?, hide: Bool) -> String { + CodexAccountSwitcherLabeling.label( + for: account, ordinal: ordinal, hidePersonalInfo: hide) + } + + @Test + func `showing personal info keeps the existing email and workspace title`() { + let account = self.account(id: "a", email: "person@example.com", workspace: "Acme") + #expect(self.label(account, ordinal: 1, hide: false) == account.menuDisplayName) + #expect(self.label(account, ordinal: 1, hide: false).contains("person@example.com")) + } + + @Test + func `hiding personal info drops the email and keeps the workspace`() { + let account = self.account(id: "a", email: "person@example.com", workspace: "Acme") + let label = self.label(account, ordinal: 1, hide: true) + #expect(label == "Account 1 · Acme") + #expect(!label.contains("person@example.com")) + #expect(!label.contains("@")) + } + + @Test + func `an account with no workspace falls back to an ordinal rather than a blank button`() { + let account = self.account(id: "a", email: "person@example.com") + let label = self.label(account, ordinal: 3, hide: true) + #expect(label == "Account 3") + #expect(!label.isEmpty) + #expect(!label.contains("@")) + // A blank-workspace string must not slip through as an empty title either. + let blank = self.account(id: "b", email: "other@example.com", workspace: " ") + #expect(self.label(blank, ordinal: 2, hide: true) == "Account 2") + } + + @Test + func `redacted ordinals follow stable identity, not display order`() { + let accounts = [ + self.account(id: "ccc", email: "c@example.com"), + self.account(id: "aaa", email: "a@example.com"), + self.account(id: "bbb", email: "b@example.com"), + ] + let ordinals = CodexAccountSwitcherLabeling.ordinals(for: accounts) + #expect(ordinals == ["aaa": 1, "bbb": 2, "ccc": 3]) + // Reordering the display list must not renumber the accounts. + #expect(CodexAccountSwitcherLabeling.ordinals(for: Array(accounts.reversed())) == ordinals) + } + + @Test + func `redacted labels stay distinct when accounts share a workspace (#3282)`() { + // Two accounts, different emails, same Business workspace. Dropping the email must not + // collapse them onto one name: #3282 requires every selection surface to distinguish + // accounts before credentials are switched. + let accounts = [ + self.account(id: "aaa", email: "one@example.com", workspace: "Odaseva"), + self.account(id: "bbb", email: "two@example.com", workspace: "Odaseva"), + ] + let labels = CodexAccountSwitcherLabeling.labels(for: accounts, hidePersonalInfo: true) + #expect(labels["aaa"] != labels["bbb"]) + #expect(labels["aaa"] == "Account 1 · Odaseva") + #expect(labels["bbb"] == "Account 2 · Odaseva") + for label in labels.values { + #expect(!label.contains("@")) + } + } + + @Test + func `same-email accounts in different workspaces keep their workspace names`() { + // The #3282 case itself: one email, two workspaces. + let accounts = [ + self.account(id: "aaa", email: "same@example.com", workspace: "Workspace A"), + self.account(id: "bbb", email: "same@example.com", workspace: "Workspace B"), + ] + let labels = CodexAccountSwitcherLabeling.labels(for: accounts, hidePersonalInfo: true) + #expect(labels["aaa"] == "Account 1 · Workspace A") + #expect(labels["bbb"] == "Account 2 · Workspace B") + } + + @Test + func `showing personal info leaves labels untouched even when they collide`() { + let accounts = [ + self.account(id: "aaa", email: "same@example.com"), + self.account(id: "bbb", email: "same@example.com"), + ] + let labels = CodexAccountSwitcherLabeling.labels(for: accounts, hidePersonalInfo: false) + // Disambiguating unredacted labels is #3282's own concern (displayDiscriminator), not this + // redaction rule's; it must not start rewriting them. + #expect(labels["aaa"] == accounts[0].menuDisplayName) + #expect(labels["bbb"] == accounts[1].menuDisplayName) + } + + @Test + func `no rendered switcher title or tooltip leaks an email when hiding personal info`() { + let accounts = [ + self.account(id: "a", email: "person@example.com", workspace: "Acme"), + self.account(id: "b", email: "other@example.com"), + ] + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: "a", + width: 320, + hidePersonalInfo: true, + onSelect: { _ in }) + view.layoutSubtreeIfNeeded() + for title in view._test_buttonTitles() + view._test_buttonToolTips().compactMap(\.self) { + #expect(!title.contains("@"), "leaked: \(title)") + #expect(!title.isEmpty) + } + } + + @Test + func `workspace emails and generated-looking labels stay private and distinct`() { + let accounts = [ + self.account(id: "a", email: "first@example.com", workspace: "Acme"), + self.account(id: "b", email: "second@example.com", workspace: "Acme"), + self.account(id: "c", email: "third@example.com", workspace: "Acme · Account 1"), + self.account(id: "d", email: "fourth@example.com", workspace: "Team fourth@example.com"), + ] + let labels = CodexAccountSwitcherLabeling.labels(for: accounts, hidePersonalInfo: true) + #expect(Set(labels.values).count == accounts.count) + #expect(labels.values.allSatisfy { !$0.contains("@") }) + let view = CodexAccountSwitcherView( + accounts: accounts, selectedAccountID: "a", width: 150, hidePersonalInfo: true, onSelect: { _ in }) + view.layoutSubtreeIfNeeded() + #expect(Set(view._test_buttonTitles()).count == accounts.count) + #expect((view._test_buttonTitles() + view._test_buttonToolTips().compactMap(\.self)) + .allSatisfy { !$0.contains("@") }) + } + + @Test + func `unrecognized email-like workspace text falls back to the account number`() { + let account = self.account(id: "a", email: "person@example.com", workspace: "用户@example.com") + #expect(self.label(account, ordinal: 1, hide: true) == "Account 1") + } +} diff --git a/Tests/CodexBarTests/CodexSwitcherPrivacyNativeProofTests.swift b/Tests/CodexBarTests/CodexSwitcherPrivacyNativeProofTests.swift new file mode 100644 index 0000000000..70a179b4ef --- /dev/null +++ b/Tests/CodexBarTests/CodexSwitcherPrivacyNativeProofTests.swift @@ -0,0 +1,161 @@ +import AppKit +import XCTest +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +final class CodexSwitcherPrivacyNativeProofTests: XCTestCase { + func test_privateAccountSwitcher() throws { + let environment = ProcessInfo.processInfo.environment + guard let path = environment["CODEXBAR_SWITCHER_PRIVACY_PROOF_DIR"] else { + throw XCTSkip("Set CODEXBAR_SWITCHER_PRIVACY_PROOF_DIR for signed synthetic UI proof") + } + let output = URL(fileURLWithPath: path, isDirectory: true) + guard environment["CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS"] == "1", + environment[CodexCredentialFileAccess.isolationEnvironmentKey] == "1", + environment["CODEXBAR_TEST_SESSION_FILE_ISOLATION"] == "1", + environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1", + NSHomeDirectory().hasPrefix(output.deletingLastPathComponent().path + "/") + else { return XCTFail("Use a contained home and credential/session isolation") } + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + // Expectations alone change; baseline screenshots must use the original proposal binary. + let baseline = environment["CODEXBAR_SWITCHER_PRIVACY_PROOF_BASELINE"] == "1" + let app = NSApplication.shared + guard app.delegate == nil else { return XCTFail("Use a standalone test host") } + let previousApp = NSWorkspace.shared.frontmostApplication + let previousPolicy = app.activationPolicy() + let host = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 720, height: 410), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false) + host.title = "CodexBar — Synthetic Account Privacy" + host.isReleasedWhenClosed = false + defer { + host.close() + _ = app.setActivationPolicy(previousPolicy) + if NSWorkspace.shared.frontmostApplication?.processIdentifier == ProcessInfo.processInfo.processIdentifier { + previousApp?.activate() + } + } + _ = app.setActivationPolicy(.regular) + app.finishLaunching() + host.center() + host.makeKeyAndOrderFront(nil) + app.activate(ignoringOtherApps: true) + var receipt: [String: Any] = ["baseline": baseline, "syntheticOnly": true] + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + host.appearance = NSAppearance(named: appearance) + let content = NSView(frame: NSRect(x: 0, y: 0, width: 720, height: 410)) + host.contentView = content + Self.addLabel("Production Codex account switcher · Synthetic accounts only", y: 365, to: content) + var rows: [[String: Any]] = [] + for (index, configuration) in [(CGFloat(320), true), (CGFloat(150), true), (CGFloat(320), false)] + .enumerated() + { + let (width, hide) = configuration + let y = CGFloat(262 - index * 104) + Self.addLabel("Hide Personal Info: \(hide ? "on" : "off") · \(Int(width)) pt", y: y + 62, to: content) + rows.append(self.addSwitcher(width: width, hide: hide, baseline: baseline, y: y, to: content)) + } + content.layoutSubtreeIfNeeded() + host.displayIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.5)) + let capture = Process() + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + capture.arguments = [ + "-x", "-o", "-l", String(host.windowNumber), + output.appendingPathComponent("switcher-\(appearance.rawValue).png").path, + ] + try capture.run() + capture.waitUntilExit() + XCTAssertEqual(capture.terminationStatus, 0) + receipt[appearance.rawValue] = rows + } + try JSONSerialization.data(withJSONObject: receipt, options: [.sortedKeys, .prettyPrinted]) + .write(to: output.appendingPathComponent("state.json"), options: .atomic) + } + + private func addSwitcher( + width: CGFloat, + hide: Bool, + baseline: Bool, + y: CGFloat, + to content: NSView) -> [String: Any] + { + let accounts = Self.accounts() + var selectedIDs: [String] = [] + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts[0].id, + width: width, + hidePersonalInfo: hide, + onSelect: { selectedIDs.append($0.id) }) + view.setFrameOrigin(NSPoint(x: 28, y: y)) + content.addSubview(view) + view.layoutSubtreeIfNeeded() + let titles = view._test_buttonTitles() + let tips = view._test_buttonToolTips().compactMap(\.self) + XCTAssertEqual(titles.count, accounts.count) + XCTAssertEqual(tips.count, accounts.count) + if hide, baseline { + XCTAssertEqual(tips[0], tips[2], "Original proposal collides with a generated account label") + XCTAssertTrue(tips[3].contains("synthetic.member@example.com")) + } else if hide { + XCTAssertEqual(Set(titles).count, accounts.count) + XCTAssertEqual(Set(tips).count, accounts.count) + XCTAssertFalse((titles + tips).contains { $0.contains("@") }) + XCTAssertTrue(titles.allSatisfy { !$0.isEmpty }) + for (index, title) in titles.enumerated() { + let ordinal = String(format: L("Account %@"), String(index + 1)) + if width >= 320 { + XCTAssertTrue(title.hasPrefix(ordinal), "Wide titles must preserve stable account identity") + } + XCTAssertTrue(tips[index].hasPrefix(ordinal)) + } + } else { + XCTAssertEqual(tips, accounts.map(\.menuDisplayName)) + } + let buttons = Self.buttons(in: view) + for account in accounts { + let button = buttons.first { $0.identifier?.rawValue == account.id } + XCTAssertNotNil(button) + button?.performClick(nil) + } + XCTAssertEqual(selectedIDs, accounts.map(\.id)) + return ["width": Double(width), "hidden": hide, "titles": titles, "toolTips": tips, "clickedIDs": selectedIDs] + } + + private static func accounts() -> [CodexVisibleAccount] { + let firstOrdinal = String(format: L("Account %@"), "1") + let workspaces = ["Acme", "Acme", "Acme · \(firstOrdinal)", "Team synthetic.member@example.com"] + return zip(["a", "b", "c", "d"], workspaces).map { id, workspace in + CodexVisibleAccount( + id: id, + email: "synthetic.\(id)@example.com", + workspaceLabel: workspace, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + } + } + + private static func buttons(in view: NSView) -> [NSButton] { + view.subviews.flatMap { child in + if let button = child as? NSButton { return [button] } + return Self.buttons(in: child) + } + } + + private static func addLabel(_ text: String, y: CGFloat, to content: NSView) { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: 14) + label.frame = NSRect(x: 28, y: y, width: 660, height: 28) + content.addSubview(label) + } +} diff --git a/Tests/CodexBarTests/CodexWorkspaceDisplayTests.swift b/Tests/CodexBarTests/CodexWorkspaceDisplayTests.swift index 76dc3ccd86..d4b8a87d09 100644 --- a/Tests/CodexBarTests/CodexWorkspaceDisplayTests.swift +++ b/Tests/CodexBarTests/CodexWorkspaceDisplayTests.swift @@ -79,12 +79,22 @@ struct CodexWorkspaceDisplayTests { }))).visibleAccounts } let original = project(activeIndex: 0, liveIndex: nil) - for accounts in [project(activeIndex: 1, liveIndex: nil), project(activeIndex: 1, liveIndex: 1)] { + let originalOrdinals = CodexAccountSwitcherLabeling.ordinals(for: original) + let originalLabels = CodexAccountSwitcherLabeling.labels(for: original, hidePersonalInfo: true) + for accounts in [ + project(activeIndex: 1, liveIndex: nil), + project(activeIndex: 1, liveIndex: 1), + project(activeIndex: 0, liveIndex: 0), + ] { + let ordinals = CodexAccountSwitcherLabeling.ordinals(for: accounts) + let labels = CodexAccountSwitcherLabeling.labels(for: accounts, hidePersonalInfo: true) for expected in original { let actual = try #require(accounts.first { $0.workspaceAccountID == expected.workspaceAccountID }) #expect(actual.displayName == expected.displayName) #expect(actual.menuDisplayName == expected.menuDisplayName) #expect(actual.storedAccountID == expected.storedAccountID) + #expect(ordinals[actual.id] == originalOrdinals[expected.id]) + #expect(labels[actual.id] == originalLabels[expected.id]) } } } @@ -132,6 +142,8 @@ struct CodexWorkspaceDisplayTests { #expect(Set(accounts.map(\.displayName)).count == 2) #expect(Set(accounts.map(\.menuDisplayName)).count == 2) #expect(accounts.map(\.displayName) == project(activePath: paths[1]).map(\.displayName)) + #expect(CodexAccountSwitcherLabeling.ordinals(for: accounts) + == CodexAccountSwitcherLabeling.ordinals(for: project(activePath: paths[1]))) for (account, path) in zip(accounts, paths) { #expect(!account.displayName.contains(path)) #expect(!account.displayName.contains("shared-workspace")) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 29f1b30c46..e1db294f66 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1043,7 +1043,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The memory-pressure debug fixture installs its synthetic entry in the Codex cache slot."), SuppressedProviderReference( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1123, + line: 1124, anchor: "controller.refreshOpenMenuIfStillVisible(menu, provider: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2593,7 +2593,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1153, + line: 1154, anchor: "return self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/docs/codex.md b/docs/codex.md index a37112de80..09cc0e83f6 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -274,6 +274,7 @@ is limited, using additional rows when needed. - A catch-up worker that loses its account or settings scope clears its abandoned Refreshing activity on exit. Legitimate pauses remain visible, and an older worker cannot clear a replacement worker's activity. - When a warm cost refresh reaches its time limit, it saves the remaining file work and completed discovery. Compatible shorter/wider history requests resume that work across the retained scan range; publication still waits for exact inventory validation. - Inline cost charts preserve a slot for every day in that window, using the selected cost-bucket time zone and the snapshot's date. Missing days are zero only after history coverage is established; unscanned days and entries without prices remain unknown. Long windows fit within the menu width without dropping dates. +- **Hide personal information** also replaces account-switcher emails with numbered labels and sanitizes email addresses embedded in workspace hints. Narrow switchers retain the account number, and tooltips use the same labels without emails. - **Hide personal information** replaces project/source names with numbered labels and hides their paths in the cost-history submenu; Usage & Spend also masks project names. Costs, tokens, grouping, and stored history are unchanged, and disabling the setting restores the original labels. This is display masking, not data deletion or export sanitization. - While a bounded refresh catches up with new session history, established totals remain visible only for the same account, history window, and bucket time zone. An incomplete first scan never borrows another account's totals.