From 0a0d0d1c2e99f3332f08f914b3b266abaa685118 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Thu, 29 Oct 2026 12:16:25 +0100
Subject: [PATCH 01/20] feat: show recent calls from the phone in the dialer
Read the phone's dialed, received and missed call lists over HFP
(AT+CPBS/AT+CPBR) and show them as tabs while the search field is
empty, with a call button on every row. Falls back to the calls seen
since launch when the phone does not share its call history. Tab moves
into the list, Esc returns to the field; the keypad's call button
fills in the last dialed number.
Co-Authored-By: Claude Opus 5.5
---
App/Resources/Localizable.xcstrings | 144 ++++++++--
App/Sources/Pickup/AppModel.swift | 6 +
App/Sources/Pickup/DialerRows.swift | 17 ++
App/Sources/Pickup/DialerView.swift | 249 +++++++++++++++---
App/Sources/Pickup/StatusItemController.swift | 2 +
.../Pickup/Testing/ScriptedPhone.swift | 12 +
.../PickupAppTests/RecentCallRowsTests.swift | 30 +++
.../PickupBluetooth/AppleHFPTransport.swift | 52 +++-
Sources/PickupBluetooth/HandsFreeLink.swift | 3 +
Sources/PickupCore/CallEngine.swift | 60 ++++-
Sources/PickupCore/CallHistory.swift | 138 ++++++++++
Sources/PickupCore/HFPEvent.swift | 15 ++
Sources/PickupCore/HFPTransport.swift | 13 +
Sources/PickupCore/HFPTypes.swift | 12 +-
.../AppleHFPTransportTests.swift | 65 +++++
.../PickupBluetoothTests/LogCanaryTests.swift | 8 +
Tests/PickupCoreTests/CallHistoryTests.swift | 186 +++++++++++++
.../PickupCoreTests/Support/TestDoubles.swift | 7 +
18 files changed, 957 insertions(+), 62 deletions(-)
create mode 100644 App/Tests/PickupAppTests/RecentCallRowsTests.swift
create mode 100644 Sources/PickupCore/CallHistory.swift
create mode 100644 Tests/PickupCoreTests/CallHistoryTests.swift
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index 054d5bd..20817c6 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -1837,24 +1837,6 @@
}
}
},
- "Type a name or a number.": {
- "comment": "Calm empty state of the popover when the search field is empty.",
- "extractionState": "manual",
- "localizations": {
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Type a name or a number."
- }
- },
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Geben Sie einen Namen oder eine Nummer ein."
- }
- }
- }
- },
"Allow access to search contacts": {
"comment": "Button in the popover that asks for Contacts access so names can be searched.",
"extractionState": "manual",
@@ -2466,6 +2448,132 @@
}
}
}
+ },
+ "Outgoing": {
+ "comment": "Tab of the recent calls in the popover: calls placed from the phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Outgoing"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ausgehend"
+ }
+ }
+ }
+ },
+ "Incoming": {
+ "comment": "Tab of the recent calls in the popover: answered incoming calls.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Incoming"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Eingehend"
+ }
+ }
+ }
+ },
+ "Missed": {
+ "comment": "Tab of the recent calls in the popover: missed incoming calls.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Missed"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Verpasst"
+ }
+ }
+ }
+ },
+ "Recent Calls": {
+ "comment": "VoiceOver label of the tabs above the recent calls in the popover.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Recent Calls"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Letzte Anrufe"
+ }
+ }
+ }
+ },
+ "No calls": {
+ "comment": "Empty state of a recent calls tab in the popover.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "No calls"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Keine Anrufe"
+ }
+ }
+ }
+ },
+ "Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings.": {
+ "comment": "Footnote below the recent calls when the phone did not share its call history and Pickup shows its own list.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Anrufe seit dem Start von Pickup. Um die Anrufliste des Telefons zu sehen, erlauben Sie in den Bluetooth-Einstellungen des Telefons den Zugriff auf Kontakte und Anrufliste."
+ }
+ }
+ }
+ },
+ "%lld calls": {
+ "comment": "Recent calls row: number of consecutive calls with the same number merged into this row, e.g. \"3 calls\".",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld calls"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%lld Anrufe"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/AppModel.swift b/App/Sources/Pickup/AppModel.swift
index c963710..29007d4 100644
--- a/App/Sources/Pickup/AppModel.swift
+++ b/App/Sources/Pickup/AppModel.swift
@@ -46,6 +46,8 @@ final class AppModel {
private(set) var notice: String?
private(set) var selectedAddress: String?
private(set) var selectedName: String?
+ /// Recent calls for the dialer: the phone's call lists, or the calls seen since launch.
+ private(set) var callHistory = CallHistory()
let callUI = CallUIModel()
let contacts = ContactsDirectory()
@@ -129,6 +131,9 @@ final class AppModel {
func reconnect() { engine?.reconnect() }
+ /// The dialer opened: read the phone's call lists again (throttled by the engine).
+ func refreshCallHistory() { engine?.refreshCallHistory() }
+
/// App termination: release the transport cleanly.
func shutdown() { teardown() }
@@ -216,6 +221,7 @@ final class AppModel {
engine.nextReconnectAttempt != nil ? .reconnecting(phoneName: phoneName) : .disconnected(phoneName: phoneName)
}
set(\.connection, status)
+ set(\.callHistory, engine.callHistory)
trackStuckLink(connected: link == .connected)
set(\.guidance, bluetoothDenied ? .bluetoothDenied
: (showCannotConnect && link != .connected ? .cannotConnect(phoneName: engine.deviceName ?? selectedName) : linkHint))
diff --git a/App/Sources/Pickup/DialerRows.swift b/App/Sources/Pickup/DialerRows.swift
index c38304a..80c726f 100644
--- a/App/Sources/Pickup/DialerRows.swift
+++ b/App/Sources/Pickup/DialerRows.swift
@@ -6,6 +6,8 @@ struct DialerRow: Identifiable, Equatable {
enum Kind: Equatable {
case call(DialNumber)
case contact(ContactSearchResult)
+ /// A recent call; `number` is nil when the entry has no dialable number (e.g. withheld).
+ case recent(CallHistoryEntry, number: DialNumber?, list: CallHistoryList)
}
var kind: Kind
@@ -35,4 +37,19 @@ enum DialerRows {
}
return rows
}
+
+ /// Rows for the recent calls of one list. Numbers from the phone are untrusted: a service code
+ /// is never allowed directly and needs the second confirmation, like typed text.
+ static func recentRows(_ entries: [CallHistoryEntry], list: CallHistoryList) -> [DialerRow] {
+ entries.enumerated().map { index, entry in
+ let number = DialNumber(entry.number)
+ return DialerRow(kind: .recent(entry, number: number, list: list), id: "recent|\(list.rawValue)|\(index)",
+ allowServiceCode: false, requiresConfirmation: number?.isServiceCode ?? false)
+ }
+ }
+
+ /// The number the keypad's call button puts into the empty field (redial): the newest dialed call.
+ static func redialNumber(in entries: [CallHistoryEntry]) -> String? {
+ entries.first { DialNumber($0.number) != nil }?.number
+ }
}
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index 7ec9aa4..53e2e8f 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -32,6 +32,8 @@ struct DialerView: View {
@State private var pendingConfirmationID: String?
/// The text exactly as the keypad produced it; any other edit (typing, paste) makes it untrusted.
@State private var keypadText: String? = ""
+ /// Tab of the recent calls shown while the field is empty (remembered between launches).
+ @AppStorage("recentCallsList") private var recentListRaw = CallHistoryList.dialed.rawValue
@FocusState private var fieldFocused: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -51,8 +53,23 @@ struct DialerView: View {
private var fromKeypad: Bool { text.isEmpty || text == keypadText }
+ private var recentList: CallHistoryList { CallHistoryList(rawValue: recentListRaw) ?? .dialed }
+
+ /// Search results, or the recent calls of the selected tab while the field is empty.
private var rows: [DialerRow] {
- DialerRows.rows(for: text, contacts: model.contacts.search(text), fromKeypad: fromKeypad)
+ if queryIsEmpty {
+ return DialerRows.recentRows(model.callHistory.entries(recentList), list: recentList)
+ }
+ return DialerRows.rows(for: text, contacts: model.contacts.search(text), fromKeypad: fromKeypad)
+ }
+
+ /// Number the keypad's call button puts into the empty field (redial).
+ private var redialNumber: String? {
+ DialerRows.redialNumber(in: model.callHistory.entries(.dialed))
+ }
+
+ private var keypadCallEnabled: Bool {
+ model.canDial && (callRow != nil || (queryIsEmpty && redialNumber != nil))
}
private var callRow: DialerRow? {
@@ -61,7 +78,8 @@ struct DialerView: View {
private var selectedIndex: Int {
let rows = self.rows
- return rows.firstIndex { $0.id == selectedID } ?? 0
+ // Recent calls start without a selection, so a stray Return never redials.
+ return rows.firstIndex { $0.id == selectedID } ?? (queryIsEmpty ? -1 : 0)
}
private var showKeypad: Bool {
@@ -111,6 +129,7 @@ struct DialerView: View {
focusFieldSoon()
DialerKeyRouter.shared.move = { delta in moveSelection(delta) }
DialerKeyRouter.shared.escape = { handleEscape() }
+ DialerKeyRouter.shared.tab = { backwards in handleTab(backwards: backwards) }
DialerKeyRouter.shared.physicalKey = { key, down in physicalKey(key, down: down) }
}
.onReceive(NotificationCenter.default.publisher(for: .pickupPopoverWillShow)) { _ in
@@ -122,6 +141,7 @@ struct DialerView: View {
error = nil
selectedID = nil
pendingConfirmationID = nil
+ model.refreshCallHistory()
focusFieldSoon()
}
.onReceive(NotificationCenter.default.publisher(for: .pickupPopoverDidShow)) { _ in focusFieldSoon() }
@@ -133,6 +153,11 @@ struct DialerView: View {
pendingConfirmationID = nil
}
.onChange(of: fieldFocused) { _, focused in if focused { collapseCaret() } }
+ .onChange(of: recentListRaw) { _, _ in
+ selectedID = nil
+ pendingConfirmationID = nil
+ error = nil
+ }
}
/// The window is not key yet when the popover is about to open; focus the field once it is.
@@ -155,14 +180,41 @@ struct DialerView: View {
}
}
- /// Esc: clear the field first; with an empty field the popover closes (return false).
+ /// Esc: leave the recent calls list first, then clear the field; with an empty field the popover
+ /// closes (return false).
private func handleEscape() -> Bool {
+ if queryIsEmpty, selectedID != nil {
+ leaveList()
+ return true
+ }
guard !text.isEmpty else { return false }
text = ""
keypadText = ""
return true
}
+ /// Tab moves from the field into the list (selects the first row), Shift-Tab back to the field.
+ /// Typing still goes to the field; Return calls the selected row.
+ private func handleTab(backwards: Bool) -> Bool {
+ let rows = self.rows
+ if backwards {
+ guard selectedID != nil, queryIsEmpty else { return false }
+ leaveList()
+ return true
+ }
+ guard !rows.isEmpty, !rows.contains(where: { $0.id == selectedID }) else { return false }
+ selectedID = rows[0].id
+ return true
+ }
+
+ private func leaveList() {
+ selectedID = nil
+ pendingConfirmationID = nil
+ error = nil
+ fieldFocused = true
+ collapseCaret()
+ }
+
// MARK: Top bar
private var topBar: some View {
@@ -292,8 +344,8 @@ struct DialerView: View {
.contentShape(Circle())
}
.buttonStyle(.plain)
- .disabled(callRow == nil || !model.canDial)
- .opacity(callRow == nil || !model.canDial ? 0.45 : 1)
+ .disabled(!keypadCallEnabled)
+ .opacity(keypadCallEnabled ? 1 : 0.45)
.accessibilityLabel(Text("Call"))
.help(Text("Call"))
.accessibilityIdentifier("keypad-call")
@@ -309,7 +361,7 @@ struct DialerView: View {
let rows = self.rows
let hasLetters = text.contains(where: \.isLetter)
if queryIsEmpty {
- emptyState
+ recentCalls
} else if rows.isEmpty {
if access == .granted || !hasLetters {
Text("No matches").font(.callout).foregroundStyle(.secondary).frame(maxWidth: .infinity)
@@ -317,42 +369,105 @@ struct DialerView: View {
}
if access != .granted && hasLetters { accessHint }
} else {
- let selected = selectedIndex
- ScrollViewReader { proxy in
- ScrollView {
- LazyVStack(spacing: 2) {
- ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in
- Button { selectedID = row.id } label: {
- ResultRow(row: row.display, selected: index == selected, enabled: model.canDial)
- }
- .buttonStyle(.plain)
- .disabled(!model.canDial)
- .id(row.id)
- .accessibilityIdentifier(row.testIdentifier)
- // Single click selects; double-click or Return calls (a stray click never dials).
- .simultaneousGesture(TapGesture(count: 2).onEnded { activate(row) })
- .accessibilityAction(.default) { activate(row) }
- .accessibilityHint(Text("Double-click or press Return to call.", comment: "VoiceOver hint of a search result row in the popover."))
+ resultList(rows)
+ if access != .granted && hasLetters { accessHint }
+ }
+ }
+
+ private func resultList(_ rows: [DialerRow]) -> some View {
+ let selected = selectedIndex
+ return ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 2) {
+ ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in
+ Button { selectedID = row.id } label: {
+ ResultRow(row: row.display(contacts: model.contacts), selected: index == selected,
+ enabled: model.canDial, trailingInset: row.isRecent ? 40 : 0)
+ }
+ .buttonStyle(.plain)
+ .disabled(!model.canDial)
+ .id(row.id)
+ .accessibilityIdentifier(row.testIdentifier)
+ // Single click selects; double-click or Return calls (a stray click never dials).
+ .simultaneousGesture(TapGesture(count: 2).onEnded { activate(row) })
+ .accessibilityAction(.default) { activate(row) }
+ .accessibilityHint(Text("Double-click or press Return to call.", comment: "VoiceOver hint of a search result row in the popover."))
+ .overlay(alignment: .trailing) {
+ if row.isRecent { recentCallButton(row) }
}
}
}
- .frame(maxHeight: (showKeypad ? 3 : 6) * 50)
- .onChange(of: selectedID) { _, new in
- if let new { proxy.scrollTo(new) }
- }
}
- if access != .granted && hasLetters { accessHint }
+ .frame(maxHeight: (showKeypad ? 3 : 6) * 50)
+ .onChange(of: selectedID) { _, new in
+ if let new { proxy.scrollTo(new) }
+ }
+ }
+ }
+
+ /// The call button at the end of a recent call row: one click dials (a service code asks first).
+ private func recentCallButton(_ row: DialerRow) -> some View {
+ let dialable = row.recentNumber != nil && model.canDial
+ return Button { selectedID = row.id; activate(row) } label: {
+ Image(systemName: "phone.fill")
+ .font(.system(size: 13))
+ .foregroundStyle(.white)
+ .frame(width: 28, height: 28)
+ .background(Color.callGreen, in: Circle())
+ .contentShape(Circle())
}
+ .buttonStyle(.plain)
+ .disabled(!dialable)
+ .opacity(dialable ? 1 : 0.35)
+ .padding(.trailing, 10)
+ .accessibilityLabel(Text("Call"))
+ .help(Text("Call"))
+ .accessibilityIdentifier(row.testIdentifier + "-call")
}
- private var emptyState: some View {
- VStack(spacing: 6) {
+ // MARK: Recent calls
+
+ private var recentCalls: some View {
+ let history = model.callHistory
+ let rows = self.rows
+ return VStack(spacing: 8) {
+ Picker(selection: $recentListRaw) {
+ Text("Outgoing", comment: "Tab of the recent calls in the popover: calls placed from the phone.")
+ .tag(CallHistoryList.dialed.rawValue)
+ Text("Incoming", comment: "Tab of the recent calls in the popover: answered incoming calls.")
+ .tag(CallHistoryList.received.rawValue)
+ Text("Missed", comment: "Tab of the recent calls in the popover: missed incoming calls.")
+ .tag(CallHistoryList.missed.rawValue)
+ } label: {
+ Text("Recent Calls", comment: "VoiceOver label of the tabs above the recent calls in the popover.")
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ .accessibilityIdentifier("recent-tabs")
+
+ if rows.isEmpty {
+ Group {
+ if history.isLoading {
+ ProgressView().controlSize(.small)
+ } else {
+ Text("No calls", comment: "Empty state of a recent calls tab in the popover.")
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ } else {
+ resultList(rows)
+ }
+ if history.source(recentList) == .session && !history.isLoading && history.phoneRefused {
+ Text("Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings.",
+ comment: "Footnote below the recent calls when the phone did not share its call history and Pickup shows its own list.")
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
if access != .granted { accessHint }
- Text("Type a name or a number.", comment: "Calm empty state of the popover when the search field is empty.")
- .font(.callout).foregroundStyle(.secondary)
}
- .frame(maxWidth: .infinity)
- .padding(.vertical, 6)
}
@ViewBuilder
@@ -416,7 +531,16 @@ struct DialerView: View {
if rows.indices.contains(selectedIndex) { activate(rows[selectedIndex]) }
}
+ /// The keypad's call button. With an empty field it puts the last dialed number in (redial);
+ /// the next press calls it.
private func placeTypedCall() {
+ if queryIsEmpty, let number = redialNumber {
+ text = number
+ keypadText = nil // came from the phone, not from the keypad: service codes need a confirmation
+ fieldFocused = true
+ collapseCaret()
+ return
+ }
if let row = callRow { activate(row) }
}
@@ -431,6 +555,12 @@ struct DialerView: View {
return
}
number = n
+ case .recent(_, let n, _):
+ guard let n else {
+ error = String(localized: "This number cannot be dialed.", comment: "Inline error when a contact's number is not a valid phone number.")
+ return
+ }
+ number = n
}
if row.requiresConfirmation && pendingConfirmationID != row.id {
pendingConfirmationID = row.id
@@ -456,6 +586,8 @@ final class DialerKeyRouter {
static let shared = DialerKeyRouter()
var move: ((Int) -> Bool)?
var escape: (() -> Bool)?
+ /// Tab (false) or Shift-Tab (true); returns true when the dialer used the key.
+ var tab: ((Bool) -> Bool)?
/// A physical dial key (0-9 * #) went down/up; drives the on-screen key highlight.
var physicalKey: ((String, Bool) -> Void)?
}
@@ -463,15 +595,44 @@ final class DialerKeyRouter {
// MARK: - Rows
fileprivate extension DialerRow {
- /// UI-test identifier: `call-row` or `contact-row--`.
+ /// UI-test identifier: `call-row`, `contact-row--` or `recent-row--`.
var testIdentifier: String {
switch kind {
case .call: "call-row"
case .contact: "contact-row-" + id.replacingOccurrences(of: "|", with: "-")
+ case .recent: id.replacingOccurrences(of: "recent|", with: "recent-row-").replacingOccurrences(of: "|", with: "-")
+ }
+ }
+
+ var isRecent: Bool { if case .recent = kind { true } else { false } }
+
+ var recentNumber: DialNumber? { if case .recent(_, let number, _) = kind { number } else { nil } }
+
+ @MainActor
+ func display(contacts: ContactsDirectory) -> ResultRow.Display {
+ switch kind {
+ case .recent(let entry, _, let list):
+ // The Mac's contact (with photo) wins over the name the phone sent.
+ let match = entry.number.isEmpty ? nil : contacts.match(for: entry.number)
+ let name = match?.name ?? entry.name
+ let formatted = entry.number.isEmpty ? nil : PhoneNumberFormat.display(entry.number)
+ let title = name ?? formatted
+ ?? String(localized: "Unknown caller")
+ var details: [String] = []
+ if name != nil, let formatted { details.append(formatted) }
+ if let date = entry.date { details.append(date.formatted(.relative(presentation: .named))) }
+ if entry.count > 1 {
+ details.append(String(localized: "\(entry.count) calls", comment: "Recent calls row: number of consecutive calls with the same number merged into this row, e.g. \"3 calls\"."))
+ }
+ return .init(title: title, subtitle: details.isEmpty ? nil : details.joined(separator: " · "),
+ party: CallParty(displayName: name, number: formatted, avatarPNG: match?.imageData),
+ isCall: false, warning: false, isMissed: list == .missed)
+ default:
+ return display
}
}
- var display: ResultRow.Display {
+ private var display: ResultRow.Display {
switch kind {
case .call(let number):
.init(title: String(localized: "Call \(PhoneNumberFormat.display(number.value))",
@@ -479,21 +640,29 @@ fileprivate extension DialerRow {
subtitle: requiresConfirmation
? String(localized: "Service code", comment: "Warning subtitle on the call row when the text is a service code (contains * or #), e.g. call forwarding.")
: nil,
- party: nil, isCall: true, warning: requiresConfirmation)
+ party: nil, isCall: true, warning: requiresConfirmation, isMissed: false)
case .contact(let result):
.init(title: result.name,
subtitle: [result.label, PhoneNumberFormat.display(result.number)].compactMap { $0 }.joined(separator: " · "),
party: CallParty(displayName: result.name, number: nil, avatarPNG: result.imageData),
- isCall: false, warning: false)
+ isCall: false, warning: false, isMissed: false)
+ case .recent:
+ .init(title: "", subtitle: nil, party: nil, isCall: false, warning: false, isMissed: false)
}
}
}
private struct ResultRow: View {
- struct Display { var title: String; var subtitle: String?; var party: CallParty?; var isCall: Bool; var warning: Bool }
+ struct Display {
+ var title: String; var subtitle: String?; var party: CallParty?; var isCall: Bool; var warning: Bool
+ /// Missed calls are titled in red, like in the Phone app.
+ var isMissed: Bool
+ }
let row: Display
let selected: Bool
let enabled: Bool
+ /// Room kept free at the end for a button laid over the row.
+ var trailingInset: CGFloat = 0
var body: some View {
HStack(spacing: 10) {
@@ -509,6 +678,7 @@ private struct ResultRow: View {
}
VStack(alignment: .leading, spacing: 1) {
Text(verbatim: row.title).font(.body).lineLimit(1).truncationMode(.tail)
+ .foregroundStyle(row.isMissed ? Color.red : Color.primary)
if let subtitle = row.subtitle {
Text(verbatim: subtitle).font(.caption)
.foregroundStyle(.secondary).lineLimit(1)
@@ -516,7 +686,8 @@ private struct ResultRow: View {
}
Spacer(minLength: 0)
}
- .padding(.horizontal, 8)
+ .padding(.leading, 8)
+ .padding(.trailing, 8 + trailingInset)
.frame(height: 48)
.background(selected ? Color.accentColor.opacity(0.22) : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.contentShape(Rectangle())
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index 2322e4d..096de06 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -159,6 +159,8 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
switch event.keyCode {
case 126: return (router.move?(-1) ?? false) ? nil : event // up
case 125: return (router.move?(1) ?? false) ? nil : event // down
+ case 48: // tab: into the list, shift-tab: back
+ return (router.tab?(event.modifierFlags.contains(.shift)) ?? false) ? nil : event
case 53: // esc
if router.escape?() == true { return nil }
self.closePopover()
diff --git a/App/Sources/Pickup/Testing/ScriptedPhone.swift b/App/Sources/Pickup/Testing/ScriptedPhone.swift
index 5f1b8d5..72c61ce 100644
--- a/App/Sources/Pickup/Testing/ScriptedPhone.swift
+++ b/App/Sources/Pickup/Testing/ScriptedPhone.swift
@@ -82,6 +82,18 @@ final class ScriptedTransport: HFPTransport {
}
}
+ /// The phone's call lists (invented): it shares them like a Pixel that was granted access.
+ func readCallHistory(_ list: CallHistoryList, count: Int) throws {
+ try requireLink()
+ let entries: [CallHistoryEntry] = switch list {
+ case .dialed: [CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: Self.john, name: "John Roe"),
+ CallHistoryEntry(number: Self.jane, name: "Jane Doe")]
+ case .received: [CallHistoryEntry(number: Self.john, name: "John Roe")]
+ case .missed: [CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: "")]
+ }
+ later { [self] in emit(.callHistory(list, Array(entries.prefix(count)))) }
+ }
+
// MARK: Phone side (driven by the scenario)
func ringIncoming(number: String?) {
diff --git a/App/Tests/PickupAppTests/RecentCallRowsTests.swift b/App/Tests/PickupAppTests/RecentCallRowsTests.swift
new file mode 100644
index 0000000..ca713e0
--- /dev/null
+++ b/App/Tests/PickupAppTests/RecentCallRowsTests.swift
@@ -0,0 +1,30 @@
+import PickupCore
+import XCTest
+@testable import Pickup
+
+/// Recent call rows: numbers from the phone are untrusted and never dial a service code directly.
+final class RecentCallRowsTests: XCTestCase {
+ func testRecentRowsCarryTheirNumberAndAStableID() {
+ let rows = DialerRows.recentRows([CallHistoryEntry(number: "+49300000123", name: "Jane Doe"),
+ CallHistoryEntry(number: "")], list: .dialed)
+ XCTAssertEqual(rows.map(\.id), ["recent|DC|0", "recent|DC|1"])
+ guard case .recent(_, let number, let list) = rows[0].kind else { return XCTFail("not a recent row") }
+ XCTAssertEqual(number?.value, "+49300000123")
+ XCTAssertEqual(list, .dialed)
+ guard case .recent(_, let withheld, _) = rows[1].kind else { return XCTFail("not a recent row") }
+ XCTAssertNil(withheld, "a withheld caller cannot be called back")
+ XCTAssertFalse(rows.contains { $0.allowServiceCode })
+ }
+
+ func testServiceCodeFromThePhoneNeedsConfirmation() {
+ let rows = DialerRows.recentRows([CallHistoryEntry(number: "*#21#")], list: .dialed)
+ XCTAssertTrue(rows[0].requiresConfirmation)
+ XCTAssertFalse(rows[0].allowServiceCode)
+ }
+
+ func testRedialUsesTheNewestDialableNumber() {
+ XCTAssertEqual(DialerRows.redialNumber(in: [CallHistoryEntry(number: ""), CallHistoryEntry(number: "+49300000123"),
+ CallHistoryEntry(number: "+49300000456")]), "+49300000123")
+ XCTAssertNil(DialerRows.redialNumber(in: []))
+ }
+}
diff --git a/Sources/PickupBluetooth/AppleHFPTransport.swift b/Sources/PickupBluetooth/AppleHFPTransport.swift
index 6797486..0e5c464 100644
--- a/Sources/PickupBluetooth/AppleHFPTransport.swift
+++ b/Sources/PickupBluetooth/AppleHFPTransport.swift
@@ -135,6 +135,10 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
private var incomingSanitizer = ATLogSanitizer()
private let outgoingTapInstalled: Bool
private var fallbackCalls: [HFPCall] = []
+ /// Call lists still to be read, in request order.
+ private var historyQueue: [(list: CallHistoryList, count: Int)] = []
+ /// The call list read in progress: the phonebook is being selected, then read.
+ private var historyStep: (list: CallHistoryList, count: Int, reading: Bool)?
private var fallbackFlush: ScheduledTask?
private var connectObserver: AnyObject?
@@ -296,6 +300,46 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
}
}
+ public func readCallHistory(_ list: CallHistoryList, count: Int) throws {
+ guard !isInvalidated else { throw TransportError.invalidated }
+ // Without the write hook, the phone's answers cannot be matched to the commands.
+ guard outgoingTapInstalled else { throw TransportError.unsupported }
+ guard linkState == .connected, link != nil else { throw TransportError.notConnected }
+ historyQueue.append((list, max(1, count)))
+ sendNextHistoryCommand()
+ }
+
+ /// Starts the next queued call list read: `AT+CPBS=""`, then `AT+CPBR=1,` once the
+ /// phone selected it (see `historyExchangeEnded`).
+ private func sendNextHistoryCommand() {
+ guard historyStep == nil, !historyQueue.isEmpty else { return }
+ let next = historyQueue.removeFirst()
+ historyStep = (next.list, next.count, false)
+ sendHistoryCommand("AT+CPBS=\"\(next.list.rawValue)\"")
+ }
+
+ private func sendHistoryCommand(_ line: String) {
+ do {
+ try issue("sendATCommand(\(line))") { $0.sendATCommand(line) }
+ } catch {
+ historyExchangeEnded(nil)
+ }
+ }
+
+ /// The phone answered the current call list command (nil = failed, discarded or not sent).
+ private func historyExchangeEnded(_ exchange: ATExchange?) {
+ guard let step = historyStep else { return }
+ if let exchange, exchange.result.isSuccess, !step.reading {
+ historyStep = (step.list, step.count, true)
+ sendHistoryCommand("AT+CPBR=1,\(step.count)")
+ return
+ }
+ historyStep = nil
+ let entries = exchange.flatMap { $0.result.isSuccess ? $0.responses.compactMap(ATParser.parseCPBR) : nil }
+ emit(.callHistory(step.list, entries))
+ sendNextHistoryCommand()
+ }
+
/// Checks the link now and runs the command on it, deferred to the next main-queue turn if an
/// IOBluetooth callback is running (IOBluetooth's command queue is not re-entrant). A deferred
/// command is dropped if the link went down in between.
@@ -616,6 +660,8 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
}
if state != .connected {
tracker.reset()
+ historyQueue.removeAll()
+ historyStep = nil
}
emit(.linkStateChanged(state))
}
@@ -702,6 +748,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
break // Indicators, RING and +CLIP arrive through the delegate.
case .discarded(let command):
log.log(.warning, "no reply to \(command); dropped from the command queue")
+ if command == .selectPhonebook || command == .readPhonebook { historyExchangeEnded(nil) }
case .completed(let exchange):
if isVerifyingSLC, exchange.command == .currentCalls, let link {
log.log(.state, "[\(linkTag)] SLC answered AT+CLCC (\(exchange.result)); adopting it")
@@ -711,6 +758,9 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
emit(.currentCalls(exchange.currentCalls))
}
emit(.commandCompleted(exchange.command, exchange.result))
+ if exchange.command == .selectPhonebook || exchange.command == .readPhonebook {
+ historyExchangeEnded(exchange)
+ }
}
}
}
@@ -729,7 +779,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
/// to an SLC nobody wants (e.g. one completing during a disconnect).
private func emit(_ event: TransportEvent) {
switch event {
- case .indicator, .ring, .callerID, .callWaiting, .currentCalls, .commandCompleted:
+ case .indicator, .ring, .callerID, .callWaiting, .currentCalls, .commandCompleted, .callHistory:
guard linkState == .connecting || linkState == .connected else {
log.log(.state, "dropped \(event) while \(linkState.rawValue) [\(linkTag)]")
return
diff --git a/Sources/PickupBluetooth/HandsFreeLink.swift b/Sources/PickupBluetooth/HandsFreeLink.swift
index 6c571de..0357ecc 100644
--- a/Sources/PickupBluetooth/HandsFreeLink.swift
+++ b/Sources/PickupBluetooth/HandsFreeLink.swift
@@ -33,6 +33,8 @@ protocol HandsFreeLink: AnyObject {
func sendDTMF(_ digit: String)
func transferAudioToPhone()
func currentCallList()
+ /// Sends a raw AT command (IOBluetooth appends the CR and queues it behind pending commands).
+ func sendATCommand(_ line: String)
/// True if `channel` (an `IOBluetoothRFCOMMChannel`) belongs to this link.
func owns(channel: AnyObject) -> Bool
@@ -82,6 +84,7 @@ final class IOBluetoothHandsFreeLink: HandsFreeLink {
func sendDTMF(_ digit: String) { device.sendDTMF(digit) }
func transferAudioToPhone() { device.transferAudioToPhone() }
func currentCallList() { device.currentCallList() }
+ func sendATCommand(_ line: String) { device.send(atCommand: line) }
func owns(channel: AnyObject) -> Bool {
guard let channel = channel as? IOBluetoothRFCOMMChannel else { return false }
diff --git a/Sources/PickupCore/CallEngine.swift b/Sources/PickupCore/CallEngine.swift
index 8d7e29a..607f090 100644
--- a/Sources/PickupCore/CallEngine.swift
+++ b/Sources/PickupCore/CallEngine.swift
@@ -35,6 +35,13 @@ public final class CallEngine {
/// A call lost with the link is resumed (same ID, number, start time) if the link comes back
/// within this window and the phone still reports a call.
public var lostCallRestoreWindow: TimeInterval = 120
+ /// Entries read per call list from the phone.
+ public var callHistoryLength = 25
+ /// Delay before the call lists are read after the link came up or a call ended (the phone
+ /// writes its call log shortly after the call).
+ public var callHistoryRefreshDelay: TimeInterval = 2
+ /// `refreshCallHistory()` does nothing if the lists were requested less than this ago.
+ public var callHistoryMinimumInterval: TimeInterval = 15
public init() {}
}
@@ -58,6 +65,8 @@ public final class CallEngine {
public private(set) var lastAudioFallback: AudioFallback?
/// Most recent call command the phone rejected (accept, end, dial, DTMF).
public private(set) var lastCommandFailure: CommandFailure?
+ /// Recent calls: the phone's call lists, or the calls seen since launch.
+ public private(set) var callHistory = CallHistory()
/// User setting "Take calls on this Mac". Off by default until the audio extension ships.
public var takeCallsOnMac = false
@@ -102,6 +111,8 @@ public final class CallEngine {
@ObservationIgnored private var dialTimeoutTask: ScheduledTask?
@ObservationIgnored private var lookupRetryTask: ScheduledTask?
@ObservationIgnored private var audioWatchdogTask: ScheduledTask?
+ @ObservationIgnored private var historyRefreshTask: ScheduledTask?
+ @ObservationIgnored private var lastHistoryRequest: Date?
public init(
transport: HFPTransport,
@@ -150,7 +161,7 @@ public final class CallEngine {
transport.invalidate()
transport.eventHandler = nil
linkStateChanged(.disconnected)
- for task in [setupEndTask, endedClearTask, dialTimeoutTask, lookupRetryTask, audioWatchdogTask] {
+ for task in [setupEndTask, endedClearTask, dialTimeoutTask, lookupRetryTask, audioWatchdogTask, historyRefreshTask] {
task?.cancel()
}
}
@@ -256,6 +267,16 @@ public final class CallEngine {
}
/// Asks the phone for the current call list again (refreshes the number).
+ /// Reads the call lists from the phone again (e.g. when the dialer opens). Skipped while a call is
+ /// live, while a read is running, or if the last read started less than
+ /// `callHistoryMinimumInterval` ago. Without an answer the session list stays in use.
+ public func refreshCallHistory() {
+ if let last = lastHistoryRequest, scheduler.now.timeIntervalSince(last) < configuration.callHistoryMinimumInterval {
+ return
+ }
+ requestCallHistory()
+ }
+
public func refreshCallList() {
requestCallList(resetAttempts: true)
}
@@ -312,6 +333,8 @@ public final class CallEngine {
currentCallsReceived(calls)
case .commandCompleted(let command, let result):
commandCompleted(command, result)
+ case .callHistory(let list, let entries):
+ callHistory.received(list, entries: entries)
case .audioConnectionChanged(let isOpen):
audioConnectionChanged(isOpen: isOpen)
}
@@ -329,6 +352,7 @@ public final class CallEngine {
requestCallList(resetAttempts: true)
}
callListWantedOnConnect = false
+ scheduleHistoryRefresh()
// SCO opened during the SLC set-up and could not be handed back then.
if audioRoute == .mac, !isAudioOnMacEnabled {
handBackAudioToPhone(.audioOnMacDisabled)
@@ -337,6 +361,9 @@ public final class CallEngine {
}
// Indicators belong to one SLC; the next one reports them afresh.
indicators = HFPIndicators()
+ callHistory.cancelPending()
+ historyRefreshTask?.cancel()
+ historyRefreshTask = nil
waitingCall = nil
audioRoute = .phone
// A hand-back deferred by the transport is dropped when the link goes down.
@@ -537,6 +564,8 @@ public final class CallEngine {
if call?.callerID == .unknown, call?.phase.isLive == true {
scheduleLookupRetry()
}
+ case .selectPhonebook, .readPhonebook:
+ break // Reported as `.callHistory` by the transport.
case .other:
break // Commands IOBluetooth sends on its own (SMS probing etc.) fail routinely.
}
@@ -661,6 +690,8 @@ public final class CallEngine {
self.call = nil
self.log.log(.state, "idle")
}
+ callHistory.record(call)
+ if reason != .connectionLost { scheduleHistoryRefresh() }
onCallFinished?(call)
}
@@ -712,6 +743,33 @@ public final class CallEngine {
}
}
+ /// Reads the call lists a little later: after the link came up or a call ended. A phone that
+ /// refused before is not asked again automatically (only when the dialer opens).
+ private func scheduleHistoryRefresh() {
+ guard lastHistoryRequest == nil || !callHistory.phoneRefused else { return }
+ historyRefreshTask?.cancel()
+ historyRefreshTask = scheduler.schedule(after: configuration.callHistoryRefreshDelay) { [weak self] in
+ guard let self else { return }
+ self.historyRefreshTask = nil
+ self.requestCallHistory()
+ }
+ }
+
+ private func requestCallHistory() {
+ guard !isInvalidated, linkState == .connected, !phase.isLive, !callHistory.isLoading else { return }
+ lastHistoryRequest = scheduler.now
+ log.log(.command, "read call history -> readCallHistory()")
+ for list in CallHistoryList.allCases {
+ do {
+ try transport.readCallHistory(list, count: configuration.callHistoryLength)
+ callHistory.requested(list)
+ } catch {
+ log.log(.info, "readCallHistory(\(list.rawValue)) unavailable: \(error)")
+ callHistory.received(list, entries: nil)
+ }
+ }
+ }
+
private func startAudioWatchdog() {
guard isAudioOnMacEnabled else { return }
audioWatchdogTask?.cancel()
diff --git a/Sources/PickupCore/CallHistory.swift b/Sources/PickupCore/CallHistory.swift
new file mode 100644
index 0000000..d3927a7
--- /dev/null
+++ b/Sources/PickupCore/CallHistory.swift
@@ -0,0 +1,138 @@
+import Foundation
+
+/// A call list kept by the phone, read over HFP as a phonebook memory (`AT+CPBS`, 3GPP TS 27.007).
+/// Android serves these from its call log, newest first, once the phone has granted the paired Mac
+/// access to contacts and call history.
+public enum CallHistoryList: String, CaseIterable, Equatable, Hashable, Sendable {
+ /// `"DC"`: dialled calls.
+ case dialed = "DC"
+ /// `"RC"`: received (answered) calls.
+ case received = "RC"
+ /// `"MC"`: missed calls.
+ case missed = "MC"
+}
+
+/// One call of a call list. Contains personal data: its descriptions never print number or name.
+public struct CallHistoryEntry: Equatable, Sendable, CustomStringConvertible, CustomDebugStringConvertible {
+ /// As the phone reported it; empty when the number was withheld or unknown.
+ public var number: String
+ /// The name the phone attached (from its contacts), if any.
+ public var name: String?
+ /// When the call started. Only known for calls Pickup saw itself (the phone sends no dates).
+ public var date: Date?
+ /// Consecutive calls with the same number, shown as one entry.
+ public var count: Int
+
+ public init(number: String, name: String? = nil, date: Date? = nil, count: Int = 1) {
+ self.number = number
+ self.name = name
+ self.date = date
+ self.count = count
+ }
+
+ public var description: String {
+ "CallHistoryEntry(number: \(number.isEmpty ? "none" : ""), name: \(name == nil ? "none" : ""), count: \(count))"
+ }
+
+ public var debugDescription: String { description }
+}
+
+/// The recent calls the UI shows: the phone's lists when it shares them, otherwise the calls Pickup
+/// saw since it was launched (kept in memory only).
+public struct CallHistory: Equatable, Sendable {
+ public enum Source: Equatable, Sendable {
+ /// Read from the phone.
+ case phone
+ /// Calls seen by Pickup since launch.
+ case session
+ }
+
+ /// Lists read from the phone, newest first. A list is missing until it was read successfully.
+ public private(set) var phoneLists: [CallHistoryList: [CallHistoryEntry]] = [:]
+ /// Calls seen by Pickup since launch, newest first.
+ public private(set) var sessionCalls: [CallHistoryList: [CallHistoryEntry]] = [:]
+ /// The last attempt to read a list from the phone failed (refused, unsupported or no answer).
+ public private(set) var phoneRefused = false
+ /// Lists requested from the phone and not answered yet.
+ public private(set) var pending: Set = []
+
+ /// Upper bound for the session lists.
+ public static let sessionLimit = 50
+
+ public init() {}
+
+ public var isLoading: Bool { !pending.isEmpty }
+
+ public func source(_ list: CallHistoryList) -> Source {
+ phoneLists[list] != nil ? .phone : .session
+ }
+
+ /// What to show for `list`: consecutive calls with the same number are merged into one entry.
+ public func entries(_ list: CallHistoryList) -> [CallHistoryEntry] {
+ Self.collapsed(phoneLists[list] ?? sessionCalls[list] ?? [])
+ }
+
+ /// Records a call that ended (session list). Failed dial attempts never reached the phone's log.
+ public mutating func record(_ call: CallInfo) {
+ guard let list = Self.list(for: call) else { return }
+ let entry = CallHistoryEntry(number: call.callerID.number ?? "", date: call.startedAt)
+ var calls = sessionCalls[list] ?? []
+ calls.insert(entry, at: 0)
+ sessionCalls[list] = Array(calls.prefix(Self.sessionLimit))
+ }
+
+ mutating func requested(_ list: CallHistoryList) {
+ pending.insert(list)
+ }
+
+ /// The phone answered a request for `list`; nil = it refused or did not answer.
+ mutating func received(_ list: CallHistoryList, entries: [CallHistoryEntry]?) {
+ pending.remove(list)
+ if let entries {
+ phoneLists[list] = entries
+ phoneRefused = false
+ } else {
+ phoneRefused = true
+ }
+ }
+
+ /// The link went down: answers still outstanding will never arrive.
+ mutating func cancelPending() {
+ pending.removeAll()
+ }
+
+ /// Which list a finished call belongs to. Declined calls count as missed (nobody talked).
+ static func list(for call: CallInfo) -> CallHistoryList? {
+ switch (call.direction, call.endReason) {
+ case (_, .failed?), (_, nil):
+ return nil
+ case (.outgoing, _):
+ return .dialed
+ case (.incoming, .missed?), (.incoming, .declined?):
+ return .missed
+ case (.incoming, _):
+ return call.connectedAt == nil ? .missed : .received
+ case (.unknown, _):
+ return nil
+ }
+ }
+
+ /// Merges runs of the same number (compared by digits) into one entry with a count.
+ static func collapsed(_ entries: [CallHistoryEntry]) -> [CallHistoryEntry] {
+ var result: [CallHistoryEntry] = []
+ for entry in entries {
+ if var last = result.last, !entry.number.isEmpty, digits(last.number) == digits(entry.number) {
+ last.count += entry.count
+ if last.name == nil { last.name = entry.name }
+ result[result.count - 1] = last
+ } else {
+ result.append(entry)
+ }
+ }
+ return result
+ }
+
+ private static func digits(_ number: String) -> String {
+ String(number.filter { $0.isASCII && ($0.isNumber || $0 == "+" || $0 == "*" || $0 == "#") })
+ }
+}
diff --git a/Sources/PickupCore/HFPEvent.swift b/Sources/PickupCore/HFPEvent.swift
index d8233a8..3a38f32 100644
--- a/Sources/PickupCore/HFPEvent.swift
+++ b/Sources/PickupCore/HFPEvent.swift
@@ -100,6 +100,21 @@ public enum ATParser {
)
}
+ /// Parses one call list entry, `+CPBR: ,"",,""`. Android appends the
+ /// number type to contact names ("Jane Doe/M"); that suffix is removed. Entries without a number
+ /// (withheld callers) keep an empty number and no name (the phone puts a generic text there).
+ public static func parseCPBR(_ line: String) -> CallHistoryEntry? {
+ guard let fields = fields(of: line, prefix: "+CPBR:"), fields.count >= 2, Int(fields[0]) != nil else { return nil }
+ let number = fields[1].trimmingCharacters(in: .whitespaces)
+ guard !number.isEmpty else { return CallHistoryEntry(number: "") }
+ var name = fields.count > 3 ? fields[3].trimmingCharacters(in: .whitespaces) : ""
+ if let slash = name.lastIndex(of: "/"), name.distance(from: slash, to: name.endIndex) == 2,
+ let type = name.last, "HMWO".contains(type) {
+ name = String(name[.. [String]? {
diff --git a/Sources/PickupCore/HFPTransport.swift b/Sources/PickupCore/HFPTransport.swift
index f28f63e..2c3fdba 100644
--- a/Sources/PickupCore/HFPTransport.swift
+++ b/Sources/PickupCore/HFPTransport.swift
@@ -24,6 +24,9 @@ public enum TransportEvent: Equatable, Sendable {
case currentCalls([HFPCall])
/// The phone answered a command with its final result code.
case commandCompleted(HFPCommand, CommandResult)
+ /// A call list read with `readCallHistory`, newest first; nil = the phone refused it (no access
+ /// to the call history granted), does not support it or did not answer.
+ case callHistory(CallHistoryList, [CallHistoryEntry]?)
/// The audio link (SCO) opened or closed. Remote-control transports may still report it,
/// because the phone can open SCO on its own; the core then hands the audio back.
case audioConnectionChanged(isOpen: Bool)
@@ -88,4 +91,14 @@ public protocol HFPTransport: AnyObject {
func transferAudioToPhone() throws
/// Asks for the current call list. `AT+CLCC`; the answer arrives as `.currentCalls`.
func requestCurrentCalls() throws
+ /// Reads the newest `count` entries of a call list (`AT+CPBS="DC"`, then `AT+CPBR=1,`).
+ /// The answer arrives as `.callHistory`. Requests are queued and answered in order.
+ func readCallHistory(_ list: CallHistoryList, count: Int) throws
+}
+
+public extension HFPTransport {
+ /// Transports without access to the phone's call lists.
+ func readCallHistory(_ list: CallHistoryList, count: Int) throws {
+ throw TransportError.unsupported
+ }
}
diff --git a/Sources/PickupCore/HFPTypes.swift b/Sources/PickupCore/HFPTypes.swift
index fdd1ee9..1011a72 100644
--- a/Sources/PickupCore/HFPTypes.swift
+++ b/Sources/PickupCore/HFPTypes.swift
@@ -142,6 +142,8 @@ public enum HFPCommand: Equatable, Sendable {
case dial // ATD;
case dtmf // AT+VTS=
case currentCalls // AT+CLCC
+ case selectPhonebook // AT+CPBS=""
+ case readPhonebook // AT+CPBR=,
case other(String) // e.g. "AT+BRSF", "AT+CIND?"
public init(atCommand line: String) {
@@ -156,6 +158,10 @@ public enum HFPCommand: Equatable, Sendable {
self = .dtmf
} else if command == "AT+CLCC" {
self = .currentCalls
+ } else if command.hasPrefix("AT+CPBS=\"") {
+ self = .selectPhonebook
+ } else if command.hasPrefix("AT+CPBR="), command != "AT+CPBR=?" {
+ self = .readPhonebook
} else {
self = .other(HFPCommand.name(of: command))
}
@@ -174,8 +180,8 @@ public enum HFPCommand: Equatable, Sendable {
///
/// Only action (`AT+CLCC`), read (`AT+CIND?`) and test (`AT+CHLD=?`) commands get information
/// responses. Set commands (`AT+CLIP=1`, `AT+CCWA=1`) get none, and their prefix must not swallow
- /// the unsolicited result of the same name (`+CLIP:` while ringing). `AT+BRSF=` is the one HFP
- /// set command that is answered with `+BRSF:`.
+ /// the unsolicited result of the same name (`+CLIP:` while ringing). `AT+BRSF=` and `AT+CPBR=`
+ /// are the set commands answered with information responses (`+BRSF:`, `+CPBR:`).
static func responsePrefix(of line: String) -> String? {
let command = line.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard command.hasPrefix("AT+") else { return nil }
@@ -184,7 +190,7 @@ public enum HFPCommand: Equatable, Sendable {
guard !name.isEmpty else { return nil }
let rest = body.dropFirst(name.count)
let isSetCommand = rest.hasPrefix("=") && rest != "=?"
- if isSetCommand && name != "+BRSF" { return nil }
+ if isSetCommand && name != "+BRSF" && name != "+CPBR" { return nil }
return String(name)
}
}
diff --git a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
index 1305d26..45dc6b4 100644
--- a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
+++ b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
@@ -41,6 +41,7 @@ final class AppleHFPTransportTests: XCTestCase {
func sendDTMF(_ digit: String) { calls.append("VTS") }
func transferAudioToPhone() { calls.append("phone") }
func currentCallList() { calls.append("CLCC") }
+ func sendATCommand(_ line: String) { calls.append(line) }
func owns(channel: AnyObject) -> Bool { channel === self.channel }
func indicatorSnapshot() -> [HFPIndicator] { [.call(false), .callSetup(.none)] }
func diagnostics() -> [String] { [] }
@@ -600,4 +601,68 @@ final class AppleHFPTransportTests: XCTestCase {
transport.receivedLine("OK", detail: nil) // AT+CLCC itself is invisible without the tap
XCTAssertEqual(transport.linkState, .connected)
}
+
+ // MARK: Call history
+
+ private var historyEvents: [TransportEvent] {
+ events.filter { if case .callHistory = $0 { true } else { false } }
+ }
+
+ func testCallHistoryIsSelectedThenReadOneListAtATime() async throws {
+ let transport = makeTransport()
+ let link = connected(transport)
+ try transport.readCallHistory(.dialed, count: 2)
+ try transport.readCallHistory(.missed, count: 2)
+ XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+") }, [#"AT+CPBS="DC""#], "the second list waits for the first")
+
+ transport.sentLine(#"AT+CPBS="DC""#, detail: nil, channel: link.channel)
+ transport.receivedLine("OK", detail: nil)
+ await drainMainQueue() // the follow-up command is deferred out of the callback
+ XCTAssertEqual(link.calls.last, "AT+CPBR=1,2")
+
+ transport.sentLine("AT+CPBR=1,2", detail: nil, channel: link.channel)
+ transport.receivedLine(#"+CPBR: 1,"+49300000123",145,"Jane Doe""#, detail: nil)
+ transport.receivedLine(#"+CPBR: 2,"+49300000456",145,"""#, detail: nil)
+ transport.receivedLine("OK", detail: nil)
+ await drainMainQueue()
+ XCTAssertEqual(historyEvents, [.callHistory(.dialed, [
+ CallHistoryEntry(number: "+49300000123", name: "Jane Doe"), CallHistoryEntry(number: "+49300000456"),
+ ])])
+ XCTAssertEqual(link.calls.last, #"AT+CPBS="MC""#)
+
+ // The phone refuses the second list (no access to the call history).
+ transport.sentLine(#"AT+CPBS="MC""#, detail: nil, channel: link.channel)
+ transport.receivedLine("OK", detail: nil)
+ await drainMainQueue()
+ transport.sentLine("AT+CPBR=1,2", detail: nil, channel: link.channel)
+ transport.receivedLine("+CME ERROR: 3", detail: nil)
+ XCTAssertEqual(historyEvents.last, .callHistory(.missed, nil))
+ }
+
+ func testCallHistoryNeedsTheWriteHookAndALink() throws {
+ let untapped = makeTransport(address: "aa-bb-cc-dd-ee-01", assumeOutgoingTap: false)
+ _ = connected(untapped)
+ XCTAssertThrowsError(try untapped.readCallHistory(.dialed, count: 5)) {
+ XCTAssertEqual($0 as? TransportError, .unsupported)
+ }
+ let transport = makeTransport()
+ XCTAssertThrowsError(try transport.readCallHistory(.dialed, count: 5)) {
+ XCTAssertEqual($0 as? TransportError, .notConnected)
+ }
+ }
+
+ func testLinkLossDropsQueuedCallHistoryReads() throws {
+ let transport = makeTransport()
+ let link = connected(transport)
+ try transport.readCallHistory(.dialed, count: 5)
+ try transport.readCallHistory(.received, count: 5)
+ dropped(transport, link)
+ clock.advance(by: 2)
+ let again = made.last!
+ again.isConnected = true
+ transport.handsFree(again, connectedWithStatus: 0)
+ try transport.readCallHistory(.missed, count: 5)
+ XCTAssertEqual(again.calls.filter { $0.hasPrefix("AT+CPBS") }.last, #"AT+CPBS="MC""#,
+ "a fresh link starts with the new request, not with the old queue")
+ }
}
diff --git a/Tests/PickupBluetoothTests/LogCanaryTests.swift b/Tests/PickupBluetoothTests/LogCanaryTests.swift
index 7469c4f..415a944 100644
--- a/Tests/PickupBluetoothTests/LogCanaryTests.swift
+++ b/Tests/PickupBluetoothTests/LogCanaryTests.swift
@@ -75,6 +75,14 @@ final class LogCanaryTests: XCTestCase {
transport.receivedLine("OK", detail: nil)
transport.receivedLine("+CNUM: ,\"\(canaryNumber)\",145,,4", detail: nil)
+ // Call history read from the phone (the engine's event log must not show it either).
+ transport.sentLine("AT+CPBS=\"RC\"", detail: nil, channel: link.channel)
+ transport.receivedLine("OK", detail: nil)
+ transport.sentLine("AT+CPBR=1,25", detail: nil, channel: link.channel)
+ transport.receivedLine("+CPBR: 1,\"\(canaryNumber)\",145,\"\(canaryName)\"", detail: nil)
+ transport.receivedLine("OK", detail: nil)
+ transport.eventHandler?(.callHistory(.received, [CallHistoryEntry(number: canaryNumber, name: canaryName)]))
+
// DTMF: a PIN typed during the call.
for character in canaryPIN {
try engine.sendDTMF(DTMFDigit(character)!)
diff --git a/Tests/PickupCoreTests/CallHistoryTests.swift b/Tests/PickupCoreTests/CallHistoryTests.swift
new file mode 100644
index 0000000..0dfb32f
--- /dev/null
+++ b/Tests/PickupCoreTests/CallHistoryTests.swift
@@ -0,0 +1,186 @@
+import XCTest
+@testable import PickupCore
+
+/// Recent calls: parsing the phone's call lists, the session list and the engine's refresh rules.
+@MainActor
+final class CallHistoryTests: XCTestCase {
+ private var transport: MockHFPTransport!
+ private var scheduler: ManualScheduler!
+ private var engine: CallEngine!
+
+ override func setUp() async throws {
+ transport = MockHFPTransport()
+ transport.supportsCallHistory = true
+ scheduler = ManualScheduler()
+ engine = CallEngine(transport: transport, scheduler: scheduler)
+ }
+
+ private var historyRequests: [CallHistoryList] {
+ transport.commands.compactMap { if case .readCallHistory(let list) = $0 { list } else { nil } }
+ }
+
+ private func answerAll(_ entries: [CallHistoryEntry]?) {
+ for list in CallHistoryList.allCases { transport.emit(.callHistory(list, entries)) }
+ }
+
+ // MARK: Parsing
+
+ func testParsesCPBREntries() {
+ XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 1,"+49300000123",145,"Jane Doe""#),
+ CallHistoryEntry(number: "+49300000123", name: "Jane Doe"))
+ XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 2,"0300000123",129,"""#),
+ CallHistoryEntry(number: "0300000123"))
+ XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 3,"0300000123",129,"Jane Doe/M""#)?.name, "Jane Doe",
+ "Android's number type suffix is removed")
+ XCTAssertEqual(ATParser.parseCPBR(#"+CPBR: 4,"",128,"Unknown""#), CallHistoryEntry(number: ""),
+ "a withheld caller has no number and the phone's placeholder text is dropped")
+ XCTAssertNil(ATParser.parseCPBR("+CPBR: (1-25),30,30"), "the test response is no entry")
+ XCTAssertNil(ATParser.parseCPBR("+CLCC: 1,0,0,0,0"))
+ }
+
+ func testPhonebookCommandsAreRecognisedAndCollectTheirResponses() {
+ XCTAssertEqual(HFPCommand(atCommand: #"AT+CPBS="DC""#), .selectPhonebook)
+ XCTAssertEqual(HFPCommand(atCommand: "AT+CPBR=1,25"), .readPhonebook)
+ XCTAssertEqual(HFPCommand.responsePrefix(of: "AT+CPBR=1,25"), "+CPBR")
+ XCTAssertNil(HFPCommand.responsePrefix(of: #"AT+CPBS="DC""#))
+
+ var tracker = ATExchangeTracker()
+ tracker.outgoing("AT+CPBR=1,25")
+ _ = tracker.incoming(#"+CPBR: 1,"+49300000123",145,"Jane Doe""#)
+ _ = tracker.incoming(#"+CPBR: 2,"+49300000456",145,"""#)
+ let outputs = tracker.incoming("OK")
+ guard case .completed(let exchange)? = outputs.last else { return XCTFail("no exchange: \(outputs)") }
+ XCTAssertEqual(exchange.command, .readPhonebook)
+ XCTAssertEqual(exchange.responses.compactMap(ATParser.parseCPBR).map(\.number), ["+49300000123", "+49300000456"])
+ }
+
+ // MARK: Session list and display
+
+ func testRunsOfTheSameNumberAreMerged() {
+ let entries = [
+ CallHistoryEntry(number: "+49 30 0000123"), CallHistoryEntry(number: "+49300000123", name: "Jane Doe"),
+ CallHistoryEntry(number: "+49300000456"), CallHistoryEntry(number: "+49300000123"),
+ CallHistoryEntry(number: ""), CallHistoryEntry(number: ""),
+ ]
+ let collapsed = CallHistory.collapsed(entries)
+ XCTAssertEqual(collapsed.map(\.count), [2, 1, 1, 1, 1], "withheld calls are never merged")
+ XCTAssertEqual(collapsed.first?.name, "Jane Doe")
+ }
+
+ func testFinishedCallsGoToTheSessionListUntilThePhoneSharesItsLists() {
+ transport.supportsCallHistory = false
+ transport.bringUp()
+ // Missed incoming call.
+ transport.emit(.callSetup(.incoming))
+ transport.emit(.currentCalls([HFPCall(index: 1, direction: .incoming, status: .incoming, number: "+49300000123")]))
+ transport.emit(.callSetup(.none))
+ scheduler.advance(by: 1)
+ // Outgoing call.
+ try? engine.dial(DialNumber("+49300000456")!)
+ transport.emit(.callSetup(.outgoing))
+ transport.emit(.callSetup(.none))
+ scheduler.advance(by: 5)
+
+ XCTAssertEqual(engine.callHistory.source(.missed), .session)
+ XCTAssertEqual(engine.callHistory.entries(.missed).map(\.number), ["+49300000123"])
+ XCTAssertEqual(engine.callHistory.entries(.dialed).map(\.number), ["+49300000456"])
+ XCTAssertNotNil(engine.callHistory.entries(.dialed).first?.date, "session entries know when the call was")
+ XCTAssertEqual(engine.callHistory.entries(.received), [])
+ XCTAssertTrue(engine.callHistory.phoneRefused)
+
+ engine.refreshCallHistory() // the phone still does not share: the session list stays
+ XCTAssertEqual(engine.callHistory.entries(.missed).count, 1)
+ }
+
+ func testListForAFinishedCall() {
+ func call(_ direction: CallDirection, _ reason: CallEndReason, connected: Bool = false) -> CallInfo {
+ var info = CallInfo(direction: direction, callerID: .number("+49300000123"), phase: .ended, startedAt: Date())
+ info.endReason = reason
+ if connected { info.connectedAt = Date() }
+ return info
+ }
+ XCTAssertEqual(CallHistory.list(for: call(.incoming, .missed)), .missed)
+ XCTAssertEqual(CallHistory.list(for: call(.incoming, .declined)), .missed)
+ XCTAssertEqual(CallHistory.list(for: call(.incoming, .remoteEnded, connected: true)), .received)
+ XCTAssertEqual(CallHistory.list(for: call(.outgoing, .unanswered)), .dialed)
+ XCTAssertEqual(CallHistory.list(for: call(.outgoing, .hungUp, connected: true)), .dialed)
+ XCTAssertNil(CallHistory.list(for: call(.outgoing, .failed)), "a refused dial never reached the phone's log")
+ }
+
+ // MARK: Engine refresh
+
+ func testListsAreReadShortlyAfterTheLinkCameUp() {
+ transport.bringUp()
+ XCTAssertEqual(historyRequests, [], "not during the SLC set-up")
+ scheduler.advance(by: 2)
+ XCTAssertEqual(historyRequests, CallHistoryList.allCases)
+ XCTAssertTrue(engine.callHistory.isLoading)
+
+ transport.emit(.callHistory(.dialed, [CallHistoryEntry(number: "+49300000123", name: "Jane Doe")]))
+ XCTAssertEqual(engine.callHistory.source(.dialed), .phone)
+ XCTAssertEqual(engine.callHistory.entries(.dialed).first?.name, "Jane Doe")
+ XCTAssertTrue(engine.callHistory.isLoading)
+ transport.emit(.callHistory(.received, []))
+ transport.emit(.callHistory(.missed, []))
+ XCTAssertFalse(engine.callHistory.isLoading)
+ XCTAssertFalse(engine.callHistory.phoneRefused)
+ }
+
+ func testRefreshIsThrottledAndSkippedDuringACall() {
+ transport.bringUp()
+ scheduler.advance(by: 2)
+ answerAll([])
+ transport.clearCommands()
+
+ engine.refreshCallHistory()
+ XCTAssertEqual(historyRequests, [], "requested less than the minimum interval ago")
+
+ scheduler.advance(by: 20)
+ transport.emit(.callSetup(.incoming))
+ transport.clearCommands()
+ engine.refreshCallHistory()
+ XCTAssertEqual(historyRequests, [], "never while a call is live")
+ }
+
+ func testListsAreReadAgainAfterACallEnded() {
+ transport.bringUp()
+ scheduler.advance(by: 2)
+ answerAll([])
+ transport.emit(.callSetup(.incoming))
+ transport.emit(.callSetup(.none))
+ transport.clearCommands()
+ scheduler.advance(by: 3)
+ XCTAssertEqual(historyRequests, CallHistoryList.allCases)
+ }
+
+ func testAPhoneThatRefusedIsNotAskedAgainAutomatically() {
+ transport.bringUp()
+ scheduler.advance(by: 2)
+ answerAll(nil)
+ XCTAssertTrue(engine.callHistory.phoneRefused)
+ transport.emit(.callSetup(.incoming))
+ transport.emit(.callSetup(.none))
+ transport.clearCommands()
+ scheduler.advance(by: 30)
+ XCTAssertEqual(historyRequests, [])
+
+ engine.refreshCallHistory() // the dialer opened: ask again
+ XCTAssertEqual(historyRequests, CallHistoryList.allCases)
+ }
+
+ func testLinkLossCancelsOutstandingReads() {
+ transport.bringUp()
+ scheduler.advance(by: 2)
+ XCTAssertTrue(engine.callHistory.isLoading)
+ transport.emit(.linkStateChanged(.disconnected))
+ XCTAssertFalse(engine.callHistory.isLoading)
+ }
+
+ func testEntryDescriptionNeverContainsPersonalData() {
+ let entry = CallHistoryEntry(number: "+49300000123", name: "Jane Doe")
+ for text in ["\(entry)", String(reflecting: entry), "\(TransportEvent.callHistory(.dialed, [entry]))"] {
+ XCTAssertFalse(text.contains("0000123"), text)
+ XCTAssertFalse(text.contains("Jane"), text)
+ }
+ }
+}
diff --git a/Tests/PickupCoreTests/Support/TestDoubles.swift b/Tests/PickupCoreTests/Support/TestDoubles.swift
index ad7590b..8ee3a29 100644
--- a/Tests/PickupCoreTests/Support/TestDoubles.swift
+++ b/Tests/PickupCoreTests/Support/TestDoubles.swift
@@ -60,6 +60,7 @@ final class MockHFPTransport: HFPTransport {
enum Command: Equatable {
case connect, disconnect, reconnect, prepareForSleep, resumeAfterSleep, invalidate
case acceptCallOnPhone, endCall, dial(String), sendDTMF(String), transferAudioToPhone, requestCurrentCalls
+ case readCallHistory(CallHistoryList)
}
var supportsAudio = false
@@ -134,6 +135,12 @@ final class MockHFPTransport: HFPTransport {
try record(.transferAudioToPhone)
}
func requestCurrentCalls() throws { try record(.requestCurrentCalls) }
+ /// Off by default (like a phone that does not share its call history), so other tests see no extra commands.
+ var supportsCallHistory = false
+ func readCallHistory(_ list: CallHistoryList, count: Int) throws {
+ guard supportsCallHistory else { throw TransportError.unsupported }
+ try record(.readCallHistory(list))
+ }
}
/// Collects log lines in memory.
From 5beb7c10aeafda07ae6e6be8e081d8806a907c24 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Thu, 29 Oct 2026 12:30:01 +0100
Subject: [PATCH 02/20] fix: make the recent calls fully accessible and keep
the popover height stable
- missed-call titles, row subtitles and the selection outline reach WCAG
contrast in light and dark mode (tested)
- left/right arrows and Command-1 to 3 switch tabs; VoiceOver announces
keyboard selection and tab changes; Tab never traps the keyboard
- call buttons are named after the caller and have a larger hit area;
the keypad button reads Redial when it fills in the last number
- keypad and recent calls share one fixed-height box, so toggling the
keypad or loading the calls no longer resizes the popover
Co-Authored-By: Claude Opus 5.5
---
App/Resources/Localizable.xcstrings | 162 +++++++++++++++++
App/Sources/Pickup/CallViewComponents.swift | 22 +++
App/Sources/Pickup/DialerView.swift | 169 ++++++++++++++----
App/Sources/Pickup/StatusItemController.swift | 11 +-
.../PickupAppTests/ColorContrastTests.swift | 49 ++++-
App/Tests/PickupUITests/Cases/AX.swift | 2 +
.../PickupUITests/Cases/PopoverTests.swift | 45 +++++
7 files changed, 421 insertions(+), 39 deletions(-)
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index 20817c6..b0d0075 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -2574,6 +2574,168 @@
}
}
}
+ },
+ "Search field": {
+ "comment": "VoiceOver announcement when the keyboard leaves the recent calls list and returns to the popover's search field.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Search field"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Suchfeld"
+ }
+ }
+ }
+ },
+ "%@ tab, no calls": {
+ "comment": "VoiceOver announcement when the recent calls tab is switched from the keyboard and the tab is empty. %@ is the tab name, e.g. \"Missed\".",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@ tab, no calls"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reiter „%@“, keine Anrufe"
+ }
+ }
+ }
+ },
+ "%@ tab, %@": {
+ "comment": "VoiceOver announcement when the recent calls tab is switched from the keyboard. First %@ is the tab name, second the selected row, e.g. \"Jane Doe, selected\".",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@ tab, %2$@"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reiter „%1$@“, %2$@"
+ }
+ }
+ }
+ },
+ "%@ tab": {
+ "comment": "VoiceOver announcement when the recent calls tab is switched from the keyboard. %@ is the tab name, e.g. \"Missed\".",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@ tab"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reiter „%@“"
+ }
+ }
+ }
+ },
+ "%@, %@, selected": {
+ "comment": "VoiceOver announcement when a row of the popover's list is selected with the keyboard. First %@ is the name or number, second the details (number, time).",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@, %2$@, selected"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@, %2$@, ausgewählt"
+ }
+ }
+ }
+ },
+ "%@, selected": {
+ "comment": "VoiceOver announcement when a row of the popover's list is selected with the keyboard. %@ is the name or number.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@, selected"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@, ausgewählt"
+ }
+ }
+ }
+ },
+ "Redial": {
+ "comment": "Label of the keypad's call button while the field is empty: it puts the last dialed number in.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Redial"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Wahlwiederholung"
+ }
+ }
+ }
+ },
+ "Loading recent calls": {
+ "comment": "VoiceOver label of the progress indicator while the recent calls are read from the phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loading recent calls"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Anrufliste wird geladen"
+ }
+ }
+ }
+ },
+ "Use the left and right arrow keys or Command-1 to 3 to switch tabs.": {
+ "comment": "VoiceOver hint of the recent calls tabs in the popover.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Use the left and right arrow keys or Command-1 to 3 to switch tabs."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Reiter mit den Pfeiltasten links und rechts oder mit Befehlstaste-1 bis 3 wechseln."
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/CallViewComponents.swift b/App/Sources/Pickup/CallViewComponents.swift
index d71d965..01df4e0 100644
--- a/App/Sources/Pickup/CallViewComponents.swift
+++ b/App/Sources/Pickup/CallViewComponents.swift
@@ -52,6 +52,28 @@ extension Color {
/// symbols on them reach at least 3:1 (WCAG 1.4.11); the regular system green and orange give about 2:1.
static let callGreen = Color(red: 0x24 / 255, green: 0x8A / 255, blue: 0x3D / 255)
static let warningOrange = Color(red: 0xC9 / 255, green: 0x34 / 255, blue: 0x00 / 255)
+
+ // Dialer list colors, light and dark. Measured against popover backgrounds from #E4E4E4 to white
+ // (light) and #1E1E1E to #363636 (dark), also on the selection fill: text reaches 4.5:1 (WCAG
+ // 1.4.3), the selection ring 3:1 (1.4.11). The system red and secondary label stay below that.
+
+ /// Title of a missed call.
+ static let missedRed = adaptive(light: 0xA8221A, dark: 0xFFA39C)
+ /// Second line of a result or recent call row.
+ static let rowSubtitle = adaptive(light: 0x56565B, dark: 0xB4B4B9)
+ /// Outline of the selected row: the state is visible without relying on the faint fill.
+ static let selectionRing = adaptive(light: 0x0064E1, dark: 0x4DA3FF)
+ static let selectionFill = adaptive(light: 0x0064E1, dark: 0x4DA3FF, alpha: 0.15)
+
+ static func adaptive(light: UInt32, dark: UInt32, alpha: CGFloat = 1) -> Color {
+ func color(_ hex: UInt32) -> NSColor {
+ NSColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255, alpha: alpha)
+ }
+ return Color(nsColor: NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? color(dark) : color(light)
+ })
+ }
}
/// A warning or error line. The symbol carries the meaning; the text stays in the primary label color,
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index 53e2e8f..d1e25ff 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -34,6 +34,8 @@ struct DialerView: View {
@State private var keypadText: String? = ""
/// Tab of the recent calls shown while the field is empty (remembered between launches).
@AppStorage("recentCallsList") private var recentListRaw = CallHistoryList.dialed.rawValue
+ /// The tab was changed from the keyboard while a row was selected: select the new tab's first row.
+ @State private var selectFirstRecentRow = false
@FocusState private var fieldFocused: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -68,6 +70,9 @@ struct DialerView: View {
DialerRows.redialNumber(in: model.callHistory.entries(.dialed))
}
+ /// The keypad's call button puts the last dialed number in instead of calling.
+ private var keypadCallsRedial: Bool { queryIsEmpty && redialNumber != nil }
+
private var keypadCallEnabled: Bool {
model.canDial && (callRow != nil || (queryIsEmpty && redialNumber != nil))
}
@@ -112,11 +117,20 @@ struct DialerView: View {
if let reason = unavailableReason, model.guidance == nil {
Text(verbatim: reason).font(.caption).foregroundStyle(.secondary)
}
- if showKeypad {
- keypad
- .transition(reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .top)))
+ if showKeypad || queryIsEmpty {
+ // Keypad and recent calls share one box of the keypad's height: toggling the keypad
+ // cross-fades in place, and loading the calls never resizes the popover.
+ ZStack(alignment: .top) {
+ if showKeypad {
+ keypad.transition(.opacity)
+ }
+ if queryIsEmpty && !showKeypad {
+ recentCalls.transition(.opacity)
+ }
+ }
+ .frame(height: Self.keypadHeight, alignment: .top)
}
- if !queryIsEmpty || !showKeypad {
+ if !queryIsEmpty {
content
}
if let error {
@@ -130,6 +144,11 @@ struct DialerView: View {
DialerKeyRouter.shared.move = { delta in moveSelection(delta) }
DialerKeyRouter.shared.escape = { handleEscape() }
DialerKeyRouter.shared.tab = { backwards in handleTab(backwards: backwards) }
+ DialerKeyRouter.shared.switchTab = { delta in
+ guard let index = CallHistoryList.allCases.firstIndex(of: recentList) else { return false }
+ return selectTab(index + delta)
+ }
+ DialerKeyRouter.shared.selectTab = { index in selectTab(index) }
DialerKeyRouter.shared.physicalKey = { key, down in physicalKey(key, down: down) }
}
.onReceive(NotificationCenter.default.publisher(for: .pickupPopoverWillShow)) { _ in
@@ -152,11 +171,19 @@ struct DialerView: View {
selectedID = nil
pendingConfirmationID = nil
}
- .onChange(of: fieldFocused) { _, focused in if focused { collapseCaret() } }
+ .onChange(of: fieldFocused) { _, focused in
+ if focused {
+ collapseCaret()
+ } else if queryIsEmpty {
+ selectedID = nil // Return goes to the focused control now, not to a recent call
+ }
+ }
.onChange(of: recentListRaw) { _, _ in
- selectedID = nil
pendingConfirmationID = nil
error = nil
+ let keepSelection = selectFirstRecentRow
+ selectFirstRecentRow = false
+ selectedID = keepSelection ? rows.first?.id : nil
}
}
@@ -195,18 +222,62 @@ struct DialerView: View {
/// Tab moves from the field into the list (selects the first row), Shift-Tab back to the field.
/// Typing still goes to the field; Return calls the selected row.
+ ///
+ /// A second Tab keeps its system meaning (with Full Keyboard Access it moves on to the tabs and
+ /// buttons), so the list is never a keyboard trap; leaving the field clears the list selection.
private func handleTab(backwards: Bool) -> Bool {
let rows = self.rows
+ let inList = rows.contains { $0.id == selectedID }
if backwards {
- guard selectedID != nil, queryIsEmpty else { return false }
+ guard inList, queryIsEmpty else { return false }
leaveList()
+ announce(String(localized: "Search field", comment: "VoiceOver announcement when the keyboard leaves the recent calls list and returns to the popover's search field."))
return true
}
- guard !rows.isEmpty, !rows.contains(where: { $0.id == selectedID }) else { return false }
+ guard !rows.isEmpty, !inList else { return false }
selectedID = rows[0].id
+ announceSelection(rows[0])
+ return true
+ }
+
+ /// Switches the recent calls tab from the keyboard (only while the field is empty). A selected
+ /// row moves to the first row of the new tab; VoiceOver hears the tab and that row.
+ private func selectTab(_ index: Int) -> Bool {
+ guard queryIsEmpty else { return false }
+ let lists = CallHistoryList.allCases
+ let target = lists[min(max(index, 0), lists.count - 1)]
+ guard target != recentList else { return true }
+ let wasInList = rows.contains { $0.id == selectedID }
+ selectFirstRecentRow = wasInList
+ recentListRaw = target.rawValue
+ let newRows = DialerRows.recentRows(model.callHistory.entries(target), list: target)
+ let detail = wasInList ? newRows.first.map(selectionText) : nil
+ let tabName = target.localizedTabName
+ announce(newRows.isEmpty
+ ? String(localized: "\(tabName) tab, no calls", comment: "VoiceOver announcement when the recent calls tab is switched from the keyboard and the tab is empty. %@ is the tab name, e.g. \"Missed\".")
+ : detail.map { String(localized: "\(tabName) tab, \($0)", comment: "VoiceOver announcement when the recent calls tab is switched from the keyboard. First %@ is the tab name, second the selected row, e.g. \"Jane Doe, selected\".") }
+ ?? String(localized: "\(tabName) tab", comment: "VoiceOver announcement when the recent calls tab is switched from the keyboard. %@ is the tab name, e.g. \"Missed\"."))
return true
}
+ /// VoiceOver text for a selected row: name (or number) and details.
+ private func selectionText(_ row: DialerRow) -> String {
+ let display = row.display(contacts: model.contacts)
+ if let subtitle = display.subtitle {
+ return String(localized: "\(display.title), \(subtitle), selected", comment: "VoiceOver announcement when a row of the popover's list is selected with the keyboard. First %@ is the name or number, second the details (number, time).")
+ }
+ return String(localized: "\(display.title), selected", comment: "VoiceOver announcement when a row of the popover's list is selected with the keyboard. %@ is the name or number.")
+ }
+
+ /// The text field keeps keyboard focus while rows are selected, so VoiceOver is told explicitly.
+ private func announceSelection(_ row: DialerRow) {
+ announce(selectionText(row))
+ }
+
+ private func announce(_ text: String) {
+ AccessibilityNotification.Announcement(text).post()
+ }
+
private func leaveList() {
selectedID = nil
pendingConfirmationID = nil
@@ -278,6 +349,10 @@ struct DialerView: View {
// MARK: Keypad
+ /// Height of the keypad (five rows of 60 pt keys, 12 pt apart); the recent calls take the same.
+ static let keypadHeight: CGFloat = 5 * 60 + 4 * 12
+ static let callButtonSize: CGFloat = 28
+
private static let keys: [[String]] = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"], ["*", "0", "#"]]
/// One glass key. The tap inserts the digit; a long press on "0" inserts "+" instead.
@@ -346,8 +421,8 @@ struct DialerView: View {
.buttonStyle(.plain)
.disabled(!keypadCallEnabled)
.opacity(keypadCallEnabled ? 1 : 0.45)
- .accessibilityLabel(Text("Call"))
- .help(Text("Call"))
+ .accessibilityLabel(keypadCallsRedial ? Text("Redial", comment: "Label of the keypad's call button while the field is empty: it puts the last dialed number in.") : Text("Call"))
+ .help(keypadCallsRedial ? Text("Redial") : Text("Call"))
.accessibilityIdentifier("keypad-call")
}
.frame(maxWidth: .infinity)
@@ -360,11 +435,9 @@ struct DialerView: View {
private var content: some View {
let rows = self.rows
let hasLetters = text.contains(where: \.isLetter)
- if queryIsEmpty {
- recentCalls
- } else if rows.isEmpty {
+ if rows.isEmpty {
if access == .granted || !hasLetters {
- Text("No matches").font(.callout).foregroundStyle(.secondary).frame(maxWidth: .infinity)
+ Text("No matches").font(.callout).foregroundStyle(Color.rowSubtitle).frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
if access != .granted && hasLetters { accessHint }
@@ -374,7 +447,8 @@ struct DialerView: View {
}
}
- private func resultList(_ rows: [DialerRow]) -> some View {
+ /// `fill`: take the height the parent offers (recent calls) instead of growing with the rows.
+ private func resultList(_ rows: [DialerRow], fill: Bool = false) -> some View {
let selected = selectedIndex
return ScrollViewReader { proxy in
ScrollView {
@@ -382,7 +456,7 @@ struct DialerView: View {
ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in
Button { selectedID = row.id } label: {
ResultRow(row: row.display(contacts: model.contacts), selected: index == selected,
- enabled: model.canDial, trailingInset: row.isRecent ? 40 : 0)
+ enabled: model.canDial, trailingInset: row.isRecent ? Self.callButtonSize + 8 : 0)
}
.buttonStyle(.plain)
.disabled(!model.canDial)
@@ -398,7 +472,7 @@ struct DialerView: View {
}
}
}
- .frame(maxHeight: (showKeypad ? 3 : 6) * 50)
+ .frame(maxHeight: fill ? .infinity : (showKeypad ? 3 : 6) * 50, alignment: .top)
.onChange(of: selectedID) { _, new in
if let new { proxy.scrollTo(new) }
}
@@ -408,20 +482,23 @@ struct DialerView: View {
/// The call button at the end of a recent call row: one click dials (a service code asks first).
private func recentCallButton(_ row: DialerRow) -> some View {
let dialable = row.recentNumber != nil && model.canDial
+ let title = row.display(contacts: model.contacts).title
+ let label = row.recentNumber == nil ? String(localized: "Call") : String(localized: "Call \(title)")
return Button { selectedID = row.id; activate(row) } label: {
Image(systemName: "phone.fill")
.font(.system(size: 13))
.foregroundStyle(.white)
- .frame(width: 28, height: 28)
+ .frame(width: Self.callButtonSize, height: Self.callButtonSize)
.background(Color.callGreen, in: Circle())
+ .frame(width: Self.callButtonSize + 12, height: Self.callButtonSize + 12) // larger hit area than the circle
.contentShape(Circle())
}
.buttonStyle(.plain)
.disabled(!dialable)
.opacity(dialable ? 1 : 0.35)
- .padding(.trailing, 10)
- .accessibilityLabel(Text("Call"))
- .help(Text("Call"))
+ .padding(.trailing, 2) // circle 8 pt from the row's edge, like the avatar on the left
+ .accessibilityLabel(Text(verbatim: label))
+ .help(Text(verbatim: label))
.accessibilityIdentifier(row.testIdentifier + "-call")
}
@@ -432,39 +509,39 @@ struct DialerView: View {
let rows = self.rows
return VStack(spacing: 8) {
Picker(selection: $recentListRaw) {
- Text("Outgoing", comment: "Tab of the recent calls in the popover: calls placed from the phone.")
- .tag(CallHistoryList.dialed.rawValue)
- Text("Incoming", comment: "Tab of the recent calls in the popover: answered incoming calls.")
- .tag(CallHistoryList.received.rawValue)
- Text("Missed", comment: "Tab of the recent calls in the popover: missed incoming calls.")
- .tag(CallHistoryList.missed.rawValue)
+ ForEach(CallHistoryList.allCases, id: \.self) { list in
+ Text(verbatim: list.localizedTabName).tag(list.rawValue)
+ }
} label: {
Text("Recent Calls", comment: "VoiceOver label of the tabs above the recent calls in the popover.")
}
.pickerStyle(.segmented)
.labelsHidden()
.accessibilityIdentifier("recent-tabs")
+ .accessibilityHint(Text("Use the left and right arrow keys or Command-1 to 3 to switch tabs.", comment: "VoiceOver hint of the recent calls tabs in the popover."))
if rows.isEmpty {
+ // Rows stay cached while the phone is asked again; the spinner only shows before the first answer.
Group {
if history.isLoading {
ProgressView().controlSize(.small)
+ .accessibilityLabel(Text("Loading recent calls", comment: "VoiceOver label of the progress indicator while the recent calls are read from the phone."))
} else {
Text("No calls", comment: "Empty state of a recent calls tab in the popover.")
- .font(.callout).foregroundStyle(.secondary)
+ .font(.callout).foregroundStyle(Color.rowSubtitle)
}
}
- .frame(maxWidth: .infinity)
- .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
- resultList(rows)
+ resultList(rows, fill: true)
}
if history.source(recentList) == .session && !history.isLoading && history.phoneRefused {
Text("Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings.",
comment: "Footnote below the recent calls when the phone did not share its call history and Pickup shows its own list.")
- .font(.caption).foregroundStyle(.secondary)
+ .font(.caption).foregroundStyle(Color.rowSubtitle)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 8) // aligned with the rows' content
}
if access != .granted { accessHint }
}
@@ -520,6 +597,7 @@ struct DialerView: View {
let rows = self.rows
guard !rows.isEmpty else { return false }
let index = min(max(selectedIndex + delta, 0), rows.count - 1)
+ if selectedID != rows[index].id { announceSelection(rows[index]) }
selectedID = rows[index].id
return true
}
@@ -588,12 +666,27 @@ final class DialerKeyRouter {
var escape: (() -> Bool)?
/// Tab (false) or Shift-Tab (true); returns true when the dialer used the key.
var tab: ((Bool) -> Bool)?
+ /// Left/right arrow: previous/next recent calls tab (only while the field is empty).
+ var switchTab: ((Int) -> Bool)?
+ /// ⌘1 to ⌘3: recent calls tab by position.
+ var selectTab: ((Int) -> Bool)?
/// A physical dial key (0-9 * #) went down/up; drives the on-screen key highlight.
var physicalKey: ((String, Bool) -> Void)?
}
// MARK: - Rows
+extension CallHistoryList {
+ /// Name of the recent calls tab.
+ var localizedTabName: String {
+ switch self {
+ case .dialed: String(localized: "Outgoing", comment: "Tab of the recent calls in the popover: calls placed from the phone.")
+ case .received: String(localized: "Incoming", comment: "Tab of the recent calls in the popover: answered incoming calls.")
+ case .missed: String(localized: "Missed", comment: "Tab of the recent calls in the popover: missed incoming calls.")
+ }
+ }
+}
+
fileprivate extension DialerRow {
/// UI-test identifier: `call-row`, `contact-row--` or `recent-row--`.
var testIdentifier: String {
@@ -678,10 +771,10 @@ private struct ResultRow: View {
}
VStack(alignment: .leading, spacing: 1) {
Text(verbatim: row.title).font(.body).lineLimit(1).truncationMode(.tail)
- .foregroundStyle(row.isMissed ? Color.red : Color.primary)
+ .foregroundStyle(row.isMissed ? Color.missedRed : Color.primary)
if let subtitle = row.subtitle {
Text(verbatim: subtitle).font(.caption)
- .foregroundStyle(.secondary).lineLimit(1)
+ .foregroundStyle(Color.rowSubtitle).lineLimit(1)
}
}
Spacer(minLength: 0)
@@ -689,7 +782,13 @@ private struct ResultRow: View {
.padding(.leading, 8)
.padding(.trailing, 8 + trailingInset)
.frame(height: 48)
- .background(selected ? Color.accentColor.opacity(0.22) : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .background(selected ? Color.selectionFill : .clear, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .overlay {
+ if selected {
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(Color.selectionRing, lineWidth: 2)
+ }
+ }
.contentShape(Rectangle())
.opacity(enabled ? 1 : 0.5)
.accessibilityElement(children: .combine)
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index 096de06..1bb841f 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -147,9 +147,14 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
private func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in
- guard let self, event.window === self.popover.contentViewController?.view.window,
- event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { return event }
+ guard let self, event.window === self.popover.contentViewController?.view.window else { return event }
let router = DialerKeyRouter.shared
+ // ⌘1 / ⌘2 / ⌘3: recent calls tabs.
+ if event.type == .keyDown, event.modifierFlags.intersection([.command, .option, .control, .shift]) == .command,
+ let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...3).contains(digit) {
+ return (router.selectTab?(digit - 1) ?? false) ? nil : event
+ }
+ guard event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { return event }
// Physical dial keys (main row and numpad) light up the on-screen key; typing is untouched.
if let key = event.characters, key.count == 1, "0123456789*#".contains(key) {
router.physicalKey?(key, event.type == .keyDown)
@@ -159,6 +164,8 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
switch event.keyCode {
case 126: return (router.move?(-1) ?? false) ? nil : event // up
case 125: return (router.move?(1) ?? false) ? nil : event // down
+ case 123: return (router.switchTab?(-1) ?? false) ? nil : event // left: previous recent calls tab
+ case 124: return (router.switchTab?(1) ?? false) ? nil : event // right: next tab
case 48: // tab: into the list, shift-tab: back
return (router.tab?(event.modifierFlags.contains(.shift)) ?? false) ? nil : event
case 53: // esc
diff --git a/App/Tests/PickupAppTests/ColorContrastTests.swift b/App/Tests/PickupAppTests/ColorContrastTests.swift
index be74dc0..043c9ae 100644
--- a/App/Tests/PickupAppTests/ColorContrastTests.swift
+++ b/App/Tests/PickupAppTests/ColorContrastTests.swift
@@ -5,8 +5,12 @@ import XCTest
/// White symbols on the accept/call and warning circles need at least 3:1 (WCAG 1.4.11).
final class ColorContrastTests: XCTestCase {
- private func luminance(_ color: Color) throws -> Double {
- let rgb = try XCTUnwrap(NSColor(color).usingColorSpace(.sRGB))
+ private func luminance(_ color: Color, dark: Bool = false) throws -> Double {
+ var resolved: NSColor?
+ try XCTUnwrap(NSAppearance(named: dark ? .darkAqua : .aqua)).performAsCurrentDrawingAppearance {
+ resolved = NSColor(color).usingColorSpace(.sRGB)
+ }
+ let rgb = try XCTUnwrap(resolved)
func linear(_ c: CGFloat) -> Double {
let c = Double(c)
return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
@@ -22,4 +26,45 @@ final class ColorContrastTests: XCTestCase {
XCTAssertGreaterThanOrEqual(try contrastWithWhite(.callGreen), 3)
XCTAssertGreaterThanOrEqual(try contrastWithWhite(.warningOrange), 3)
}
+
+ // MARK: Dialer list (WCAG 1.4.3 text 4.5:1, 1.4.11 selection outline 3:1)
+
+ private static let lightBackgrounds: [UInt32] = [0xFFFFFF, 0xF0F0F0, 0xE4E4E4]
+ private static let darkBackgrounds: [UInt32] = [0x1E1E1E, 0x2A2A2A, 0x363636]
+
+ private func ratio(_ color: Color, dark: Bool, on background: UInt32) throws -> Double {
+ let bg = Color.adaptive(light: background, dark: background)
+ let a = try luminance(color, dark: dark), b = try luminance(bg, dark: dark)
+ return (max(a, b) + 0.05) / (min(a, b) + 0.05)
+ }
+
+ /// The selection fill (15 % over the background), as the background of a selected row.
+ private func selectedBackground(_ background: UInt32, dark: Bool) -> UInt32 {
+ let fill: UInt32 = dark ? 0x4DA3FF : 0x0064E1
+ func channel(_ shift: UInt32) -> UInt32 {
+ let f = Double((fill >> shift) & 0xFF), b = Double((background >> shift) & 0xFF)
+ return UInt32((f * 0.15 + b * 0.85).rounded()) << shift
+ }
+ return channel(16) | channel(8) | channel(0)
+ }
+
+ func testDialerListTextReachesFourAndAHalfToOne() throws {
+ for (dark, backgrounds) in [(false, Self.lightBackgrounds), (true, Self.darkBackgrounds)] {
+ for background in backgrounds {
+ for surface in [background, selectedBackground(background, dark: dark)] {
+ XCTAssertGreaterThanOrEqual(try ratio(.missedRed, dark: dark, on: surface), 4.5, "missed red on \(String(surface, radix: 16))")
+ XCTAssertGreaterThanOrEqual(try ratio(.rowSubtitle, dark: dark, on: surface), 4.5, "subtitle on \(String(surface, radix: 16))")
+ }
+ }
+ }
+ }
+
+ func testSelectionOutlineReachesThreeToOne() throws {
+ for (dark, backgrounds) in [(false, Self.lightBackgrounds), (true, Self.darkBackgrounds)] {
+ for background in backgrounds {
+ XCTAssertGreaterThanOrEqual(try ratio(.selectionRing, dark: dark, on: background), 3)
+ XCTAssertGreaterThanOrEqual(try ratio(.selectionRing, dark: dark, on: selectedBackground(background, dark: dark)), 3)
+ }
+ }
+ }
}
diff --git a/App/Tests/PickupUITests/Cases/AX.swift b/App/Tests/PickupUITests/Cases/AX.swift
index 32e45fe..557cddc 100644
--- a/App/Tests/PickupUITests/Cases/AX.swift
+++ b/App/Tests/PickupUITests/Cases/AX.swift
@@ -118,6 +118,8 @@ enum Key {
static let escape: CGKeyCode = 53
static let down: CGKeyCode = 125
static let up: CGKeyCode = 126
+ static let left: CGKeyCode = 123
+ static let right: CGKeyCode = 124
static let tab: CGKeyCode = 48
static let space: CGKeyCode = 49
static let comma: CGKeyCode = 43
diff --git a/App/Tests/PickupUITests/Cases/PopoverTests.swift b/App/Tests/PickupUITests/Cases/PopoverTests.swift
index feb03f1..e20d555 100644
--- a/App/Tests/PickupUITests/Cases/PopoverTests.swift
+++ b/App/Tests/PickupUITests/Cases/PopoverTests.swift
@@ -34,6 +34,51 @@ final class PopoverTests: PickupUITestCase {
XCTAssertNotNil(appLog.range(of: #"dial .*123"#, options: .regularExpression))
}
+ func testRecentCallsAreReachedWithTabAndCalledWithReturn() throws {
+ try launch("outgoing-answered")
+ openPopover()
+ XCTAssertTrue(waitExists("recent-row-DC-2"), "the phone's dialed calls show while the field is empty")
+ XCTAssertFalse(element("recent-row-DC-0").isSelected, "no row starts selected: a stray Return never redials")
+ XCTAssertTrue(exists("recent-row-DC-0-call"), "every row has its own call button")
+
+ keyboard.press(Key.tab)
+ XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-0").isSelected }, "Tab moves into the list")
+ keyboard.press(Key.down)
+ XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-1").isSelected })
+ keyboard.press(Key.right)
+ XCTAssertTrue(Wait.until(3) { self.exists("recent-row-MC-0") && self.element("recent-row-MC-0").isSelected },
+ "Right switches to the next tab and keeps the keyboard in the list")
+ keyboard.press(Key.left)
+ XCTAssertTrue(Wait.until(3) { self.exists("recent-row-RC-0") && self.element("recent-row-RC-0").isSelected })
+ keyboard.press(Key.left)
+ XCTAssertTrue(Wait.until(3) { self.exists("recent-row-DC-0") && self.element("recent-row-DC-0").isSelected })
+
+ keyboard.press(Key.escape)
+ XCTAssertTrue(Wait.until(3) { !self.element("recent-row-DC-0").isSelected }, "Esc returns to the search field")
+ XCTAssertTrue(exists("popover-root"), "…and keeps the popover open")
+ XCTAssertFalse(appLog.contains("-> dial()"), "moving through the list never dials")
+
+ keyboard.press(Key.tab)
+ keyboard.press(Key.returnKey)
+ XCTAssertTrue(waitExists("call-hud"), "Return calls the selected recent call")
+ XCTAssertNotNil(appLog.range(of: #"dial .*123"#, options: .regularExpression), "Jane's number was dialed:\n\(appLog)")
+ }
+
+ func testTogglingTheKeypadKeepsThePopoverHeight() throws {
+ try launch("idle")
+ openPopover()
+ XCTAssertTrue(waitExists("recent-row-DC-0"))
+ guard let before = stableFrame("popover-root") else { return XCTFail("no popover frame") }
+ XCTAssertTrue(element("keypad-toggle").press())
+ XCTAssertTrue(waitExists("key-5"))
+ guard let keypad = stableFrame("popover-root") else { return XCTFail("no popover frame") }
+ XCTAssertEqual(keypad.height, before.height, accuracy: 1, "the keypad takes the recent calls' place")
+ XCTAssertTrue(element("keypad-toggle").press())
+ XCTAssertTrue(waitExists("recent-row-DC-0"))
+ guard let after = stableFrame("popover-root") else { return XCTFail("no popover frame") }
+ XCTAssertEqual(after.height, before.height, accuracy: 1)
+ }
+
func testTypingDigitsOpensTheKeypad() throws {
try launch("idle")
openPopover()
From 7a0641a9cd7aac52080ade9e24c5257f7d6da824 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Thu, 29 Oct 2026 12:48:37 +0100
Subject: [PATCH 03/20] fix: never read the call lists while IOBluetooth
finishes the link set-up
A read that started 2 s after the SLC interleaved with IOBluetooth's own
AT+VGS/AT+VGM and crashed IOBluetooth in processResultCode. Reads now
wait 10 s after the SLC and until no command waits for its reply.
Co-Authored-By: Claude Opus 5.5
---
.../PickupBluetooth/AppleHFPTransport.swift | 34 ++++++++++++++++++-
.../AppleHFPTransportTests.swift | 20 +++++++++++
docs/BLUETOOTH.md | 2 ++
3 files changed, 55 insertions(+), 1 deletion(-)
diff --git a/Sources/PickupBluetooth/AppleHFPTransport.swift b/Sources/PickupBluetooth/AppleHFPTransport.swift
index 0e5c464..03f513e 100644
--- a/Sources/PickupBluetooth/AppleHFPTransport.swift
+++ b/Sources/PickupBluetooth/AppleHFPTransport.swift
@@ -51,6 +51,12 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
public var deviceAppearedDelay: TimeInterval = 2.5
/// An SLC that still looks up after an unconfirmed disconnect must answer `AT+CLCC` within this.
public var verifySLC: TimeInterval = 3
+ /// No call list is read this soon after the SLC came up: IOBluetooth still sends its own
+ /// follow-up commands then (`AT+VGS`, `AT+VGM`), and interleaving ours with them crashed
+ /// IOBluetooth on 2026-10-29 (see `readCallHistory`).
+ public var historyQuietPeriod: TimeInterval = 10
+ /// How often a waiting call list read checks again whether the link is idle.
+ public var historyRetry: TimeInterval = 0.5
public init() {}
}
@@ -139,6 +145,9 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
private var historyQueue: [(list: CallHistoryList, count: Int)] = []
/// The call list read in progress: the phonebook is being selected, then read.
private var historyStep: (list: CallHistoryList, count: Int, reading: Bool)?
+ /// When the current SLC came up; call list reads wait `timeouts.historyQuietPeriod` after it.
+ private var slcEstablishedAt: Date?
+ private var historyRetryTask: ScheduledTask?
private var fallbackFlush: ScheduledTask?
private var connectObserver: AnyObject?
@@ -255,6 +264,8 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
verifyTimer = nil
deviceAppearedTask = nil
fallbackFlush = nil
+ historyRetryTask?.cancel()
+ historyRetryTask = nil
connectObserver = nil
// The registry parks the object (closes its link, keeps it alive and listening) and
// remembers a disconnect in flight for the next owner.
@@ -311,8 +322,25 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
/// Starts the next queued call list read: `AT+CPBS=""`, then `AT+CPBR=1,` once the
/// phone selected it (see `historyExchangeEnded`).
+ ///
+ /// Crash of 2026-10-29 (SIGSEGV in `-[IOBluetoothHandsFreeDevice processResultCode:]`): the read
+ /// started 2 s after the SLC, while IOBluetooth's own `AT+VGS`/`AT+VGM` were still due; they went
+ /// out 6 s late and IOBluetooth crashed on the next incoming data. Reads that started after those
+ /// commands never failed. So a read starts only after the quiet period and when no command is
+ /// waiting for its reply.
private func sendNextHistoryCommand() {
- guard historyStep == nil, !historyQueue.isEmpty else { return }
+ guard historyStep == nil, !historyQueue.isEmpty, historyRetryTask == nil else { return }
+ let quietUntil = slcEstablishedAt.map { $0.addingTimeInterval(timeouts.historyQuietPeriod) } ?? scheduler.now
+ if scheduler.now < quietUntil || tracker.pendingCount > 0 {
+ let delay = max(quietUntil.timeIntervalSince(scheduler.now), timeouts.historyRetry)
+ historyRetryTask = scheduler.schedule(after: delay) { [weak self] in
+ guard let self else { return }
+ self.historyRetryTask = nil
+ guard self.linkState == .connected else { return }
+ self.sendNextHistoryCommand()
+ }
+ return
+ }
let next = historyQueue.removeFirst()
historyStep = (next.list, next.count, false)
sendHistoryCommand("AT+CPBS=\"\(next.list.rawValue)\"")
@@ -596,6 +624,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
default:
break
}
+ slcEstablishedAt = scheduler.now
setLinkState(.connected)
unconfirmedDisconnects = 0
setLinkSetupBlocked(false)
@@ -662,6 +691,9 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
tracker.reset()
historyQueue.removeAll()
historyStep = nil
+ historyRetryTask?.cancel()
+ historyRetryTask = nil
+ slcEstablishedAt = nil
}
emit(.linkStateChanged(state))
}
diff --git a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
index 45dc6b4..4fab8de 100644
--- a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
+++ b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
@@ -611,6 +611,7 @@ final class AppleHFPTransportTests: XCTestCase {
func testCallHistoryIsSelectedThenReadOneListAtATime() async throws {
let transport = makeTransport()
let link = connected(transport)
+ clock.advance(by: 10)
try transport.readCallHistory(.dialed, count: 2)
try transport.readCallHistory(.missed, count: 2)
XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+") }, [#"AT+CPBS="DC""#], "the second list waits for the first")
@@ -639,6 +640,24 @@ final class AppleHFPTransportTests: XCTestCase {
XCTAssertEqual(historyEvents.last, .callHistory(.missed, nil))
}
+ /// Regression test for the crash of 2026-10-29 (SIGSEGV in `-[IOBluetoothHandsFreeDevice
+ /// processResultCode:]`): a read interleaved with IOBluetooth's own commands right after the SLC.
+ func testCallHistoryWaitsForTheQuietPeriodAndAnIdleLink() throws {
+ let transport = makeTransport()
+ let link = connected(transport)
+ clock.advance(by: 2)
+ try transport.readCallHistory(.dialed, count: 5)
+ XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "not while IOBluetooth still finishes the set-up")
+
+ // IOBluetooth's own command is still waiting for its reply when the quiet period ends.
+ transport.sentLine("AT+VGS=7", detail: nil, channel: link.channel)
+ clock.advance(by: 8)
+ XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "not while another command waits for its reply")
+ transport.receivedLine("OK", detail: nil)
+ clock.advance(by: 0.5)
+ XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+CPB") }, [#"AT+CPBS="DC""#])
+ }
+
func testCallHistoryNeedsTheWriteHookAndALink() throws {
let untapped = makeTransport(address: "aa-bb-cc-dd-ee-01", assumeOutgoingTap: false)
_ = connected(untapped)
@@ -662,6 +681,7 @@ final class AppleHFPTransportTests: XCTestCase {
again.isConnected = true
transport.handsFree(again, connectedWithStatus: 0)
try transport.readCallHistory(.missed, count: 5)
+ clock.advance(by: 10)
XCTAssertEqual(again.calls.filter { $0.hasPrefix("AT+CPBS") }.last, #"AT+CPBS="MC""#,
"a fresh link starts with the new request, not with the old queue")
}
diff --git a/docs/BLUETOOTH.md b/docs/BLUETOOTH.md
index 0894f74..dd7c0f5 100644
--- a/docs/BLUETOOTH.md
+++ b/docs/BLUETOOTH.md
@@ -16,6 +16,8 @@ Rules for `AppleHFPTransport`:
- **Outgoing AT lines are routed by channel.** The process-wide write hook hands each line to the transport that owns the channel's hands-free object; lines of parked objects are only logged.
- **Diagnostics:** every AT line carries the object tag (`[hf#1]`), and connect lines show how many hands-free objects exist. An `AT+BRSF` on a channel the transport does not own is logged as an error, because that is the signature of this crash. Log lines are written with one `write(2)` each, before IOBluetooth processes the data, so the last line before a crash shows what triggered it.
+- **Call list reads (`AT+CPBS`/`AT+CPBR`) never overlap IOBluetooth's own commands.** On 2026-10-29 a read started 2 s after the SLC, while IOBluetooth's follow-up `AT+VGS`/`AT+VGM` were still due; they went out 6 s late and IOBluetooth crashed in `-[IOBluetoothHandsFreeDevice processResultCode:]` (`objc_retain` on a freed object) on the next incoming data. Reads that started after those commands never failed. A read therefore waits 10 s after the SLC (`Timeouts.historyQuietPeriod`) and until no command waits for its reply, and the two commands of one list are sent strictly one after the other.
+
### Known edge cases to revisit after hardware tests
- Third reconnect review, item L3: deferred on purpose until hardware tests with the first signed build confirm the open IOBluetooth behaviours.
- A `connected(status≠0)` callback (aborted connect of a parked object, or in the transport itself) does not confirm the pending close; the next owner then waits the full 10 s again.
From 51d2e7bf59cb093ecfd8a83bb2ea37b432ec2473 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Thu, 29 Oct 2026 12:56:33 +0100
Subject: [PATCH 04/20] fix: keep the popover layout steady when the keypad is
toggled
- the list has a fixed width, so the legacy scroller shown with a mouse
no longer widens it and pushes the content sideways
- the keypad button keeps one glass (tint changes) without the press
bounce, outside a glass container that made the search field shift
- keys fill the content width, so keypad and lists share side margins
- rows fade out at the bottom instead of being cut
Co-Authored-By: Claude Opus 5.5
---
App/Sources/Pickup/DialerView.swift | 50 ++++++++++++++++++++---------
1 file changed, 35 insertions(+), 15 deletions(-)
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index d1e25ff..535115a 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -288,19 +288,21 @@ struct DialerView: View {
// MARK: Top bar
+ /// No `GlassEffectContainer` here: the button and the capsule are exactly the container's blend
+ /// distance apart, so toggling the button made the glass merge and the capsule visibly shift.
private var topBar: some View {
- GlassEffectContainer(spacing: 8) {
- HStack(spacing: 8) {
+ HStack(spacing: 8) {
Button(action: toggleKeypad) {
Image(systemName: "circle.grid.3x3.fill")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(showKeypad ? Color.white : Color.primary)
.frame(width: 34, height: 34)
- .glassEffect(showKeypad ? .regular.tint(.accentColor).interactive() : .regular.interactive(),
- in: Circle())
+ // One glass whose tint changes (no swap of glass variants), and no interactive
+ // press bounce: the button keeps its size and place when it is toggled.
+ .glassEffect(.regular.tint(showKeypad ? .accentColor : nil), in: Circle())
.contentShape(Circle())
}
- .buttonStyle(.plain)
+ .buttonStyle(SteadyPressStyle())
// Like Apple's toolbar buttons: no focus ring, except for Full Keyboard Access users.
.focusEffectDisabled(!NSApp.isFullKeyboardAccessEnabled)
.accessibilityLabel(Text("Keypad"))
@@ -331,7 +333,6 @@ struct DialerView: View {
.padding(.horizontal, 12)
.frame(height: 34)
.glassEffect(.regular, in: Capsule())
- }
}
}
@@ -349,8 +350,15 @@ struct DialerView: View {
// MARK: Keypad
- /// Height of the keypad (five rows of 60 pt keys, 12 pt apart); the recent calls take the same.
- static let keypadHeight: CGFloat = 5 * 60 + 4 * 12
+ /// Keys fill the content width (popover 300 pt minus 2 × 16 pt), so the keypad has the same side
+ /// margins as the top bar and the lists.
+ static let keySize: CGFloat = 68
+ /// Width of everything between the popover's 16 pt side margins.
+ static let contentWidth: CGFloat = 300 - 2 * 16
+ static let keyColumnSpacing: CGFloat = (contentWidth - 3 * keySize) / 2
+ static let keyRowSpacing: CGFloat = 12
+ /// Height of the keypad (four rows of keys and the call button); the recent calls take the same.
+ static let keypadHeight: CGFloat = 5 * keySize + 4 * keyRowSpacing
static let callButtonSize: CGFloat = 28
private static let keys: [[String]] = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"], ["*", "0", "#"]]
@@ -366,13 +374,13 @@ struct DialerView: View {
} label: {
VStack(spacing: -2) {
Text(verbatim: key)
- .font(.system(size: 24, weight: .regular, design: .rounded))
+ .font(.system(size: 26, weight: .regular, design: .rounded))
if let glyph = KeypadKey.secondaryGlyph(for: key) {
Text(verbatim: glyph).font(.system(size: 12, weight: .medium)).foregroundStyle(.secondary)
.accessibilityIdentifier("key-plus")
}
}
- .frame(width: 60, height: 60)
+ .frame(width: Self.keySize, height: Self.keySize)
.glassEffect(.regular.interactive(), in: Circle())
.overlay(Circle().fill(Color.primary.opacity(pressed ? 0.22 : 0)))
.scaleEffect(pressed ? 0.93 : 1)
@@ -401,10 +409,10 @@ struct DialerView: View {
}
private var keypad: some View {
- GlassEffectContainer(spacing: 12) {
- VStack(spacing: 12) {
+ GlassEffectContainer(spacing: Self.keyRowSpacing) {
+ VStack(spacing: Self.keyRowSpacing) {
ForEach(Self.keys, id: \.self) { row in
- HStack(spacing: 16) {
+ HStack(spacing: Self.keyColumnSpacing) {
ForEach(row, id: \.self) { key in
keypadKey(key)
}
@@ -412,9 +420,9 @@ struct DialerView: View {
}
Button(action: placeTypedCall) {
Image(systemName: "phone.fill")
- .font(.system(size: 24))
+ .font(.system(size: 26))
.foregroundStyle(.white)
- .frame(width: 60, height: 60)
+ .frame(width: Self.keySize, height: Self.keySize)
.glassEffect(.regular.tint(.callGreen).interactive(), in: Circle())
.contentShape(Circle())
}
@@ -472,7 +480,12 @@ struct DialerView: View {
}
}
}
+ // Fixed width: with a mouse attached, macOS shows a legacy scroller that widened the list
+ // by its width, pushed the popover's content 5 pt to the left and made it jump when the
+ // keypad (no scroller) replaced the list.
+ .frame(width: Self.contentWidth)
.frame(maxHeight: fill ? .infinity : (showKeypad ? 3 : 6) * 50, alignment: .top)
+ .scrollEdgeEffectStyle(.soft, for: .bottom) // rows fade out at the bottom instead of being cut
.onChange(of: selectedID) { _, new in
if let new { proxy.scrollTo(new) }
}
@@ -812,3 +825,10 @@ private struct PlusLongPress: ViewModifier {
}
}
}
+
+/// Pressed feedback without moving anything: the label only dims while the mouse is down.
+private struct SteadyPressStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label.opacity(configuration.isPressed ? 0.7 : 1)
+ }
+}
From 30ec53681a6135c6a7b0132a9cc93dd1cc0a6a03 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Thu, 29 Oct 2026 13:12:20 +0100
Subject: [PATCH 05/20] fix: replace the native segmented control that widened
the popover in German
The recent calls tabs are equal segments that always fill the content
width; VoiceOver still sees a segmented picker. Every dialer block now
has the fixed content width, so no child can widen the popover. Keys
are back to 60 pt.
Co-Authored-By: Claude Opus 5.5
---
App/Sources/Pickup/DialerView.swift | 75 ++++++++++++++-----
.../PickupUITests/Cases/PopoverTests.swift | 41 ++++++----
2 files changed, 85 insertions(+), 31 deletions(-)
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index 535115a..1a43bc1 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -108,6 +108,7 @@ struct DialerView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
topBar
+ .frame(width: Self.contentWidth)
if let guidance = model.guidance {
GuidanceCard(guidance: guidance, model: model, openSettings: openSettings)
}
@@ -128,10 +129,11 @@ struct DialerView: View {
recentCalls.transition(.opacity)
}
}
- .frame(height: Self.keypadHeight, alignment: .top)
+ .frame(width: Self.contentWidth, height: Self.keypadHeight, alignment: .top)
}
if !queryIsEmpty {
content
+ .frame(width: Self.contentWidth)
}
if let error {
StatusMessage(text: error, kind: .error)
@@ -350,12 +352,12 @@ struct DialerView: View {
// MARK: Keypad
- /// Keys fill the content width (popover 300 pt minus 2 × 16 pt), so the keypad has the same side
- /// margins as the top bar and the lists.
- static let keySize: CGFloat = 68
- /// Width of everything between the popover's 16 pt side margins.
+ static let keySize: CGFloat = 60
+ /// Width of everything between the popover's 16 pt side margins. Every block of the dialer gets
+ /// exactly this width: a child with a larger ideal width (the native segmented control in German,
+ /// a legacy scroller) used to widen the popover's content and shift it sideways.
static let contentWidth: CGFloat = 300 - 2 * 16
- static let keyColumnSpacing: CGFloat = (contentWidth - 3 * keySize) / 2
+ static let keyColumnSpacing: CGFloat = 16
static let keyRowSpacing: CGFloat = 12
/// Height of the keypad (four rows of keys and the call button); the recent calls take the same.
static let keypadHeight: CGFloat = 5 * keySize + 4 * keyRowSpacing
@@ -374,7 +376,7 @@ struct DialerView: View {
} label: {
VStack(spacing: -2) {
Text(verbatim: key)
- .font(.system(size: 26, weight: .regular, design: .rounded))
+ .font(.system(size: 24, weight: .regular, design: .rounded))
if let glyph = KeypadKey.secondaryGlyph(for: key) {
Text(verbatim: glyph).font(.system(size: 12, weight: .medium)).foregroundStyle(.secondary)
.accessibilityIdentifier("key-plus")
@@ -420,7 +422,7 @@ struct DialerView: View {
}
Button(action: placeTypedCall) {
Image(systemName: "phone.fill")
- .font(.system(size: 26))
+ .font(.system(size: 24))
.foregroundStyle(.white)
.frame(width: Self.keySize, height: Self.keySize)
.glassEffect(.regular.tint(.callGreen).interactive(), in: Circle())
@@ -521,16 +523,8 @@ struct DialerView: View {
let history = model.callHistory
let rows = self.rows
return VStack(spacing: 8) {
- Picker(selection: $recentListRaw) {
- ForEach(CallHistoryList.allCases, id: \.self) { list in
- Text(verbatim: list.localizedTabName).tag(list.rawValue)
- }
- } label: {
- Text("Recent Calls", comment: "VoiceOver label of the tabs above the recent calls in the popover.")
- }
- .pickerStyle(.segmented)
- .labelsHidden()
- .accessibilityIdentifier("recent-tabs")
+ RecentCallsTabs(selection: $recentListRaw)
+ .accessibilityIdentifier("recent-tabs")
.accessibilityHint(Text("Use the left and right arrow keys or Command-1 to 3 to switch tabs.", comment: "VoiceOver hint of the recent calls tabs in the popover."))
if rows.isEmpty {
@@ -832,3 +826,48 @@ private struct SteadyPressStyle: ButtonStyle {
configuration.label.opacity(configuration.isPressed ? 0.7 : 1)
}
}
+
+/// Tabs above the recent calls: equal segments that always fill the given width exactly. The native
+/// segmented control sizes itself from its labels and grew wider than the popover with the German
+/// texts, which shifted the whole content. VoiceOver still gets a real segmented picker.
+private struct RecentCallsTabs: View {
+ @Binding var selection: String
+
+ var body: some View {
+ HStack(spacing: 2) {
+ ForEach(CallHistoryList.allCases, id: \.self) { list in
+ let selected = selection == list.rawValue
+ Button { selection = list.rawValue } label: {
+ Text(verbatim: list.localizedTabName)
+ .font(.callout.weight(selected ? .semibold : .regular))
+ .lineLimit(1)
+ .minimumScaleFactor(0.75)
+ .frame(maxWidth: .infinity)
+ .frame(height: 24)
+ .background {
+ if selected {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color.selectionFill)
+ .strokeBorder(Color.selectionRing, lineWidth: 1.5)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(2)
+ .background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .frame(maxWidth: .infinity)
+ .accessibilityRepresentation {
+ Picker(selection: $selection) {
+ ForEach(CallHistoryList.allCases, id: \.self) { list in
+ Text(verbatim: list.localizedTabName).tag(list.rawValue)
+ }
+ } label: {
+ Text("Recent Calls", comment: "VoiceOver label of the tabs above the recent calls in the popover.")
+ }
+ .pickerStyle(.segmented)
+ }
+ }
+}
diff --git a/App/Tests/PickupUITests/Cases/PopoverTests.swift b/App/Tests/PickupUITests/Cases/PopoverTests.swift
index e20d555..9642366 100644
--- a/App/Tests/PickupUITests/Cases/PopoverTests.swift
+++ b/App/Tests/PickupUITests/Cases/PopoverTests.swift
@@ -64,19 +64,34 @@ final class PopoverTests: PickupUITestCase {
XCTAssertNotNil(appLog.range(of: #"dial .*123"#, options: .regularExpression), "Jane's number was dialed:\n\(appLog)")
}
- func testTogglingTheKeypadKeepsThePopoverHeight() throws {
- try launch("idle")
- openPopover()
- XCTAssertTrue(waitExists("recent-row-DC-0"))
- guard let before = stableFrame("popover-root") else { return XCTFail("no popover frame") }
- XCTAssertTrue(element("keypad-toggle").press())
- XCTAssertTrue(waitExists("key-5"))
- guard let keypad = stableFrame("popover-root") else { return XCTFail("no popover frame") }
- XCTAssertEqual(keypad.height, before.height, accuracy: 1, "the keypad takes the recent calls' place")
- XCTAssertTrue(element("keypad-toggle").press())
- XCTAssertTrue(waitExists("recent-row-DC-0"))
- guard let after = stableFrame("popover-root") else { return XCTFail("no popover frame") }
- XCTAssertEqual(after.height, before.height, accuracy: 1)
+ /// Neither the popover nor the top bar may move when the keypad replaces the recent calls, in
+ /// English and in German (the native segmented control used to widen the content in German).
+ func testTogglingTheKeypadKeepsTheLayoutSteady() throws {
+ for language in ["en", "de"] {
+ try launch("idle", language: language)
+ openPopover()
+ XCTAssertTrue(waitExists("recent-row-DC-0"))
+ guard let before = stableFrame("popover-root"), let field = stableFrame("search-field"),
+ let tabs = stableFrame("recent-tabs") else { return XCTFail("[\(language)] no frames") }
+ XCTAssertEqual(tabs.minX, before.minX + 16, accuracy: 1, "[\(language)] tabs start at the content margin")
+ XCTAssertEqual(tabs.maxX, before.maxX - 16, accuracy: 1, "[\(language)] tabs end at the content margin")
+ XCTAssertTrue(element("keypad-toggle").press())
+ XCTAssertTrue(waitExists("key-5"))
+ guard let keypad = stableFrame("popover-root"), let keypadField = stableFrame("search-field") else {
+ return XCTFail("[\(language)] no frames")
+ }
+ XCTAssertEqual(keypad.height, before.height, accuracy: 1, "[\(language)] the keypad takes the recent calls' place")
+ XCTAssertEqual(keypadField.minX, field.minX, accuracy: 0.5, "[\(language)] the search field does not move")
+ XCTAssertEqual(keypadField.width, field.width, accuracy: 0.5, "[\(language)] the search field keeps its width")
+ XCTAssertTrue(element("keypad-toggle").press())
+ XCTAssertTrue(waitExists("recent-row-DC-0"))
+ guard let after = stableFrame("popover-root"), let afterField = stableFrame("search-field") else {
+ return XCTFail("[\(language)] no frames")
+ }
+ XCTAssertEqual(after.height, before.height, accuracy: 1)
+ XCTAssertEqual(afterField.minX, field.minX, accuracy: 0.5)
+ terminateApp()
+ }
}
func testTypingDigitsOpensTheKeypad() throws {
From 98789dea1ef4b9189fd15ec9f043d32dc13a3c8e Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Thu, 29 Oct 2026 13:16:50 +0100
Subject: [PATCH 06/20] style: fill the active recent calls tab with the accent
color
Co-Authored-By: Claude Opus 5.5
---
App/Sources/Pickup/CallViewComponents.swift | 4 ++++
App/Sources/Pickup/DialerView.swift | 6 +++---
App/Tests/PickupAppTests/ColorContrastTests.swift | 7 +++++++
3 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/App/Sources/Pickup/CallViewComponents.swift b/App/Sources/Pickup/CallViewComponents.swift
index 01df4e0..703c142 100644
--- a/App/Sources/Pickup/CallViewComponents.swift
+++ b/App/Sources/Pickup/CallViewComponents.swift
@@ -64,6 +64,10 @@ extension Color {
/// Outline of the selected row: the state is visible without relying on the faint fill.
static let selectionRing = adaptive(light: 0x0064E1, dark: 0x4DA3FF)
static let selectionFill = adaptive(light: 0x0064E1, dark: 0x4DA3FF, alpha: 0.15)
+ /// Active recent calls tab: the system's selection color, derived from the user's accent color
+ /// (darker than the accent itself, so the white label reaches 4.5:1), with its label color.
+ static let activeSegment = Color(nsColor: .selectedContentBackgroundColor)
+ static let activeSegmentText = Color(nsColor: .alternateSelectedControlTextColor)
static func adaptive(light: UInt32, dark: UInt32, alpha: CGFloat = 1) -> Color {
func color(_ hex: UInt32) -> NSColor {
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index 1a43bc1..9adc4fe 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -840,15 +840,15 @@ private struct RecentCallsTabs: View {
Button { selection = list.rawValue } label: {
Text(verbatim: list.localizedTabName)
.font(.callout.weight(selected ? .semibold : .regular))
+ .foregroundStyle(selected ? Color.activeSegmentText : Color.primary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
.frame(height: 24)
.background {
+ // Active tab like a native segmented control: filled with the accent color.
if selected {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color.selectionFill)
- .strokeBorder(Color.selectionRing, lineWidth: 1.5)
+ RoundedRectangle(cornerRadius: 6, style: .continuous).fill(Color.activeSegment)
}
}
.contentShape(Rectangle())
diff --git a/App/Tests/PickupAppTests/ColorContrastTests.swift b/App/Tests/PickupAppTests/ColorContrastTests.swift
index 043c9ae..6a27809 100644
--- a/App/Tests/PickupAppTests/ColorContrastTests.swift
+++ b/App/Tests/PickupAppTests/ColorContrastTests.swift
@@ -67,4 +67,11 @@ final class ColorContrastTests: XCTestCase {
}
}
}
+
+ func testActiveTabLabelReachesFourAndAHalfToOne() throws {
+ for dark in [false, true] {
+ let a = try luminance(.activeSegmentText, dark: dark), b = try luminance(.activeSegment, dark: dark)
+ XCTAssertGreaterThanOrEqual((max(a, b) + 0.05) / (min(a, b) + 0.05), 4.5, dark ? "dark" : "light")
+ }
+ }
}
From d7f9c5aa49617bd755dd458b4f1070b0de94230a Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Thu, 29 Oct 2026 13:24:07 +0100
Subject: [PATCH 07/20] fix: Return calls the selected recent call wherever
keyboard focus is
Return is handled in the popover's key monitor when a row is selected,
and rows no longer take keyboard focus, so only one outline shows.
Co-Authored-By: Claude Opus 5.5
---
App/Sources/Pickup/DialerView.swift | 16 ++++++++++++++++
App/Sources/Pickup/StatusItemController.swift | 2 ++
2 files changed, 18 insertions(+)
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index 9adc4fe..ca5316a 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -151,6 +151,7 @@ struct DialerView: View {
return selectTab(index + delta)
}
DialerKeyRouter.shared.selectTab = { index in selectTab(index) }
+ DialerKeyRouter.shared.submit = { submitSelectedRow() }
DialerKeyRouter.shared.physicalKey = { key, down in physicalKey(key, down: down) }
}
.onReceive(NotificationCenter.default.publisher(for: .pickupPopoverWillShow)) { _ in
@@ -469,6 +470,10 @@ struct DialerView: View {
enabled: model.canDial, trailingInset: row.isRecent ? Self.callButtonSize + 8 : 0)
}
.buttonStyle(.plain)
+ // Rows never take keyboard focus: the selection outline is their only indicator
+ // (a second, system focus ring looked like two selections). The keyboard reaches
+ // them with Tab and the arrow keys from the field; the call buttons stay focusable.
+ .focusable(false)
.disabled(!model.canDial)
.id(row.id)
.accessibilityIdentifier(row.testIdentifier)
@@ -606,6 +611,15 @@ struct DialerView: View {
let index = min(max(selectedIndex + delta, 0), rows.count - 1)
if selectedID != rows[index].id { announceSelection(rows[index]) }
selectedID = rows[index].id
+ fieldFocused = true // arrow keys bring keyboard focus back from any button to the field
+ return true
+ }
+
+ /// Return with an explicitly selected row (arrow keys, Tab, click): call it. Handled in the key
+ /// monitor, so it works even when keyboard focus is not in the text field.
+ private func submitSelectedRow() -> Bool {
+ guard let row = rows.first(where: { $0.id == selectedID }) else { return false }
+ activate(row)
return true
}
@@ -677,6 +691,8 @@ final class DialerKeyRouter {
var switchTab: ((Int) -> Bool)?
/// ⌘1 to ⌘3: recent calls tab by position.
var selectTab: ((Int) -> Bool)?
+ /// Return/Enter: calls the selected row, whichever control has keyboard focus; false = no row selected.
+ var submit: (() -> Bool)?
/// A physical dial key (0-9 * #) went down/up; drives the on-screen key highlight.
var physicalKey: ((String, Bool) -> Void)?
}
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index 1bb841f..1485a6d 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -166,6 +166,8 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
case 125: return (router.move?(1) ?? false) ? nil : event // down
case 123: return (router.switchTab?(-1) ?? false) ? nil : event // left: previous recent calls tab
case 124: return (router.switchTab?(1) ?? false) ? nil : event // right: next tab
+ case 36, 76: // return / enter: call the selected row
+ return (router.submit?() ?? false) ? nil : event
case 48: // tab: into the list, shift-tab: back
return (router.tab?(event.modifierFlags.contains(.shift)) ?? false) ? nil : event
case 53: // esc
From 99a3a047255ffb1bda00fe3998fadb988aa34f72 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 09:47:56 +0100
Subject: [PATCH 08/20] feat: natural Tab order, Down to select and Command-C
to copy in the recent calls
Tab now follows the system order from the search field to the tabs and then the
list, each one focus stop (also without Full Keyboard Access). Down from the
field selects the first row like in the search results, Command-C copies the
selected number, rows get a Call / Copy Number context menu, and typing while
the tabs or the list have focus continues in the search field.
Co-Authored-By: Claude Opus 5.5
---
App/Resources/Localizable.xcstrings | 54 +++++++
App/Sources/Pickup/DialerRows.swift | 24 +++
App/Sources/Pickup/DialerView.swift | 146 ++++++++++++------
App/Sources/Pickup/StatusItemController.swift | 14 +-
.../PickupAppTests/RecentCallRowsTests.swift | 15 ++
App/Tests/PickupUITests/Cases/AX.swift | 1 +
.../PickupUITests/Cases/PopoverTests.swift | 30 +++-
7 files changed, 228 insertions(+), 56 deletions(-)
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index b0d0075..bba60c1 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -2736,6 +2736,60 @@
}
}
}
+ },
+ "Copy Number": {
+ "comment": "Context menu item of a row in the popover's list: copies the phone number.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Copy Number"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nummer kopieren"
+ }
+ }
+ }
+ },
+ "Search Results": {
+ "comment": "VoiceOver label of the list of contacts and numbers matching the popover's search field.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Search Results"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Suchergebnisse"
+ }
+ }
+ }
+ },
+ "Number copied": {
+ "comment": "VoiceOver announcement after a phone number from the popover's list was copied with Command-C or the context menu.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Number copied"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nummer kopiert"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/DialerRows.swift b/App/Sources/Pickup/DialerRows.swift
index 80c726f..cfdffec 100644
--- a/App/Sources/Pickup/DialerRows.swift
+++ b/App/Sources/Pickup/DialerRows.swift
@@ -53,3 +53,27 @@ enum DialerRows {
entries.first { DialNumber($0.number) != nil }?.number
}
}
+
+extension DialerRow {
+ /// What ⌘C and "Copy Number" put on the pasteboard: the number as the row knows it; nil for a
+ /// withheld caller.
+ var copyableNumber: String? {
+ switch kind {
+ case .call(let number): number.value
+ case .contact(let result): result.number
+ case .recent(let entry, _, _): entry.number.isEmpty ? nil : entry.number
+ }
+ }
+}
+
+/// Typing while the tabs or the list have keyboard focus goes on in the search field.
+enum TypeAhead {
+ /// True for printable text; false for whitespace, control characters and the function keys'
+ /// private-use characters (arrows, Page Up, …).
+ static func accepts(_ characters: String) -> Bool {
+ guard !characters.isEmpty, !characters.allSatisfy(\.isWhitespace) else { return false }
+ return characters.unicodeScalars.allSatisfy { scalar in
+ !CharacterSet.controlCharacters.contains(scalar) && !(0xF700...0xF8FF).contains(scalar.value)
+ }
+ }
+}
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index ca5316a..20f6491 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -36,7 +36,10 @@ struct DialerView: View {
@AppStorage("recentCallsList") private var recentListRaw = CallHistoryList.dialed.rawValue
/// The tab was changed from the keyboard while a row was selected: select the new tab's first row.
@State private var selectFirstRecentRow = false
- @FocusState private var fieldFocused: Bool
+ /// Keyboard focus. Tab runs field → tabs → list in the system order (also without Full Keyboard
+ /// Access); the tabs and the list are one focus stop each.
+ private enum Focus: Hashable { case field, tabs, list }
+ @FocusState private var focus: Focus?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// MARK: Derived state
@@ -145,13 +148,14 @@ struct DialerView: View {
focusFieldSoon()
DialerKeyRouter.shared.move = { delta in moveSelection(delta) }
DialerKeyRouter.shared.escape = { handleEscape() }
- DialerKeyRouter.shared.tab = { backwards in handleTab(backwards: backwards) }
DialerKeyRouter.shared.switchTab = { delta in
guard let index = CallHistoryList.allCases.firstIndex(of: recentList) else { return false }
return selectTab(index + delta)
}
DialerKeyRouter.shared.selectTab = { index in selectTab(index) }
DialerKeyRouter.shared.submit = { submitSelectedRow() }
+ DialerKeyRouter.shared.copy = { copySelectedNumber() }
+ DialerKeyRouter.shared.typeText = { characters in typeIntoField(characters) }
DialerKeyRouter.shared.physicalKey = { key, down in physicalKey(key, down: down) }
}
.onReceive(NotificationCenter.default.publisher(for: .pickupPopoverWillShow)) { _ in
@@ -174,11 +178,19 @@ struct DialerView: View {
selectedID = nil
pendingConfirmationID = nil
}
- .onChange(of: fieldFocused) { _, focused in
- if focused {
+ .onChange(of: focus) { _, new in
+ switch new {
+ case .field:
collapseCaret()
- } else if queryIsEmpty {
- selectedID = nil // Return goes to the focused control now, not to a recent call
+ case .list:
+ // Entering the list (Tab, ↓ from the tabs) selects its first row, so Return calls it.
+ let rows = self.rows
+ if !rows.contains(where: { $0.id == selectedID }), let first = rows.first {
+ selectedID = first.id
+ announceSelection(first)
+ }
+ case .tabs, nil:
+ if queryIsEmpty { selectedID = nil } // Return goes to the focused control now, not to a recent call
}
}
.onChange(of: recentListRaw) { _, _ in
@@ -194,7 +206,7 @@ struct DialerView: View {
private func focusFieldSoon() {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(200))
- fieldFocused = true
+ focus = .field
collapseCaret()
}
}
@@ -210,10 +222,10 @@ struct DialerView: View {
}
}
- /// Esc: leave the recent calls list first, then clear the field; with an empty field the popover
- /// closes (return false).
+ /// Esc: back to the search field from the tabs, the list or a selected row first, then clear the
+ /// field; with an empty field the popover closes (return false).
private func handleEscape() -> Bool {
- if queryIsEmpty, selectedID != nil {
+ if focus == .tabs || focus == .list || (queryIsEmpty && selectedID != nil) {
leaveList()
return true
}
@@ -223,26 +235,6 @@ struct DialerView: View {
return true
}
- /// Tab moves from the field into the list (selects the first row), Shift-Tab back to the field.
- /// Typing still goes to the field; Return calls the selected row.
- ///
- /// A second Tab keeps its system meaning (with Full Keyboard Access it moves on to the tabs and
- /// buttons), so the list is never a keyboard trap; leaving the field clears the list selection.
- private func handleTab(backwards: Bool) -> Bool {
- let rows = self.rows
- let inList = rows.contains { $0.id == selectedID }
- if backwards {
- guard inList, queryIsEmpty else { return false }
- leaveList()
- announce(String(localized: "Search field", comment: "VoiceOver announcement when the keyboard leaves the recent calls list and returns to the popover's search field."))
- return true
- }
- guard !rows.isEmpty, !inList else { return false }
- selectedID = rows[0].id
- announceSelection(rows[0])
- return true
- }
-
/// Switches the recent calls tab from the keyboard (only while the field is empty). A selected
/// row moves to the first row of the new tab; VoiceOver hears the tab and that row.
private func selectTab(_ index: Int) -> Bool {
@@ -272,7 +264,7 @@ struct DialerView: View {
return String(localized: "\(display.title), selected", comment: "VoiceOver announcement when a row of the popover's list is selected with the keyboard. %@ is the name or number.")
}
- /// The text field keeps keyboard focus while rows are selected, so VoiceOver is told explicitly.
+ /// The text field or the list keeps keyboard focus while rows are selected, so VoiceOver is told explicitly.
private func announceSelection(_ row: DialerRow) {
announce(selectionText(row))
}
@@ -285,7 +277,7 @@ struct DialerView: View {
selectedID = nil
pendingConfirmationID = nil
error = nil
- fieldFocused = true
+ focus = .field
collapseCaret()
}
@@ -319,12 +311,12 @@ struct DialerView: View {
.accessibilityHidden(true)
TextField("Search", text: $text)
.textFieldStyle(.plain)
- .focused($fieldFocused)
+ .focused($focus, equals: .field)
.onSubmit(submitFromReturn)
.accessibilityIdentifier("search-field")
.accessibilityLabel(Text("Search contacts or enter a number", comment: "VoiceOver label of the popover's search field."))
if !text.isEmpty {
- Button { text = ""; keypadText = ""; fieldFocused = true } label: {
+ Button { text = ""; keypadText = ""; focus = .field } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
@@ -348,7 +340,7 @@ struct DialerView: View {
} else {
manuallyOpen = true
}
- fieldFocused = true
+ focus = .field
}
// MARK: Keypad
@@ -470,9 +462,8 @@ struct DialerView: View {
enabled: model.canDial, trailingInset: row.isRecent ? Self.callButtonSize + 8 : 0)
}
.buttonStyle(.plain)
- // Rows never take keyboard focus: the selection outline is their only indicator
- // (a second, system focus ring looked like two selections). The keyboard reaches
- // them with Tab and the arrow keys from the field; the call buttons stay focusable.
+ // Rows never take keyboard focus: the list is one focus stop and the selection
+ // outline its only indicator (a second, system focus ring looked like two selections).
.focusable(false)
.disabled(!model.canDial)
.id(row.id)
@@ -481,6 +472,7 @@ struct DialerView: View {
.simultaneousGesture(TapGesture(count: 2).onEnded { activate(row) })
.accessibilityAction(.default) { activate(row) }
.accessibilityHint(Text("Double-click or press Return to call.", comment: "VoiceOver hint of a search result row in the popover."))
+ .contextMenu { rowMenu(row) }
.overlay(alignment: .trailing) {
if row.isRecent { recentCallButton(row) }
}
@@ -493,6 +485,14 @@ struct DialerView: View {
.frame(width: Self.contentWidth)
.frame(maxHeight: fill ? .infinity : (showKeypad ? 3 : 6) * 50, alignment: .top)
.scrollEdgeEffectStyle(.soft, for: .bottom) // rows fade out at the bottom instead of being cut
+ // One focus stop for the whole list: ↑/↓ select, Return calls, ⌘C copies. The selected row's
+ // outline is the focus indicator (no second system ring).
+ .focusable(interactions: .edit)
+ .focused($focus, equals: .list)
+ .focusEffectDisabled()
+ .accessibilityLabel(queryIsEmpty
+ ? Text("Recent Calls")
+ : Text("Search Results", comment: "VoiceOver label of the list of contacts and numbers matching the popover's search field."))
.onChange(of: selectedID) { _, new in
if let new { proxy.scrollTo(new) }
}
@@ -514,6 +514,7 @@ struct DialerView: View {
.contentShape(Circle())
}
.buttonStyle(.plain)
+ .focusable(false) // Return calls the selected row; one Tab stop per row would make the list a long detour
.disabled(!dialable)
.opacity(dialable ? 1 : 0.35)
.padding(.trailing, 2) // circle 8 pt from the row's edge, like the avatar on the left
@@ -522,15 +523,30 @@ struct DialerView: View {
.accessibilityIdentifier(row.testIdentifier + "-call")
}
+ @ViewBuilder
+ private func rowMenu(_ row: DialerRow) -> some View {
+ Button("Call") { selectedID = row.id; activate(row) }
+ .disabled(!model.canDial)
+ Button {
+ copy(row)
+ } label: {
+ Text("Copy Number", comment: "Context menu item of a row in the popover's list: copies the phone number.")
+ }
+ .disabled(row.copyableNumber == nil)
+ }
+
// MARK: Recent calls
private var recentCalls: some View {
let history = model.callHistory
let rows = self.rows
return VStack(spacing: 8) {
- RecentCallsTabs(selection: $recentListRaw)
+ RecentCallsTabs(selection: $recentListRaw, focused: focus == .tabs)
+ .focusable(interactions: .edit)
+ .focused($focus, equals: .tabs)
+ .focusEffectDisabled() // RecentCallsTabs draws its own ring
.accessibilityIdentifier("recent-tabs")
- .accessibilityHint(Text("Use the left and right arrow keys or Command-1 to 3 to switch tabs.", comment: "VoiceOver hint of the recent calls tabs in the popover."))
+ .accessibilityHint(Text("Use the left and right arrow keys or Command-1 to 3 to switch tabs.", comment: "VoiceOver hint of the recent calls tabs in the popover."))
if rows.isEmpty {
// Rows stay cached while the phone is asked again; the spinner only shows before the first answer.
@@ -601,17 +617,47 @@ struct DialerView: View {
private func appendKey(_ key: String) {
text.append(key)
keypadText = text
- fieldFocused = true
+ focus = .field
collapseCaret()
}
private func moveSelection(_ delta: Int) -> Bool {
let rows = self.rows
guard !rows.isEmpty else { return false }
+ if focus == .tabs {
+ guard delta > 0 else { return false }
+ focus = .list // ↓ from the tabs enters the list at its first row
+ return true
+ }
let index = min(max(selectedIndex + delta, 0), rows.count - 1)
if selectedID != rows[index].id { announceSelection(rows[index]) }
selectedID = rows[index].id
- fieldFocused = true // arrow keys bring keyboard focus back from any button to the field
+ if focus != .list { focus = .field } // arrow keys bring keyboard focus back from any button to the field
+ return true
+ }
+
+ /// ⌘C on a selected row copies its number. A search text in the field keeps the normal copy.
+ private func copySelectedNumber() -> Bool {
+ guard focus != .tabs, focus != .field || queryIsEmpty else { return false }
+ let rows = self.rows
+ guard rows.indices.contains(selectedIndex), rows[selectedIndex].copyableNumber != nil else { return false }
+ copy(rows[selectedIndex])
+ return true
+ }
+
+ private func copy(_ row: DialerRow) {
+ guard let number = row.copyableNumber else { return }
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(number, forType: .string)
+ announce(String(localized: "Number copied", comment: "VoiceOver announcement after a phone number from the popover's list was copied with Command-C or the context menu."))
+ }
+
+ /// Typing while the tabs or the list have focus continues the search in the field.
+ private func typeIntoField(_ characters: String) -> Bool {
+ guard focus == .tabs || focus == .list, TypeAhead.accepts(characters) else { return false }
+ text.append(characters)
+ focus = .field
+ collapseCaret()
return true
}
@@ -636,7 +682,7 @@ struct DialerView: View {
if queryIsEmpty, let number = redialNumber {
text = number
keypadText = nil // came from the phone, not from the keypad: service codes need a confirmation
- fieldFocused = true
+ focus = .field
collapseCaret()
return
}
@@ -685,14 +731,16 @@ final class DialerKeyRouter {
static let shared = DialerKeyRouter()
var move: ((Int) -> Bool)?
var escape: (() -> Bool)?
- /// Tab (false) or Shift-Tab (true); returns true when the dialer used the key.
- var tab: ((Bool) -> Bool)?
/// Left/right arrow: previous/next recent calls tab (only while the field is empty).
var switchTab: ((Int) -> Bool)?
/// ⌘1 to ⌘3: recent calls tab by position.
var selectTab: ((Int) -> Bool)?
/// Return/Enter: calls the selected row, whichever control has keyboard focus; false = no row selected.
var submit: (() -> Bool)?
+ /// ⌘C: copies the selected row's number; false = the system copy applies.
+ var copy: (() -> Bool)?
+ /// Printable text typed while the tabs or the list have focus; true = it went to the search field.
+ var typeText: ((String) -> Bool)?
/// A physical dial key (0-9 * #) went down/up; drives the on-screen key highlight.
var physicalKey: ((String, Bool) -> Void)?
}
@@ -848,6 +896,8 @@ private struct SteadyPressStyle: ButtonStyle {
/// texts, which shifted the whole content. VoiceOver still gets a real segmented picker.
private struct RecentCallsTabs: View {
@Binding var selection: String
+ /// Keyboard focus is on the tabs (←/→ switch): drawn as a ring around the control.
+ var focused = false
var body: some View {
HStack(spacing: 2) {
@@ -870,10 +920,16 @@ private struct RecentCallsTabs: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+ .focusable(false) // the control is one focus stop; ←/→ switch the tab
}
}
.padding(2)
.background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .overlay {
+ if focused {
+ RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(Color.selectionRing, lineWidth: 2)
+ }
+ }
.frame(maxWidth: .infinity)
.accessibilityRepresentation {
Picker(selection: $selection) {
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index 1485a6d..6274aa0 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -154,13 +154,21 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...3).contains(digit) {
return (router.selectTab?(digit - 1) ?? false) ? nil : event
}
+ // ⌘C on a selected row copies its number (otherwise the normal copy).
+ if event.type == .keyDown, event.modifierFlags.intersection([.command, .option, .control, .shift]) == .command,
+ event.charactersIgnoringModifiers?.lowercased() == "c" {
+ return (router.copy?() ?? false) ? nil : event
+ }
guard event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { return event }
// Physical dial keys (main row and numpad) light up the on-screen key; typing is untouched.
- if let key = event.characters, key.count == 1, "0123456789*#".contains(key) {
+ let isDialKey = event.characters.map { $0.count == 1 && "0123456789*#".contains($0) } ?? false
+ if isDialKey, let key = event.characters {
router.physicalKey?(key, event.type == .keyDown)
- return event
}
guard event.type == .keyDown else { return event }
+ // Typing while the tabs or the list have focus goes on in the search field.
+ if let characters = event.characters, router.typeText?(characters) == true { return nil }
+ if isDialKey { return event }
switch event.keyCode {
case 126: return (router.move?(-1) ?? false) ? nil : event // up
case 125: return (router.move?(1) ?? false) ? nil : event // down
@@ -168,8 +176,6 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
case 124: return (router.switchTab?(1) ?? false) ? nil : event // right: next tab
case 36, 76: // return / enter: call the selected row
return (router.submit?() ?? false) ? nil : event
- case 48: // tab: into the list, shift-tab: back
- return (router.tab?(event.modifierFlags.contains(.shift)) ?? false) ? nil : event
case 53: // esc
if router.escape?() == true { return nil }
self.closePopover()
diff --git a/App/Tests/PickupAppTests/RecentCallRowsTests.swift b/App/Tests/PickupAppTests/RecentCallRowsTests.swift
index ca713e0..647730c 100644
--- a/App/Tests/PickupAppTests/RecentCallRowsTests.swift
+++ b/App/Tests/PickupAppTests/RecentCallRowsTests.swift
@@ -27,4 +27,19 @@ final class RecentCallRowsTests: XCTestCase {
CallHistoryEntry(number: "+49300000456")]), "+49300000123")
XCTAssertNil(DialerRows.redialNumber(in: []))
}
+
+ func testCopyableNumber() {
+ let rows = DialerRows.recentRows([CallHistoryEntry(number: "+49300000123", name: "Jane Doe"), CallHistoryEntry(number: "")],
+ list: .missed)
+ XCTAssertEqual(rows.map(\.copyableNumber), ["+49300000123", nil], "a withheld caller has nothing to copy")
+ XCTAssertEqual(DialerRows.rows(for: "+49 30 0000123", contacts: [], fromKeypad: false).first?.copyableNumber,
+ DialNumber("+49 30 0000123")?.value)
+ }
+
+ func testTypeAheadAcceptsOnlyPrintableText() {
+ for text in ["J", "j", "5", "+", "*", "#", "ä"] { XCTAssertTrue(TypeAhead.accepts(text), text) }
+ for text in ["", " ", "\t", "\r", "\u{1B}", "\u{F700}", "\u{F701}", "\u{7F}"] {
+ XCTAssertFalse(TypeAhead.accepts(text), text.debugDescription)
+ }
+ }
}
diff --git a/App/Tests/PickupUITests/Cases/AX.swift b/App/Tests/PickupUITests/Cases/AX.swift
index 557cddc..bdaf098 100644
--- a/App/Tests/PickupUITests/Cases/AX.swift
+++ b/App/Tests/PickupUITests/Cases/AX.swift
@@ -125,6 +125,7 @@ enum Key {
static let comma: CGKeyCode = 43
static let q: CGKeyCode = 12
static let w: CGKeyCode = 13
+ static let c: CGKeyCode = 8
}
/// Keyboard events posted straight to the process (they do not go to whatever app is in front).
diff --git a/App/Tests/PickupUITests/Cases/PopoverTests.swift b/App/Tests/PickupUITests/Cases/PopoverTests.swift
index 9642366..58345e6 100644
--- a/App/Tests/PickupUITests/Cases/PopoverTests.swift
+++ b/App/Tests/PickupUITests/Cases/PopoverTests.swift
@@ -1,3 +1,4 @@
+import AppKit
import XCTest
/// Popover, search, keypad, keyboard handling and window shortcuts (docs/TESTING.md, section 4).
@@ -41,10 +42,30 @@ final class PopoverTests: PickupUITestCase {
XCTAssertFalse(element("recent-row-DC-0").isSelected, "no row starts selected: a stray Return never redials")
XCTAssertTrue(exists("recent-row-DC-0-call"), "every row has its own call button")
- keyboard.press(Key.tab)
- XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-0").isSelected }, "Tab moves into the list")
+ // ↓ from the search field selects the first row, like in the search results.
+ keyboard.press(Key.down)
+ XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-0").isSelected }, "Down selects the first row")
keyboard.press(Key.down)
XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-1").isSelected })
+
+ // ⌘C copies the selected number (the pasteboard is restored afterwards).
+ let pasteboard = NSPasteboard.general
+ let saved = pasteboard.string(forType: .string)
+ defer { pasteboard.clearContents(); if let saved { pasteboard.setString(saved, forType: .string) } }
+ pasteboard.clearContents()
+ keyboard.press(Key.c, flags: .maskCommand)
+ XCTAssertTrue(Wait.until(3) { pasteboard.string(forType: .string)?.contains("0000") == true },
+ "Command-C copies the selected row's number")
+
+ keyboard.press(Key.escape)
+ XCTAssertTrue(Wait.until(3) { !self.element("recent-row-DC-1").isSelected }, "Esc returns to the search field")
+ XCTAssertTrue(exists("popover-root"), "…and keeps the popover open")
+
+ // Tab follows the system order: field → tabs → list (which selects its first row).
+ keyboard.press(Key.tab)
+ XCTAssertFalse(element("recent-row-DC-0").isSelected, "the first Tab stops at the tabs")
+ keyboard.press(Key.tab)
+ XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-0").isSelected }, "the second Tab enters the list")
keyboard.press(Key.right)
XCTAssertTrue(Wait.until(3) { self.exists("recent-row-MC-0") && self.element("recent-row-MC-0").isSelected },
"Right switches to the next tab and keeps the keyboard in the list")
@@ -52,13 +73,8 @@ final class PopoverTests: PickupUITestCase {
XCTAssertTrue(Wait.until(3) { self.exists("recent-row-RC-0") && self.element("recent-row-RC-0").isSelected })
keyboard.press(Key.left)
XCTAssertTrue(Wait.until(3) { self.exists("recent-row-DC-0") && self.element("recent-row-DC-0").isSelected })
-
- keyboard.press(Key.escape)
- XCTAssertTrue(Wait.until(3) { !self.element("recent-row-DC-0").isSelected }, "Esc returns to the search field")
- XCTAssertTrue(exists("popover-root"), "…and keeps the popover open")
XCTAssertFalse(appLog.contains("-> dial()"), "moving through the list never dials")
- keyboard.press(Key.tab)
keyboard.press(Key.returnKey)
XCTAssertTrue(waitExists("call-hud"), "Return calls the selected recent call")
XCTAssertNotNil(appLog.range(of: #"dial .*123"#, options: .regularExpression), "Jane's number was dialed:\n\(appLog)")
From 6eb6b0668d9eeeed78fa2bbf8ae057da9497138d Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 10:06:09 +0100
Subject: [PATCH 09/20] feat: resizable popover, round keypad keys and About
links
- The popover's width can be dragged at either side between 300 and 600 pt
(VoiceOver: adjustable). Search and lists grow, the keypad keeps its size.
The width is saved in the user defaults.
- Keypad keys use plain glass with an even press effect: the interactive glass
stretched pressed keys into ovals. More room between search bar and keys.
- The About panel links to the GitHub repository and to davideiken.de.
Co-Authored-By: Claude Opus 5.5
---
App/Resources/Localizable.xcstrings | 18 +++++
App/Sources/Pickup/AppInfo.swift | 7 +-
App/Sources/Pickup/DialerView.swift | 54 ++++++++++-----
App/Sources/Pickup/MenuView.swift | 69 ++++++++++++++++++-
App/Sources/Pickup/PopoverWidth.swift | 23 +++++++
App/Sources/Pickup/StatusItemController.swift | 20 +++++-
.../PickupAppTests/PopoverWidthTests.swift | 18 +++++
7 files changed, 184 insertions(+), 25 deletions(-)
create mode 100644 App/Sources/Pickup/PopoverWidth.swift
create mode 100644 App/Tests/PickupAppTests/PopoverWidthTests.swift
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index bba60c1..67fe832 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -2790,6 +2790,24 @@
}
}
}
+ },
+ "Window width": {
+ "comment": "VoiceOver label of the invisible handle at the side of the popover that changes its width.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Window width"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fensterbreite"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/AppInfo.swift b/App/Sources/Pickup/AppInfo.swift
index 9b2bc63..b64de1c 100644
--- a/App/Sources/Pickup/AppInfo.swift
+++ b/App/Sources/Pickup/AppInfo.swift
@@ -2,9 +2,10 @@ import Foundation
/// Static facts about the app shown in the About panel.
enum AppInfo {
- /// Public source repository. `nil` (or a placeholder) hides the link in the About panel.
- /// Set it once the public `pickup` repository exists.
- static let repositoryURL: String? = nil
+ /// Public source repository, linked in the About panel. `nil` hides the link.
+ static let repositoryURL: String? = "https://github.com/macx/pickup"
+ /// The author's website, linked in the About panel. `nil` hides the link.
+ static let websiteURL: String? = "https://davideiken.de"
static var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index 20f6491..bc86c84 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -13,6 +13,8 @@ private enum DialerDemo {
/// Top bar (keypad toggle + search capsule), guidance, then keypad or result list.
struct DialerView: View {
let model: AppModel
+ /// Width between the popover's side margins (the popover's width is set by the user).
+ let contentWidth: CGFloat
let openSettings: () -> Void
let close: () -> Void
@@ -111,7 +113,7 @@ struct DialerView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
topBar
- .frame(width: Self.contentWidth)
+ .frame(width: contentWidth)
if let guidance = model.guidance {
GuidanceCard(guidance: guidance, model: model, openSettings: openSettings)
}
@@ -126,23 +128,26 @@ struct DialerView: View {
// cross-fades in place, and loading the calls never resizes the popover.
ZStack(alignment: .top) {
if showKeypad {
- keypad.transition(.opacity)
+ keypad
+ .padding(.top, Self.keypadTopInset)
+ .transition(.opacity)
}
if queryIsEmpty && !showKeypad {
recentCalls.transition(.opacity)
}
}
- .frame(width: Self.contentWidth, height: Self.keypadHeight, alignment: .top)
+ .frame(width: contentWidth, height: Self.keypadHeight + Self.keypadTopInset, alignment: .top)
}
if !queryIsEmpty {
content
- .frame(width: Self.contentWidth)
+ .frame(width: contentWidth)
}
if let error {
StatusMessage(text: error, kind: .error)
}
}
- .padding([.horizontal, .top], 16)
+ .padding(.horizontal, Self.sideMargin)
+ .padding(.top, 16)
.padding(.bottom, 12)
.onAppear {
focusFieldSoon()
@@ -346,15 +351,17 @@ struct DialerView: View {
// MARK: Keypad
static let keySize: CGFloat = 60
- /// Width of everything between the popover's 16 pt side margins. Every block of the dialer gets
- /// exactly this width: a child with a larger ideal width (the native segmented control in German,
- /// a legacy scroller) used to widen the popover's content and shift it sideways.
- static let contentWidth: CGFloat = 300 - 2 * 16
+ /// Margin on both sides. Every block of the dialer gets exactly `contentWidth`: a child with a
+ /// larger ideal width (the native segmented control in German, a legacy scroller) used to widen the
+ /// popover's content and shift it sideways.
+ static let sideMargin: CGFloat = 16
static let keyColumnSpacing: CGFloat = 16
static let keyRowSpacing: CGFloat = 12
/// Height of the keypad (four rows of keys and the call button); the recent calls take the same.
static let keypadHeight: CGFloat = 5 * keySize + 4 * keyRowSpacing
static let callButtonSize: CGFloat = 28
+ /// Extra room between the search bar and the first row of keys.
+ static let keypadTopInset: CGFloat = 10
private static let keys: [[String]] = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"], ["*", "0", "#"]]
@@ -376,13 +383,11 @@ struct DialerView: View {
}
}
.frame(width: Self.keySize, height: Self.keySize)
- .glassEffect(.regular.interactive(), in: Circle())
- .overlay(Circle().fill(Color.primary.opacity(pressed ? 0.22 : 0)))
- .scaleEffect(pressed ? 0.93 : 1)
- .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: pressed)
+ // Plain glass: the interactive variant stretches the key into an oval while it is pressed.
+ .glassEffect(.regular, in: Circle())
.contentShape(Circle())
}
- .buttonStyle(.plain)
+ .buttonStyle(KeyPressStyle(highlighted: pressed, reduceMotion: reduceMotion))
.accessibilityLabel(Text(verbatim: key))
.accessibilityIdentifier(Self.identifier(forKey: key))
.modifier(PlusLongPress(enabled: plus != nil) {
@@ -418,10 +423,10 @@ struct DialerView: View {
.font(.system(size: 24))
.foregroundStyle(.white)
.frame(width: Self.keySize, height: Self.keySize)
- .glassEffect(.regular.tint(.callGreen).interactive(), in: Circle())
+ .glassEffect(.regular.tint(.callGreen), in: Circle())
.contentShape(Circle())
}
- .buttonStyle(.plain)
+ .buttonStyle(KeyPressStyle(reduceMotion: reduceMotion))
.disabled(!keypadCallEnabled)
.opacity(keypadCallEnabled ? 1 : 0.45)
.accessibilityLabel(keypadCallsRedial ? Text("Redial", comment: "Label of the keypad's call button while the field is empty: it puts the last dialed number in.") : Text("Call"))
@@ -482,7 +487,7 @@ struct DialerView: View {
// Fixed width: with a mouse attached, macOS shows a legacy scroller that widened the list
// by its width, pushed the popover's content 5 pt to the left and made it jump when the
// keypad (no scroller) replaced the list.
- .frame(width: Self.contentWidth)
+ .frame(width: contentWidth)
.frame(maxHeight: fill ? .infinity : (showKeypad ? 3 : 6) * 50, alignment: .top)
.scrollEdgeEffectStyle(.soft, for: .bottom) // rows fade out at the bottom instead of being cut
// One focus stop for the whole list: ↑/↓ select, Return calls, ⌘C copies. The selected row's
@@ -884,6 +889,21 @@ private struct PlusLongPress: ViewModifier {
}
}
+/// A round key: darkens and shrinks evenly (stays a circle) while the mouse is down or the matching
+/// physical key is held.
+private struct KeyPressStyle: ButtonStyle {
+ var highlighted = false
+ let reduceMotion: Bool
+
+ func makeBody(configuration: Configuration) -> some View {
+ let pressed = highlighted || configuration.isPressed
+ configuration.label
+ .overlay(Circle().fill(Color.primary.opacity(pressed ? 0.22 : 0)).allowsHitTesting(false))
+ .scaleEffect(pressed ? 0.93 : 1)
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: pressed)
+ }
+}
+
/// Pressed feedback without moving anything: the label only dims while the mouse is down.
private struct SteadyPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
diff --git a/App/Sources/Pickup/MenuView.swift b/App/Sources/Pickup/MenuView.swift
index 4a49a08..6d91f4d 100644
--- a/App/Sources/Pickup/MenuView.swift
+++ b/App/Sources/Pickup/MenuView.swift
@@ -8,23 +8,88 @@ struct MenuView: View {
let openSettings: () -> Void
let showAbout: () -> Void
let close: () -> Void
+ @AppStorage(PopoverWidth.defaultsKey) private var savedWidth = Double(PopoverWidth.minimum)
+ /// Width while an edge is being dragged; saved when the drag ends.
+ @State private var liveWidth: CGFloat?
+
+ private var width: CGFloat { liveWidth ?? PopoverWidth.clamped(savedWidth) }
var body: some View {
// Structure for step B: [top bar: grid button + search capsule] / [collapsible content: keypad or
// results] / [bottom bar]. The three parts are separate stack children so the top bar and the
// folding keypad can be added without reworking the bottom bar or the guidance area.
VStack(spacing: 0) {
- DialerView(model: model, openSettings: openSettings, close: close)
+ DialerView(model: model, contentWidth: width - 2 * DialerView.sideMargin, openSettings: openSettings, close: close)
BottomBar(status: model.connection, openSettings: openSettings, showAbout: showAbout)
}
- .frame(width: 300)
+ .frame(width: width)
+ .overlay(alignment: .leading) { resizeEdge(.leading) }
+ .overlay(alignment: .trailing) { resizeEdge(.trailing) }
.accessibilityElement(children: .contain)
.accessibilityIdentifier("popover-root")
.onAppear { Task { await model.permissions.refresh() } }
}
}
+extension MenuView {
+ private func resizeEdge(_ edge: HorizontalEdge) -> some View {
+ PopoverResizeEdge(edge: edge, width: width) { live in
+ liveWidth = live
+ } commit: { final in
+ liveWidth = nil
+ savedWidth = Double(final)
+ }
+ }
+}
+
+/// An invisible strip along a side edge of the popover: dragging it changes the popover's width (the
+/// keypad keeps its size; search and lists grow). VoiceOver adjusts it with increment/decrement.
+private struct PopoverResizeEdge: View {
+ let edge: HorizontalEdge
+ let width: CGFloat
+ let live: (CGFloat) -> Void
+ let commit: (CGFloat) -> Void
+ /// Pointer x (screen coordinates) and width when the drag began. Screen coordinates, because the
+ /// popover moves under the pointer while it grows.
+ @State private var start: (x: CGFloat, width: CGFloat)?
+
+ var body: some View {
+ Color.clear
+ .frame(width: 6)
+ .frame(maxHeight: .infinity)
+ .contentShape(Rectangle())
+ .pointerStyle(.columnResize)
+ .gesture(
+ DragGesture(minimumDistance: 1)
+ .onChanged { _ in
+ let x = NSEvent.mouseLocation.x
+ if start == nil {
+ start = (x, width)
+ NotificationCenter.default.post(name: .pickupPopoverResizing, object: true)
+ }
+ guard let origin = start else { return }
+ live(PopoverWidth.resized(from: origin.width, outwardDelta: (x - origin.x) * (edge == .trailing ? 1 : -1)))
+ }
+ .onEnded { _ in
+ start = nil
+ commit(width)
+ NotificationCenter.default.post(name: .pickupPopoverResizing, object: false)
+ })
+ .accessibilityElement()
+ .accessibilityLabel(Text("Window width", comment: "VoiceOver label of the invisible handle at the side of the popover that changes its width."))
+ .accessibilityValue(Text(verbatim: "\(Int(width))"))
+ .accessibilityAdjustableAction { direction in
+ switch direction {
+ case .increment: commit(PopoverWidth.clamped(width + PopoverWidth.accessibilityStep))
+ case .decrement: commit(PopoverWidth.clamped(width - PopoverWidth.accessibilityStep))
+ @unknown default: break
+ }
+ }
+ .accessibilityHidden(edge == .leading) // one handle is enough for VoiceOver
+ }
+}
+
/// Status on the left, one round Liquid Glass button with the app menu on the right.
private struct BottomBar: View {
let status: ConnectionStatus
diff --git a/App/Sources/Pickup/PopoverWidth.swift b/App/Sources/Pickup/PopoverWidth.swift
new file mode 100644
index 0000000..016d7fa
--- /dev/null
+++ b/App/Sources/Pickup/PopoverWidth.swift
@@ -0,0 +1,23 @@
+import CoreGraphics
+
+/// Width of the menu bar popover, which the user can drag between `minimum` and `maximum`. Saved in
+/// the user defaults, so it survives relaunches and restarts.
+enum PopoverWidth {
+ static let minimum: CGFloat = 300
+ static let maximum: CGFloat = 2 * minimum
+ static let defaultsKey = "popoverWidth"
+ /// Step of the VoiceOver adjust actions (increment / decrement).
+ static let accessibilityStep: CGFloat = 50
+
+ static func clamped(_ width: CGFloat) -> CGFloat {
+ guard width.isFinite else { return minimum }
+ return min(max(width.rounded(), minimum), maximum)
+ }
+
+ /// The popover stays centred under its icon, so dragging one edge by `delta` moves both edges:
+ /// the width changes by twice the distance and the dragged edge stays under the pointer.
+ /// `delta` is positive towards the outside of the dragged edge.
+ static func resized(from start: CGFloat, outwardDelta delta: CGFloat) -> CGFloat {
+ clamped(start + 2 * delta)
+ }
+}
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index 6274aa0..c29d428 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -38,6 +38,13 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
if UserDefaults.standard.bool(forKey: "PickupOpenPopover") { popover.behavior = .applicationDefined }
popover.animates = !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
popover.delegate = self
+ // While an edge is dragged the popover follows the pointer without its resize animation.
+ NotificationCenter.default.addObserver(forName: .pickupPopoverResizing, object: nil, queue: .main) { [weak self] note in
+ let resizing = note.object as? Bool ?? false
+ MainActor.assumeIsolated {
+ self?.popover.animates = !resizing && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+ }
+ }
trackIcon()
installShortcutMonitor()
// Global shortcut (Settings): toggles the popover from anywhere.
@@ -240,11 +247,16 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
comment: "One-line description in the About panel."),
attributes: [.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
.foregroundColor: NSColor.secondaryLabelColor])
- if let string = AppInfo.repositoryURL, let url = URL(string: string) {
+ // Links open in the default browser; Pickup itself never goes online.
+ let links: [(String?, String?)] = [
+ (AppInfo.repositoryURL, String(localized: "Source code on GitHub", comment: "Link text in the About panel.")),
+ (AppInfo.websiteURL, nil), // shown as its domain
+ ]
+ for case let (string?, text) in links {
+ guard let url = URL(string: string), let title = text ?? url.host() else { continue }
credits.append(NSAttributedString(string: "\n"))
credits.append(NSAttributedString(
- string: String(localized: "Source code on GitHub", comment: "Link text in the About panel."),
- attributes: [.link: url, .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)]))
+ string: title, attributes: [.link: url, .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)]))
}
options[.credits] = credits
NSApp.orderFrontStandardAboutPanel(options: options)
@@ -256,6 +268,8 @@ extension Notification.Name {
static let pickupPopoverDidShow = Notification.Name("PickupPopoverDidShow")
/// Posted right before the popover opens; the dialer resets and focuses its search field.
static let pickupPopoverWillShow = Notification.Name("PickupPopoverWillShow")
+ /// Posted when the user starts (`true`) and stops (`false`) dragging the popover's width.
+ static let pickupPopoverResizing = Notification.Name("PickupPopoverResizing")
}
/// Settings window that opens without a focused control. With keyboard navigation turned on, AppKit
diff --git a/App/Tests/PickupAppTests/PopoverWidthTests.swift b/App/Tests/PickupAppTests/PopoverWidthTests.swift
new file mode 100644
index 0000000..c7b78b7
--- /dev/null
+++ b/App/Tests/PickupAppTests/PopoverWidthTests.swift
@@ -0,0 +1,18 @@
+import XCTest
+@testable import Pickup
+
+final class PopoverWidthTests: XCTestCase {
+ func testWidthStaysBetweenTheMinimumAndTwiceTheMinimum() {
+ XCTAssertEqual(PopoverWidth.maximum, 2 * PopoverWidth.minimum)
+ XCTAssertEqual(PopoverWidth.clamped(120), PopoverWidth.minimum)
+ XCTAssertEqual(PopoverWidth.clamped(10_000), PopoverWidth.maximum)
+ XCTAssertEqual(PopoverWidth.clamped(420.4), 420)
+ XCTAssertEqual(PopoverWidth.clamped(.nan), PopoverWidth.minimum, "a broken saved value falls back to the minimum")
+ }
+
+ func testDraggingAnEdgeChangesTheWidthByTwiceTheDistance() {
+ XCTAssertEqual(PopoverWidth.resized(from: 300, outwardDelta: 40), 380)
+ XCTAssertEqual(PopoverWidth.resized(from: 400, outwardDelta: -30), 340)
+ XCTAssertEqual(PopoverWidth.resized(from: 300, outwardDelta: -30), 300)
+ }
+}
From 9255ccedd90ad3e6b7bd6103d4b9c3516cd3bfef Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 10:09:22 +0100
Subject: [PATCH 10/20] feat: missed-call dot, Call Back notification action
and phone battery display
- A missed call puts a system-red dot on the menu bar icon (saved across
relaunches). Opening the popover shows the Missed tab and clears it.
- Missed-call notifications for a dialable number offer "Call Back"; service
codes never get the action.
- Settings > Phone battery: gauge and percentage in the popover's status line
(default), percentage next to the menu bar icon, or hidden. HFP reports the
charge in 20 % steps.
Co-Authored-By: Claude Opus 5.5
---
App/Resources/Localizable.xcstrings | 162 ++++++++++++++++--
App/Sources/Pickup/AppModel.swift | 35 +++-
App/Sources/Pickup/DialerView.swift | 4 +
App/Sources/Pickup/MenuView.swift | 44 ++++-
App/Sources/Pickup/MissedCallNotifier.swift | 34 +++-
App/Sources/Pickup/PhoneBattery.swift | 27 +++
App/Sources/Pickup/SettingsView.swift | 10 ++
App/Sources/Pickup/StatusItemController.swift | 70 +++++++-
.../PickupAppTests/PhoneBatteryTests.swift | 26 +++
9 files changed, 386 insertions(+), 26 deletions(-)
create mode 100644 App/Sources/Pickup/PhoneBattery.swift
create mode 100644 App/Tests/PickupAppTests/PhoneBatteryTests.swift
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index 67fe832..b7de488 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -2575,24 +2575,6 @@
}
}
},
- "Search field": {
- "comment": "VoiceOver announcement when the keyboard leaves the recent calls list and returns to the popover's search field.",
- "extractionState": "manual",
- "localizations": {
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Search field"
- }
- },
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Suchfeld"
- }
- }
- }
- },
"%@ tab, no calls": {
"comment": "VoiceOver announcement when the recent calls tab is switched from the keyboard and the tab is empty. %@ is the tab name, e.g. \"Missed\".",
"extractionState": "manual",
@@ -2808,6 +2790,150 @@
}
}
}
+ },
+ "Call Back": {
+ "comment": "Button of the missed-call notification: calls the number back on the phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Call Back"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zurückrufen"
+ }
+ }
+ }
+ },
+ "%@, missed calls": {
+ "comment": "Accessibility label of the menu bar icon while it shows the red dot for missed calls. %@ is the app name.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@, missed calls"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%@, verpasste Anrufe"
+ }
+ }
+ }
+ },
+ "%@, phone battery %@": {
+ "comment": "Accessibility label of the menu bar icon with the phone's battery next to it. First %@ is the app name (maybe with \"missed calls\"), second the percentage, e.g. \"60 %\".",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@, phone battery %2$@"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "%1$@, Akku des Telefons %2$@"
+ }
+ }
+ }
+ },
+ "Phone battery": {
+ "comment": "Settings: where the phone's battery charge is shown. Also the VoiceOver label of the battery gauge in the popover.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Phone battery"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Akku des Telefons"
+ }
+ }
+ }
+ },
+ "In the menu window": {
+ "comment": "Settings option for the phone's battery: gauge and percentage in the popover's status line.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "In the menu window"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Im Menüfenster"
+ }
+ }
+ }
+ },
+ "In the menu bar": {
+ "comment": "Settings option for the phone's battery: percentage next to Pickup's menu bar icon.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "In the menu bar"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "In der Menüleiste"
+ }
+ }
+ }
+ },
+ "Don’t show": {
+ "comment": "Settings option for the phone's battery: hidden.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Don’t show"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nicht anzeigen"
+ }
+ }
+ }
+ },
+ "Your phone reports its charge in steps of 20 percent.": {
+ "comment": "Tooltip of the phone battery setting.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Your phone reports its charge in steps of 20 percent."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Das Telefon meldet seinen Ladestand in Schritten von 20 Prozent."
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/AppModel.swift b/App/Sources/Pickup/AppModel.swift
index 29007d4..af3f72d 100644
--- a/App/Sources/Pickup/AppModel.swift
+++ b/App/Sources/Pickup/AppModel.swift
@@ -48,6 +48,15 @@ final class AppModel {
private(set) var selectedName: String?
/// Recent calls for the dialer: the phone's call lists, or the calls seen since launch.
private(set) var callHistory = CallHistory()
+ /// A call was missed since the user last looked at the recent calls: red dot on the menu bar icon.
+ /// Saved, so the dot survives a relaunch.
+ private(set) var hasUnseenMissedCalls = false
+ /// The phone's battery level, 0...5 (HFP `battchg`); nil when not connected or not reported.
+ private(set) var phoneBattery: Int?
+ /// Where the battery is shown (Settings).
+ var batteryDisplay: BatteryDisplay = .popover {
+ didSet { if !TestMode.isActive { UserDefaults.standard.set(batteryDisplay.rawValue, forKey: BatteryDisplay.defaultsKey) } }
+ }
let callUI = CallUIModel()
let contacts = ContactsDirectory()
@@ -63,6 +72,7 @@ final class AppModel {
static let addressKey = "phoneAddress"
static let nameKey = "phoneName"
+ static let unseenMissedCallsKey = "unseenMissedCalls"
/// Launch argument `-PickupDemo `: static preview data, no Bluetooth.
let isDemo = UserDefaults.standard.string(forKey: "PickupDemo") != nil
@@ -88,6 +98,8 @@ final class AppModel {
let defaults = UserDefaults.standard
selectedAddress = defaults.string(forKey: Self.addressKey)
selectedName = defaults.string(forKey: Self.nameKey)
+ hasUnseenMissedCalls = defaults.bool(forKey: Self.unseenMissedCallsKey)
+ batteryDisplay = defaults.string(forKey: BatteryDisplay.defaultsKey).flatMap(BatteryDisplay.init) ?? .popover
}
// MARK: Lifecycle
@@ -205,6 +217,7 @@ final class AppModel {
guard let engine else {
set(\.connection, selectedAddress == nil ? .noPhone : .disconnected(phoneName: selectedName ?? fallbackPhoneName))
set(\.callIsLive, false)
+ set(\.phoneBattery, isDemo ? 3 : nil)
set(\.guidance, bluetoothDenied ? .bluetoothDenied : (selectedAddress == nil ? .choosePhone : linkHint))
if !isDemo { setUI(incoming: nil, active: nil) }
return
@@ -222,6 +235,7 @@ final class AppModel {
}
set(\.connection, status)
set(\.callHistory, engine.callHistory)
+ set(\.phoneBattery, link == .connected ? engine.phoneStatus.battery : nil)
trackStuckLink(connected: link == .connected)
set(\.guidance, bluetoothDenied ? .bluetoothDenied
: (showCannotConnect && link != .connected ? .cannotConnect(phoneName: engine.deviceName ?? selectedName) : linkHint))
@@ -408,10 +422,27 @@ final class AppModel {
// MARK: Missed calls
private func callFinished(_ call: CallInfo) {
- guard call.direction == .incoming, call.endReason == .missed else { return }
+ guard MissedCalls.isMissed(call) else { return }
+ setUnseenMissedCalls(true)
let number = call.callerID.number
let name = number.flatMap { contacts.match(for: $0)?.name }
- MissedCallNotifier.shared.notifyMissed(name: name, number: number.map { PhoneNumberFormat.display($0) })
+ MissedCallNotifier.shared.notifyMissed(name: name, number: number)
+ }
+
+ /// The user opened the recent calls (the popover shows the missed calls then): the dot goes away.
+ func markMissedCallsSeen() { setUnseenMissedCalls(false) }
+
+ private func setUnseenMissedCalls(_ unseen: Bool) {
+ guard hasUnseenMissedCalls != unseen else { return }
+ hasUnseenMissedCalls = unseen
+ if !TestMode.isActive { UserDefaults.standard.set(unseen, forKey: Self.unseenMissedCallsKey) }
+ }
+
+ /// "Call Back" in a missed-call notification: an explicit user action, so it dials. A service code
+ /// from the phone is never dialed this way.
+ func callBack(_ number: String) {
+ guard let dialNumber = DialNumber(number), !dialNumber.isServiceCode else { return }
+ if let message = dial(dialNumber) { showNotice(message) }
}
// MARK: tel: / callto: links
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index bc86c84..cd94171 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -172,6 +172,10 @@ struct DialerView: View {
error = nil
selectedID = nil
pendingConfirmationID = nil
+ if model.hasUnseenMissedCalls {
+ recentListRaw = CallHistoryList.missed.rawValue // the red dot led here
+ model.markMissedCallsSeen()
+ }
model.refreshCallHistory()
focusFieldSoon()
}
diff --git a/App/Sources/Pickup/MenuView.swift b/App/Sources/Pickup/MenuView.swift
index 6d91f4d..bac48aa 100644
--- a/App/Sources/Pickup/MenuView.swift
+++ b/App/Sources/Pickup/MenuView.swift
@@ -21,7 +21,9 @@ struct MenuView: View {
VStack(spacing: 0) {
DialerView(model: model, contentWidth: width - 2 * DialerView.sideMargin, openSettings: openSettings, close: close)
- BottomBar(status: model.connection, openSettings: openSettings, showAbout: showAbout)
+ BottomBar(status: model.connection,
+ battery: model.batteryDisplay == .popover ? model.phoneBattery : nil,
+ openSettings: openSettings, showAbout: showAbout)
}
.frame(width: width)
.overlay(alignment: .leading) { resizeEdge(.leading) }
@@ -93,6 +95,8 @@ private struct PopoverResizeEdge: View {
/// Status on the left, one round Liquid Glass button with the app menu on the right.
private struct BottomBar: View {
let status: ConnectionStatus
+ /// Phone battery level 0...5, shown after the status text; nil hides it.
+ let battery: Int?
let openSettings: () -> Void
let showAbout: () -> Void
@State private var anchor = AnchorView.Holder()
@@ -105,6 +109,9 @@ private struct BottomBar: View {
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
+ if let battery {
+ BatteryGauge(level: battery)
+ }
Spacer(minLength: 8)
GlassEffectContainer {
Button {
@@ -152,6 +159,41 @@ private struct BottomBar: View {
}
}
+/// The phone's battery: an outlined battery filled in system green up to the level, and the percentage.
+private struct BatteryGauge: View {
+ let level: Int
+
+ var body: some View {
+ let text = PhoneBattery.percentText(level: level)
+ HStack(spacing: 4) {
+ HStack(spacing: 1) {
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .strokeBorder(Color.secondary, lineWidth: 1)
+ .overlay(alignment: .leading) {
+ GeometryReader { proxy in
+ RoundedRectangle(cornerRadius: 1.5, style: .continuous)
+ .fill(Color(nsColor: .systemGreen))
+ .frame(width: max(0, (proxy.size.width - 4) * PhoneBattery.fraction(level: level)))
+ .padding(2)
+ }
+ }
+ .frame(width: 22, height: 11)
+ RoundedRectangle(cornerRadius: 1, style: .continuous)
+ .fill(Color.secondary)
+ .frame(width: 1.5, height: 4)
+ }
+ Text(verbatim: text)
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ .fixedSize()
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text("Phone battery"))
+ .accessibilityValue(Text(verbatim: text))
+ .accessibilityIdentifier("phone-battery")
+ }
+}
+
/// Short first-run / problem hint with one action, in the style of a system callout.
struct GuidanceCard: View {
let guidance: AppModel.Guidance
diff --git a/App/Sources/Pickup/MissedCallNotifier.swift b/App/Sources/Pickup/MissedCallNotifier.swift
index d469727..1a09815 100644
--- a/App/Sources/Pickup/MissedCallNotifier.swift
+++ b/App/Sources/Pickup/MissedCallNotifier.swift
@@ -1,19 +1,39 @@
import Foundation
+import PickupCore
import UserNotifications
+/// Which finished calls count as missed (notification and red dot on the menu bar icon).
+enum MissedCalls {
+ /// An incoming call nobody answered. Declined on this Mac is a choice, not missed.
+ static func isMissed(_ call: CallInfo) -> Bool { isMissed(direction: call.direction, endReason: call.endReason) }
+
+ static func isMissed(direction: CallDirection, endReason: CallEndReason?) -> Bool {
+ direction == .incoming && endReason == .missed
+ }
+}
+
/// Posts a local notification for missed calls, only when the user allowed notifications.
final class MissedCallNotifier: NSObject, UNUserNotificationCenterDelegate {
static let shared = MissedCallNotifier()
static let enabledKey = "notifyMissedCalls"
+ private static let category = "missed-call"
+ private static let callBackAction = "call-back"
+ private static let numberKey = "number"
/// Call once at launch: shows banners while the app is "active" (menu bar apps always are).
func install() {
guard PermissionsModel.notificationsAvailable else { return }
- UNUserNotificationCenter.current().delegate = self
+ let center = UNUserNotificationCenter.current()
+ center.delegate = self
+ let callBack = UNNotificationAction(identifier: Self.callBackAction,
+ title: String(localized: "Call Back", comment: "Button of the missed-call notification: calls the number back on the phone."))
+ center.setNotificationCategories([UNNotificationCategory(identifier: Self.category, actions: [callBack], intentIdentifiers: [])])
UserDefaults.standard.register(defaults: [Self.enabledKey: true])
}
- func notifyMissed(name: String?, number: String?) {
+ /// `number` as the phone reported it (formatted for display here); nil when withheld.
+ func notifyMissed(name: String?, number rawNumber: String?) {
+ let number = rawNumber.map { PhoneNumberFormat.display($0) }
guard !TestMode.isActive, PermissionsModel.notificationsAvailable,
UserDefaults.standard.bool(forKey: Self.enabledKey) else { return }
let center = UNUserNotificationCenter.current()
@@ -24,6 +44,10 @@ final class MissedCallNotifier: NSObject, UNUserNotificationCenterDelegate {
content.body = name ?? number ?? String(localized: "Unknown caller")
if name != nil, let number { content.subtitle = number }
content.threadIdentifier = "missed-calls"
+ if let rawNumber, DialNumber(rawNumber).map({ !$0.isServiceCode }) == true {
+ content.categoryIdentifier = Self.category // "Call Back" only for a dialable number
+ content.userInfo = [Self.numberKey: rawNumber]
+ }
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
}
}
@@ -32,4 +56,10 @@ final class MissedCallNotifier: NSObject, UNUserNotificationCenterDelegate {
-> UNNotificationPresentationOptions {
[.banner, .list, .sound]
}
+
+ func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
+ guard response.actionIdentifier == Self.callBackAction,
+ let number = response.notification.request.content.userInfo[Self.numberKey] as? String else { return }
+ await MainActor.run { AppModel.shared.callBack(number) }
+ }
}
diff --git a/App/Sources/Pickup/PhoneBattery.swift b/App/Sources/Pickup/PhoneBattery.swift
new file mode 100644
index 0000000..8d918d1
--- /dev/null
+++ b/App/Sources/Pickup/PhoneBattery.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+/// Where Pickup shows the phone's battery charge (Settings).
+enum BatteryDisplay: String, CaseIterable, Identifiable {
+ /// Battery gauge and percentage in the popover's status line ("Connected to …").
+ case popover
+ /// Percentage next to the menu bar icon.
+ case menuBar
+ case off
+
+ static let defaultsKey = "phoneBatteryDisplay"
+ var id: String { rawValue }
+}
+
+/// The phone's battery as HFP reports it: a level from 0 to 5 (`battchg`), so the percentage moves
+/// in steps of 20.
+enum PhoneBattery {
+ static func percent(level: Int) -> Int { min(max(level, 0), 5) * 20 }
+
+ /// Filled share of the gauge, 0...1.
+ static func fraction(level: Int) -> Double { Double(percent(level: level)) / 100 }
+
+ /// "60 %" (German) or "60%" (English), in the user's locale.
+ static func percentText(level: Int, locale: Locale = .current) -> String {
+ (Double(percent(level: level)) / 100).formatted(.percent.locale(locale))
+ }
+}
diff --git a/App/Sources/Pickup/SettingsView.swift b/App/Sources/Pickup/SettingsView.swift
index 93c2349..873412f 100644
--- a/App/Sources/Pickup/SettingsView.swift
+++ b/App/Sources/Pickup/SettingsView.swift
@@ -32,6 +32,16 @@ struct SettingsView: View {
.disabled(true)
.accessibilityIdentifier("settings-take-calls")
.help(Text("Not available yet. For now, Pickup controls your calls from the Mac while the conversation stays on your phone."))
+ Picker("Phone battery", selection: Binding(get: { model.batteryDisplay }, set: { model.batteryDisplay = $0 })) {
+ Text("In the menu window", comment: "Settings option for the phone's battery: gauge and percentage in the popover's status line.")
+ .tag(BatteryDisplay.popover)
+ Text("In the menu bar", comment: "Settings option for the phone's battery: percentage next to Pickup's menu bar icon.")
+ .tag(BatteryDisplay.menuBar)
+ Text("Don’t show", comment: "Settings option for the phone's battery: hidden.")
+ .tag(BatteryDisplay.off)
+ }
+ .accessibilityIdentifier("settings-battery")
+ .help(Text("Your phone reports its charge in steps of 20 percent.", comment: "Tooltip of the phone battery setting."))
LabeledContent("Global shortcut") { ShortcutRecorder() }
.help(Text("Opens Pickup's menu bar window from anywhere. Press it again to close it."))
ShortcutHint()
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index c29d428..2a21059 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -8,6 +8,8 @@ import SwiftUI
final class StatusItemController: NSObject, NSPopoverDelegate {
private let model: AppModel
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+ /// Red dot on the icon for missed calls the user has not looked at yet.
+ private let missedDot = StatusBadgeDot()
private let popover = NSPopover()
private var settingsWindow: NSWindow?
private var keyMonitor: Any?
@@ -56,17 +58,57 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
// MARK: Icon
- /// Re-reads the connection state whenever it changes: filled handset with call arcs = connected, outlined
- /// handset = disconnected (docs/ICON.md).
+ /// Re-reads the state whenever it changes: filled handset with call arcs = connected, outlined
+ /// handset = disconnected (docs/ICON.md); a red dot for unseen missed calls; the phone's battery
+ /// percentage next to the icon when chosen in Settings.
private func trackIcon() {
withObservationTracking {
let connected = model.connection.isConnected || model.isDemo
- statusItem.button?.image = Self.menuBarImage(connected: connected)
+ let battery = model.batteryDisplay == .menuBar ? model.phoneBattery : nil
+ updateIcon(connected: connected, battery: battery, missed: model.hasUnseenMissedCalls)
} onChange: { [weak self] in
Task { @MainActor in self?.trackIcon() }
}
}
+ private func updateIcon(connected: Bool, battery: Int?, missed: Bool) {
+ guard let button = statusItem.button else { return }
+ button.image = Self.menuBarImage(connected: connected)
+ if let battery {
+ statusItem.length = NSStatusItem.variableLength
+ button.imagePosition = .imageLeading
+ button.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize - 1, weight: .regular)
+ button.title = PhoneBattery.percentText(level: battery)
+ } else {
+ button.title = ""
+ button.imagePosition = .imageOnly
+ statusItem.length = NSStatusItem.squareLength
+ }
+ let name = String(localized: "Pickup", comment: "Accessibility label of the menu bar icon. The app name.")
+ var label = missed
+ ? String(localized: "\(name), missed calls", comment: "Accessibility label of the menu bar icon while it shows the red dot for missed calls. %@ is the app name.")
+ : name
+ if let battery {
+ label = String(localized: "\(label), phone battery \(PhoneBattery.percentText(level: battery))",
+ comment: "Accessibility label of the menu bar icon with the phone's battery next to it. First %@ is the app name (maybe with \"missed calls\"), second the percentage, e.g. \"60 %\".")
+ }
+ button.setAccessibilityLabel(label)
+ if missedDot.superview !== button { button.addSubview(missedDot) }
+ missedDot.isHidden = !missed
+ button.layoutSubtreeIfNeeded()
+ positionMissedDot()
+ }
+
+ /// At the top right of the handset, also when the percentage widens the button.
+ private func positionMissedDot() {
+ guard let button = statusItem.button else { return }
+ let image = (button.cell as? NSButtonCell)?.imageRect(forBounds: button.bounds)
+ ?? NSRect(x: (button.bounds.width - 18) / 2, y: (button.bounds.height - 18) / 2, width: 18, height: 18)
+ let size = StatusBadgeDot.diameter
+ let y = button.isFlipped ? image.minY - 1 : image.maxY - size + 1
+ missedDot.frame = NSRect(x: image.maxX - size + 1, y: y, width: size, height: size)
+ }
+
/// The template PDFs from App/Resources/MenuBar (copied into the bundle by scripts/build-app.sh). Outside the
/// app bundle (`swift run`, tests) they are missing, so the SF Symbols stand in.
private static func menuBarImage(connected: Bool) -> NSImage? {
@@ -263,6 +305,28 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
}
}
+/// The missed-call dot: system red, follows light/dark mode and accent changes.
+private final class StatusBadgeDot: NSView {
+ static let diameter: CGFloat = 7
+
+ override init(frame: NSRect) {
+ super.init(frame: frame)
+ wantsLayer = true
+ setAccessibilityElement(false) // the button's label says "missed calls"
+ }
+
+ required init?(coder: NSCoder) { nil }
+
+ override var wantsUpdateLayer: Bool { true }
+
+ override func updateLayer() {
+ layer?.backgroundColor = NSColor.systemRed.cgColor
+ layer?.cornerRadius = bounds.width / 2
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? { nil } // clicks go to the button
+}
+
extension Notification.Name {
/// Posted once the popover window is key; the dialer focuses the field and puts the caret at the end.
static let pickupPopoverDidShow = Notification.Name("PickupPopoverDidShow")
diff --git a/App/Tests/PickupAppTests/PhoneBatteryTests.swift b/App/Tests/PickupAppTests/PhoneBatteryTests.swift
new file mode 100644
index 0000000..10e9b61
--- /dev/null
+++ b/App/Tests/PickupAppTests/PhoneBatteryTests.swift
@@ -0,0 +1,26 @@
+import PickupCore
+import XCTest
+@testable import Pickup
+
+final class PhoneBatteryTests: XCTestCase {
+ func testLevelsMapToTwentyPercentSteps() {
+ XCTAssertEqual((0...5).map { PhoneBattery.percent(level: $0) }, [0, 20, 40, 60, 80, 100])
+ XCTAssertEqual(PhoneBattery.percent(level: 9), 100, "out-of-range values from the phone are clamped")
+ XCTAssertEqual(PhoneBattery.percent(level: -1), 0)
+ XCTAssertEqual(PhoneBattery.fraction(level: 3), 0.6, accuracy: 0.001)
+ }
+
+ func testPercentTextFollowsTheLocale() {
+ XCTAssertEqual(PhoneBattery.percentText(level: 3, locale: Locale(identifier: "en_US")), "60%")
+ XCTAssertEqual(PhoneBattery.percentText(level: 3, locale: Locale(identifier: "de_DE")), "60\u{00A0}%")
+ }
+}
+
+final class MissedCallsTests: XCTestCase {
+ func testOnlyUnansweredIncomingCallsCountAsMissed() {
+ XCTAssertTrue(MissedCalls.isMissed(direction: .incoming, endReason: .missed))
+ XCTAssertFalse(MissedCalls.isMissed(direction: .incoming, endReason: .declined), "declining on this Mac is a choice")
+ XCTAssertFalse(MissedCalls.isMissed(direction: .outgoing, endReason: .unanswered))
+ XCTAssertFalse(MissedCalls.isMissed(direction: .incoming, endReason: .remoteEnded))
+ }
+}
From 3159498f8f1f537a734ce6cb04de633532916765 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 10:11:33 +0100
Subject: [PATCH 11/20] feat: add a recent caller to the contacts app set up on
this Mac
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Recent calls and typed numbers without a contact get "Add to …" in their
context menu. Pickup writes an escaped vCard to a private temporary folder and
opens it with the app that handles vCards (Contacts, or e.g. Cardhop), then
removes the file after five minutes.
Co-Authored-By: Claude Opus 5.5
---
App/Resources/Localizable.xcstrings | 54 ++++++++++++
App/Sources/Pickup/ContactCard.swift | 83 +++++++++++++++++++
App/Sources/Pickup/DialerView.swift | 30 +++++++
.../PickupAppTests/ContactCardTests.swift | 27 ++++++
4 files changed, 194 insertions(+)
create mode 100644 App/Sources/Pickup/ContactCard.swift
create mode 100644 App/Tests/PickupAppTests/ContactCardTests.swift
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index b7de488..fb371c9 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -2934,6 +2934,60 @@
}
}
}
+ },
+ "The contact could not be handed to your contacts app.": {
+ "comment": "Inline error when Pickup could not open the new contact card in the contacts app.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "The contact could not be handed to your contacts app."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Der Kontakt konnte nicht an Ihre Kontakte-App übergeben werden."
+ }
+ }
+ }
+ },
+ "Add to %@…": {
+ "comment": "Context menu item of a recent call or typed number without a contact: opens the contacts app (e.g. Contacts or Cardhop) with a new contact. %@ is the app name.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Add to %@…"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zu %@ hinzufügen …"
+ }
+ }
+ }
+ },
+ "Add to Contacts…": {
+ "comment": "Context menu item of a recent call or typed number without a contact, when the name of the contacts app is unknown.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Add to Contacts…"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zu Kontakten hinzufügen …"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/ContactCard.swift b/App/Sources/Pickup/ContactCard.swift
new file mode 100644
index 0000000..b7f491f
--- /dev/null
+++ b/App/Sources/Pickup/ContactCard.swift
@@ -0,0 +1,83 @@
+import AppKit
+import UniformTypeIdentifiers
+
+/// "Add to Contacts…" for a number without a contact: a vCard handed to the app that opens vCards
+/// on this Mac (Contacts, or e.g. Cardhop when the user made it the default).
+enum ContactCard {
+ /// A vCard 3.0 with the number and, if the phone sent one, the name. Name and number come from the
+ /// phone (untrusted): both are escaped, so they cannot add lines or properties to the card.
+ static func vCard(name: String?, number: String) -> String {
+ let name = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let (given, family) = split(name)
+ let lines = [
+ "BEGIN:VCARD",
+ "VERSION:3.0",
+ "N:\(escape(family));\(escape(given));;;",
+ "FN:\(escape(name))",
+ "TEL;TYPE=VOICE:\(escape(number))",
+ "END:VCARD",
+ ]
+ return lines.joined(separator: "\r\n") + "\r\n"
+ }
+
+ /// RFC 2426 text escaping; other control characters are dropped.
+ static func escape(_ text: String) -> String {
+ var result = ""
+ for character in text {
+ switch character {
+ case "\\": result += "\\\\"
+ case ",": result += "\\,"
+ case ";": result += "\\;"
+ case "\n", "\r", "\r\n": result += "\\n"
+ default:
+ if character.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) {
+ result.append(character)
+ }
+ }
+ }
+ return result
+ }
+
+ /// "Jane Doe" → ("Jane", "Doe"); a single word is the given name.
+ static func split(_ name: String) -> (given: String, family: String) {
+ guard let space = name.lastIndex(of: " ") else { return (name, "") }
+ return (String(name[.. Bool {
+ let folder = FileManager.default.temporaryDirectory.appendingPathComponent("Pickup-\(UUID().uuidString)", isDirectory: true)
+ let file = folder.appendingPathComponent(fileName(name: name, number: number))
+ do {
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ try Data(vCard(name: name, number: number).utf8).write(to: file, options: .atomic)
+ } catch {
+ return false
+ }
+ guard NSWorkspace.shared.open(file) else {
+ try? FileManager.default.removeItem(at: folder)
+ return false
+ }
+ Task {
+ try? await Task.sleep(for: .seconds(300))
+ try? FileManager.default.removeItem(at: folder)
+ }
+ return true
+ }
+
+ /// "Jane Doe.vcf"; the name (or number) without characters a file name cannot have.
+ static func fileName(name: String?, number: String) -> String {
+ let base = (name.flatMap { $0.isEmpty ? nil : $0 } ?? number)
+ .components(separatedBy: CharacterSet(charactersIn: "/:\\").union(.controlCharacters)).joined()
+ .trimmingCharacters(in: .whitespaces)
+ return (base.isEmpty ? "Contact" : String(base.prefix(80))) + ".vcf"
+ }
+}
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index cd94171..48407cc 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -542,6 +542,20 @@ struct DialerView: View {
Text("Copy Number", comment: "Context menu item of a row in the popover's list: copies the phone number.")
}
.disabled(row.copyableNumber == nil)
+ if let card = row.newContact(contacts: model.contacts) {
+ Button {
+ if !ContactCard.open(name: card.name, number: card.number) {
+ error = String(localized: "The contact could not be handed to your contacts app.",
+ comment: "Inline error when Pickup could not open the new contact card in the contacts app.")
+ }
+ } label: {
+ if let app = ContactCard.appName {
+ Text("Add to \(app)…", comment: "Context menu item of a recent call or typed number without a contact: opens the contacts app (e.g. Contacts or Cardhop) with a new contact. %@ is the app name.")
+ } else {
+ Text("Add to Contacts…", comment: "Context menu item of a recent call or typed number without a contact, when the name of the contacts app is unknown.")
+ }
+ }
+ }
}
// MARK: Recent calls
@@ -781,6 +795,22 @@ fileprivate extension DialerRow {
var recentNumber: DialNumber? { if case .recent(_, let number, _) = kind { number } else { nil } }
+ /// Name and number for "Add to Contacts…": a recent call or typed number that matches no contact
+ /// on this Mac. nil for contacts and withheld callers.
+ @MainActor
+ func newContact(contacts: ContactsDirectory) -> (name: String?, number: String)? {
+ switch kind {
+ case .recent(let entry, _, _):
+ guard !entry.number.isEmpty, contacts.match(for: entry.number) == nil else { return nil }
+ return (entry.name, entry.number)
+ case .call(let number):
+ guard !number.isServiceCode, contacts.match(for: number.value) == nil else { return nil }
+ return (nil, number.value)
+ case .contact:
+ return nil
+ }
+ }
+
@MainActor
func display(contacts: ContactsDirectory) -> ResultRow.Display {
switch kind {
diff --git a/App/Tests/PickupAppTests/ContactCardTests.swift b/App/Tests/PickupAppTests/ContactCardTests.swift
new file mode 100644
index 0000000..7d1addb
--- /dev/null
+++ b/App/Tests/PickupAppTests/ContactCardTests.swift
@@ -0,0 +1,27 @@
+import XCTest
+@testable import Pickup
+
+/// The vCard for "Add to Contacts…": data from the phone must never add lines or properties.
+final class ContactCardTests: XCTestCase {
+ func testCardCarriesNameAndNumber() {
+ let card = ContactCard.vCard(name: "Jane Doe", number: "+49 30 0000123")
+ XCTAssertEqual(card, "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;Jane;;;\r\nFN:Jane Doe\r\nTEL;TYPE=VOICE:+49 30 0000123\r\nEND:VCARD\r\n")
+ XCTAssertTrue(ContactCard.vCard(name: nil, number: "+49300000123").contains("\r\nN:;;;;\r\nFN:\r\n"))
+ }
+
+ func testPhoneDataCannotInjectProperties() {
+ let card = ContactCard.vCard(name: "Jane\r\nEMAIL:x@example.com;Doe", number: "123\nEND:VCARD")
+ let lines = card.components(separatedBy: "\r\n").filter { !$0.isEmpty }
+ XCTAssertEqual(lines.count, 6, card)
+ XCTAssertFalse(lines.contains { $0.hasPrefix("EMAIL") })
+ XCTAssertEqual(lines.filter { $0 == "END:VCARD" }.count, 1)
+ XCTAssertEqual(ContactCard.escape("a,b;c\\d\u{07}"), #"a\,b\;c\\d"#)
+ }
+
+ func testFileNameIsSafe() {
+ XCTAssertEqual(ContactCard.fileName(name: "Jane Doe", number: "1"), "Jane Doe.vcf")
+ XCTAssertEqual(ContactCard.fileName(name: "../x/y", number: "1"), "..xy.vcf")
+ XCTAssertEqual(ContactCard.fileName(name: nil, number: "+49 30 0000123"), "+49 30 0000123.vcf")
+ XCTAssertEqual(ContactCard.fileName(name: "", number: ""), "Contact.vcf")
+ }
+}
From 579ee7a2aeaec6defc59d284833e7d836fca044d Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 10:19:04 +0100
Subject: [PATCH 12/20] feat: hold, swap and call waiting in the call window
The engine tracks a second call: a held call next to the active one and the
waiting call. With phones that announce three-way calling (+BRSF bit 0, +CHLD
modes 0-2) the call window offers Hold/Resume, Swap, ending the held call, and
for a waiting call Hold & Accept, End & Accept and Decline (AT+CHLD=0/1/2).
Hanging up the active call with one on hold resumes the held call.
Effects apply on the phone's OK or the matching indicator, whichever comes
first; AT+CLCC confirms numbers. A waiting call taken on the phone itself is
recognised too. The fuzz test found that a phone without three-way calling
could block hanging up once it reported a second call; AT+CHUP is used then
(regression test added). Needs a hardware test with a real second call.
Co-Authored-By: Claude Opus 5.5
---
App/Resources/Localizable.xcstrings | 144 ++++++++++
App/Sources/Pickup/AppModel.swift | 12 +-
App/Sources/Pickup/CallHUDView.swift | 112 +++++++-
App/Sources/Pickup/CallUIModels.swift | 12 +
.../PickupBluetooth/AppleHFPTransport.swift | 18 ++
Sources/PickupBluetooth/HandsFreeLink.swift | 5 +
Sources/PickupCore/CallEngine.swift | 262 +++++++++++++++++-
Sources/PickupCore/HFPTransport.swift | 12 +
Sources/PickupCore/HFPTypes.swift | 14 +
.../AppleHFPTransportTests.swift | 25 ++
Tests/PickupCoreTests/CallHoldTests.swift | 256 +++++++++++++++++
.../PickupCoreTests/Support/TestDoubles.swift | 7 +
docs/BLUETOOTH.md | 1 +
13 files changed, 867 insertions(+), 13 deletions(-)
create mode 100644 Tests/PickupCoreTests/CallHoldTests.swift
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index fb371c9..64778b5 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -2988,6 +2988,150 @@
}
}
}
+ },
+ "Hold": {
+ "comment": "Button in the call window that puts the call on hold.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Hold"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Halten"
+ }
+ }
+ }
+ },
+ "Resume": {
+ "comment": "Button in the call window that takes the call off hold.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Resume"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fortsetzen"
+ }
+ }
+ }
+ },
+ "Swap": {
+ "comment": "Button in the call window that swaps between the active call and the call on hold.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Swap"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Makeln"
+ }
+ }
+ }
+ },
+ "Swap to %@": {
+ "comment": "VoiceOver label of the button that swaps to the call on hold. %@ is the caller's name or number.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Swap to %@"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zu %@ wechseln"
+ }
+ }
+ }
+ },
+ "End call with %@": {
+ "comment": "VoiceOver label of the button that ends the call on hold. %@ is the caller's name or number.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "End call with %@"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Anruf mit %@ beenden"
+ }
+ }
+ }
+ },
+ "Hold & Accept": {
+ "comment": "Button in the call window when a second call comes in: puts the current call on hold and answers the new one.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Hold & Accept"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Halten und annehmen"
+ }
+ }
+ }
+ },
+ "End & Accept": {
+ "comment": "Button in the call window when a second call comes in: ends the current call and answers the new one.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "End & Accept"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Beenden und annehmen"
+ }
+ }
+ }
+ },
+ "Call waiting": {
+ "comment": "Status of a second incoming call in the call window (call waiting).",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Call waiting"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Anklopfen"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/AppModel.swift b/App/Sources/Pickup/AppModel.swift
index af3f72d..f3bfc41 100644
--- a/App/Sources/Pickup/AppModel.swift
+++ b/App/Sources/Pickup/AppModel.swift
@@ -294,7 +294,9 @@ final class AppModel {
ActiveCallInfo(
party: party, phase: phase, startedAt: call.connectedAt, phoneName: phoneName,
audioOnMac: engine.isAudioOnMacEnabled && engine.audioRoute == .mac, isMuted: false,
- waiting: engine.waitingCall.map { self.party(for: $0.callerID) }, notice: notice)
+ waiting: engine.waitingCall.map { self.party(for: $0.callerID) },
+ held: engine.heldCall.map { self.party(for: $0.callerID) },
+ canHold: engine.supportsCallHold, notice: notice)
}
private func party(for callerID: CallerID) -> CallParty {
@@ -366,7 +368,13 @@ final class AppModel {
guard let digit = DTMFDigit(digit) else { return }
self?.run { try $0.sendDTMF(digit) }
},
- moveToPhone: { [weak self] in self?.run { try $0.transferAudioToPhone() } })
+ moveToPhone: { [weak self] in self?.run { try $0.transferAudioToPhone() } },
+ toggleHold: { [weak self] in self?.run { try $0.toggleHold() } },
+ swapCalls: { [weak self] in self?.run { try $0.swapCalls() } },
+ acceptWaitingHolding: { [weak self] in self?.run { try $0.acceptWaitingCall(endingCurrent: false) } },
+ acceptWaitingEnding: { [weak self] in self?.run { try $0.acceptWaitingCall(endingCurrent: true) } },
+ rejectWaiting: { [weak self] in self?.run { try $0.rejectWaitingCall() } },
+ endHeld: { [weak self] in self?.run { try $0.endHeldCall() } })
}
/// End Call in the HUD: the button shows progress until the phone confirms (usually 1–2 s). If the
diff --git a/App/Sources/Pickup/CallHUDView.swift b/App/Sources/Pickup/CallHUDView.swift
index a43524d..ed3dd41 100644
--- a/App/Sources/Pickup/CallHUDView.swift
+++ b/App/Sources/Pickup/CallHUDView.swift
@@ -39,12 +39,25 @@ struct CallHUDView: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
+ if showsHoldControl {
+ CircleControl(symbol: info.phase == .held ? "play.fill" : "pause.fill",
+ title: info.phase == .held ? "Resume" : "Hold",
+ style: info.phase == .held ? .toggled : .neutral,
+ identifier: "hud-hold", action: actions.toggleHold)
+ }
+ if let held = info.held, info.phase == .active {
+ heldCallRow(held)
+ }
if let waiting = info.waiting {
- Text("Call waiting: \(waiting.primaryText ?? String(localized: "Unknown caller"))")
- .font(.callout)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .fixedSize(horizontal: false, vertical: true)
+ if info.canHold {
+ waitingCallCard(waiting)
+ } else {
+ Text("Call waiting: \(waiting.primaryText ?? String(localized: "Unknown caller"))")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
}
if let notice = info.notice {
StatusMessage(text: notice)
@@ -98,6 +111,70 @@ struct CallHUDView: View {
.accessibilityIdentifier("call-hud")
}
+ /// Hold / Resume for a single call (with two calls, the second call's row offers Swap instead).
+ private var showsHoldControl: Bool {
+ info.canHold && info.held == nil && info.waiting == nil && (info.phase == .active || info.phase == .held)
+ }
+
+ /// The call on hold while another one is active: swap to it or end it.
+ private func heldCallRow(_ held: CallParty) -> some View {
+ let name = held.primaryText ?? String(localized: "Unknown caller")
+ return SecondCallRow(party: held, status: String(localized: "On hold")) {
+ Button(action: actions.swapCalls) {
+ Label("Swap", systemImage: "arrow.left.arrow.right")
+ }
+ .buttonStyle(.bordered)
+ .accessibilityLabel(Text("Swap to \(name)", comment: "VoiceOver label of the button that swaps to the call on hold. %@ is the caller's name or number."))
+ .help(Text("Swap to \(name)"))
+ .accessibilityIdentifier("hud-swap")
+ Button(action: actions.endHeld) {
+ Image(systemName: "phone.down.fill")
+ .foregroundStyle(.white)
+ .frame(width: 28, height: 28)
+ .background(Color.red, in: Circle())
+ .contentShape(Circle())
+ }
+ .buttonStyle(PressableStyle())
+ .accessibilityLabel(Text("End call with \(name)", comment: "VoiceOver label of the button that ends the call on hold. %@ is the caller's name or number."))
+ .help(Text("End call with \(name)"))
+ .accessibilityIdentifier("hud-end-held")
+ }
+ }
+
+ /// A second caller: take the call (holding or ending the current one) or decline it.
+ private func waitingCallCard(_ waiting: CallParty) -> some View {
+ let holdAndAccept = Button(action: actions.acceptWaitingHolding) {
+ Text("Hold & Accept", comment: "Button in the call window when a second call comes in: puts the current call on hold and answers the new one.")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(.callGreen)
+ .accessibilityIdentifier("hud-waiting-hold-accept")
+ let endAndAccept = Button(action: actions.acceptWaitingEnding) {
+ Text("End & Accept", comment: "Button in the call window when a second call comes in: ends the current call and answers the new one.")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .accessibilityIdentifier("hud-waiting-end-accept")
+ let decline = Button(action: actions.rejectWaiting) {
+ Text("Decline").frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .tint(.red)
+ .accessibilityIdentifier("hud-waiting-decline")
+ return VStack(spacing: 8) {
+ SecondCallRow(party: waiting, status: String(localized: "Call waiting", comment: "Status of a second incoming call in the call window (call waiting).")) {}
+ if info.held == nil { holdAndAccept }
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 8) { endAndAccept; decline }
+ VStack(spacing: 8) { endAndAccept; decline }
+ }
+ }
+ .controlSize(.large)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("hud-waiting")
+ }
+
@ViewBuilder
private var statusLine: some View {
switch info.phase {
@@ -151,6 +228,31 @@ struct CallHUDView: View {
}
}
+/// A second call (held or waiting) under the main call: avatar, name, status and its buttons.
+private struct SecondCallRow: View {
+ let party: CallParty
+ let status: String
+ @ViewBuilder let actions: () -> Actions
+
+ var body: some View {
+ HStack(spacing: 10) {
+ AvatarView(party: party, size: 32)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(verbatim: party.primaryText ?? String(localized: "Unknown caller"))
+ .font(.callout.weight(.semibold))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Text(verbatim: status).font(.caption).foregroundStyle(.secondary)
+ }
+ .accessibilityElement(children: .combine)
+ Spacer(minLength: 4)
+ actions()
+ }
+ .padding(8)
+ .background(.gray.opacity(0.2), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+}
+
struct KeypadView: View {
let onDigit: (Character) -> Void
/// The HUD keypad takes keyboard focus for typing digits; the dial pad leaves it to its text field.
diff --git a/App/Sources/Pickup/CallUIModels.swift b/App/Sources/Pickup/CallUIModels.swift
index fbc758c..3b3b27c 100644
--- a/App/Sources/Pickup/CallUIModels.swift
+++ b/App/Sources/Pickup/CallUIModels.swift
@@ -53,6 +53,10 @@ struct ActiveCallInfo: Equatable, Sendable {
var endDuration: String? = nil
/// A second caller waiting on the line.
var waiting: CallParty? = nil
+ /// A second call on hold while this one is active.
+ var held: CallParty? = nil
+ /// The phone supports holding, swapping and taking a waiting call.
+ var canHold = false
var notice: String? = nil
/// The user pressed End Call and the phone has not confirmed yet: the button shows progress.
var isEnding = false
@@ -67,6 +71,14 @@ struct CallActions {
var toggleMute: () -> Void = {}
var sendDigit: (Character) -> Void = { _ in }
var moveToPhone: () -> Void = {}
+ var toggleHold: () -> Void = {}
+ var swapCalls: () -> Void = {}
+ /// Take the waiting call and hold the current one.
+ var acceptWaitingHolding: () -> Void = {}
+ /// Take the waiting call and end the current one.
+ var acceptWaitingEnding: () -> Void = {}
+ var rejectWaiting: () -> Void = {}
+ var endHeld: () -> Void = {}
}
enum ConnectionStatus: Equatable {
diff --git a/Sources/PickupBluetooth/AppleHFPTransport.swift b/Sources/PickupBluetooth/AppleHFPTransport.swift
index 03f513e..05b8b02 100644
--- a/Sources/PickupBluetooth/AppleHFPTransport.swift
+++ b/Sources/PickupBluetooth/AppleHFPTransport.swift
@@ -311,6 +311,24 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
}
}
+ /// `+BRSF` bit 0 (three-way calling) and `+CHLD` modes 0, 1 and 2 (IOBluetooth's bitmap: bit 0 = "0",
+ /// bit 1 = "1", bit 3 = "2"; see `HFPFeatures.callHoldModes`).
+ public var supportsCallHold: Bool {
+ guard let link, linkState == .connected else { return false }
+ return Self.supportsCallHold(features: link.audioGatewayFeatures, holdModes: link.callHoldModes)
+ }
+
+ nonisolated static func supportsCallHold(features: UInt32, holdModes: UInt32) -> Bool {
+ let required: UInt32 = 0b1011
+ return features & 1 != 0 && holdModes & required == required
+ }
+
+ public func callHold(_ action: CallHoldAction) throws {
+ guard supportsCallHold else { throw TransportError.unsupported }
+ let line = "AT+CHLD=\(action.rawValue)"
+ try issue("sendATCommand(\(line))") { $0.sendATCommand(line) }
+ }
+
public func readCallHistory(_ list: CallHistoryList, count: Int) throws {
guard !isInvalidated else { throw TransportError.invalidated }
// Without the write hook, the phone's answers cannot be matched to the commands.
diff --git a/Sources/PickupBluetooth/HandsFreeLink.swift b/Sources/PickupBluetooth/HandsFreeLink.swift
index 0357ecc..8319f9b 100644
--- a/Sources/PickupBluetooth/HandsFreeLink.swift
+++ b/Sources/PickupBluetooth/HandsFreeLink.swift
@@ -16,6 +16,9 @@ protocol HandsFreeLink: AnyObject {
/// The phone's baseband (ACL) link is up.
var isACLConnected: Bool { get }
var deviceName: String? { get }
+ /// The phone's `+BRSF` feature bitmap and `+CHLD` modes from the SLC set-up (0 until known).
+ var audioGatewayFeatures: UInt32 { get }
+ var callHoldModes: UInt32 { get }
/// Makes `delegate` receive the callbacks and `incoming` the raw AT lines. Every delegate ever
/// attached is kept alive by the link (`delegate` is `assign` in IOBluetooth).
@@ -61,6 +64,8 @@ final class IOBluetoothHandsFreeLink: HandsFreeLink {
var isSCOConnected: Bool { device.isSCOConnected() }
var isACLConnected: Bool { device.device?.isConnected() ?? false }
var deviceName: String? { device.device?.name }
+ var audioGatewayFeatures: UInt32 { device.deviceSupportedFeatures }
+ var callHoldModes: UInt32 { device.deviceCallHoldModes }
func attach(delegate: HandsFreeDelegate, incoming: @escaping PUBATLogHandler) {
if !delegates.contains(where: { $0 === delegate }) { delegates.append(delegate) }
diff --git a/Sources/PickupCore/CallEngine.swift b/Sources/PickupCore/CallEngine.swift
index 607f090..f4bdb55 100644
--- a/Sources/PickupCore/CallEngine.swift
+++ b/Sources/PickupCore/CallEngine.swift
@@ -42,6 +42,9 @@ public final class CallEngine {
public var callHistoryRefreshDelay: TimeInterval = 2
/// `refreshCallHistory()` does nothing if the lists were requested less than this ago.
public var callHistoryMinimumInterval: TimeInterval = 15
+ /// A waiting call that stopped ringing counts as taken on the phone (with the current call on
+ /// hold) if the phone reports an active and a held call within this time.
+ public var waitingCallTakenWindow: TimeInterval = 5
public init() {}
}
@@ -57,8 +60,11 @@ public final class CallEngine {
public private(set) var deviceName: String?
/// The current call, or the call that just ended (phase `.ended`) until it is cleared.
public private(set) var call: CallInfo?
- /// A second incoming call while `call` is active (call waiting). Shown only; not controllable yet.
+ /// A second incoming call while `call` is active (call waiting).
public private(set) var waitingCall: CallInfo?
+ /// A second call on hold while `call` is active (after taking a waiting call, or swapping).
+ /// Whenever it is set, `call` is the active one.
+ public private(set) var heldCall: CallInfo?
public private(set) var indicators = HFPIndicators()
public private(set) var audioRoute: AudioRoute = .phone
/// Most recent time the engine moved the audio back to the phone, for a short notice.
@@ -74,6 +80,8 @@ public final class CallEngine {
public var supportsAudio: Bool { transport.supportsAudio }
/// Audio on the Mac is possible for this call: transport capability and user setting.
public var isAudioOnMacEnabled: Bool { supportsAudio && takeCallsOnMac }
+ /// The phone supports holding, swapping and taking a waiting call.
+ public var supportsCallHold: Bool { transport.supportsCallHold }
public var phoneStatus: PhoneStatus {
PhoneStatus(
hasService: indicators.service, signal: indicators.signal,
@@ -106,6 +114,14 @@ public final class CallEngine {
@ObservationIgnored private var cancelResentFor: UUID?
@ObservationIgnored private var isInvalidated = false
+ /// The three-way calling command sent and not answered yet; its effect is applied when the phone
+ /// confirms it (or earlier, when the matching indicator arrives first).
+ private enum HoldRequest { case toggle, swap, acceptWaitingHolding, acceptWaitingEnding, rejectWaiting, endActive, endHeld }
+ @ObservationIgnored private var holdRequest: HoldRequest?
+ /// The waiting call that stopped ringing without a request from this Mac (taken, declined or
+ /// missed on the phone), to recognise it when the phone reports it as active.
+ @ObservationIgnored private var resolvedWaiting: (call: CallInfo, at: Date)?
+
@ObservationIgnored private var setupEndTask: ScheduledTask?
@ObservationIgnored private var endedClearTask: ScheduledTask?
@ObservationIgnored private var dialTimeoutTask: ScheduledTask?
@@ -206,6 +222,18 @@ public final class CallEngine {
guard [.dialing, .active, .held].contains(phase), let call else {
throw CallEngineError.invalidPhase(phase)
}
+ if heldCall != nil, phase == .active, transport.supportsCallHold {
+ // End the active call and go back to the held one, as on the phone. Without three-way
+ // calling (a second call the phone set up on its own), AT+CHUP below still ends the call.
+ localEndRequestedFor = call.id
+ do {
+ try sendHold(.releaseActiveAcceptOther, .endActive)
+ } catch {
+ localEndRequestedFor = nil
+ throw error
+ }
+ return
+ }
log.log(.command, "hang up -> endCall()")
localEndRequestedFor = call.id
if phase == .dialing {
@@ -260,6 +288,60 @@ public final class CallEngine {
try perform { try transport.sendDTMF(digit) }
}
+ // MARK: Three-way calling
+
+ /// Puts the call on hold, or resumes it. Only with a single call.
+ public func toggleHold() throws {
+ try requireCallHold()
+ guard heldCall == nil, waitingCall == nil, phase == .active || phase == .held else {
+ throw CallEngineError.invalidPhase(phase)
+ }
+ try sendHold(.holdActiveAcceptOther, .toggle)
+ }
+
+ /// Swaps the active and the held call.
+ public func swapCalls() throws {
+ try requireCallHold()
+ guard heldCall != nil, phase == .active else { throw CallEngineError.invalidPhase(phase) }
+ try sendHold(.holdActiveAcceptOther, .swap)
+ }
+
+ /// Takes the waiting call. `endingCurrent`: ends the current call instead of holding it. With a
+ /// call already on hold, only ending the current call is possible.
+ public func acceptWaitingCall(endingCurrent: Bool) throws {
+ try requireCallHold()
+ guard waitingCall != nil, phase == .active || phase == .held else { throw CallEngineError.invalidPhase(phase) }
+ guard endingCurrent || heldCall == nil else { throw CallEngineError.unsupported }
+ if endingCurrent, let call { localEndRequestedFor = call.id }
+ try sendHold(endingCurrent ? .releaseActiveAcceptOther : .holdActiveAcceptOther,
+ endingCurrent ? .acceptWaitingEnding : .acceptWaitingHolding)
+ }
+
+ /// Rejects the waiting call; the current call goes on.
+ public func rejectWaitingCall() throws {
+ try requireCallHold()
+ guard waitingCall != nil else { throw CallEngineError.invalidPhase(phase) }
+ try sendHold(.releaseHeldOrRejectWaiting, .rejectWaiting)
+ }
+
+ /// Ends the held call; the active call goes on.
+ public func endHeldCall() throws {
+ try requireCallHold()
+ guard heldCall != nil, waitingCall == nil else { throw CallEngineError.invalidPhase(phase) }
+ try sendHold(.releaseHeldOrRejectWaiting, .endHeld)
+ }
+
+ private func requireCallHold() throws {
+ try requireConnected()
+ guard transport.supportsCallHold else { throw CallEngineError.unsupported }
+ }
+
+ private func sendHold(_ action: CallHoldAction, _ request: HoldRequest) throws {
+ log.log(.command, "call hold \(action.rawValue) (\(request)) -> callHold()")
+ try perform { try transport.callHold(action) }
+ holdRequest = request
+ }
+
/// "Move to phone": hands the call audio back to the phone.
public func transferAudioToPhone() throws {
try requireConnected()
@@ -365,6 +447,12 @@ public final class CallEngine {
historyRefreshTask?.cancel()
historyRefreshTask = nil
waitingCall = nil
+ holdRequest = nil
+ resolvedWaiting = nil
+ if let held = heldCall {
+ heldCall = nil
+ recordBackground(held, .connectionLost)
+ }
audioRoute = .phone
// A hand-back deferred by the transport is dropped when the link goes down.
expectingAudioClose = false
@@ -420,9 +508,20 @@ public final class CallEngine {
}
case .none:
- if waitingCall != nil, phase == .active || phase == .held {
- log.log(.state, "waiting call gone")
- waitingCall = nil
+ if let waiting = waitingCall, phase == .active || phase == .held {
+ switch holdRequest {
+ case .acceptWaitingHolding:
+ promoteWaiting(holdingCurrent: true)
+ case .acceptWaitingEnding:
+ promoteWaiting(holdingCurrent: false)
+ case .rejectWaiting:
+ waitingCall = nil
+ recordBackground(waiting, .declined)
+ default:
+ log.log(.state, "waiting call gone")
+ waitingCall = nil
+ resolvedWaiting = (waiting, scheduler.now)
+ }
// If the waiting call was taken (calls swapped), the list shows the new active call.
requestCallList(resetAttempts: true)
} else if phase == .ringing || phase == .dialing, indicators.call != true, let call {
@@ -472,6 +571,12 @@ public final class CallEngine {
private func callIndicatorCleared() {
guard let call, call.phase == .active || call.phase == .held else { return }
+ if let held = heldCall {
+ // No call left at all: the held one is over too.
+ heldCall = nil
+ recordBackground(held, localEndRequestedFor == call.id ? .hungUp : .remoteEnded)
+ }
+ holdRequest = nil
let waiting = waitingCall
finish(localEndRequestedFor == call.id ? .hungUp : .remoteEnded)
// HFP: when the active call ends while another one waits, the waiting call keeps ringing.
@@ -485,13 +590,108 @@ public final class CallEngine {
private func callHeldChanged(_ held: CallHeldState) {
guard var call, call.phase == .active || call.phase == .held else { return }
+ if heldCall != nil {
+ switch held {
+ case .activeAndHeld:
+ return // still one active and one held call (also after a swap)
+ case .heldOnly:
+ // The active call ended (other party or on the phone); the held one stays on hold.
+ resumeHeld(after: localEndRequestedFor == call.id ? .hungUp : .remoteEnded, phase: .held)
+ case .none:
+ if holdRequest == .endActive {
+ resumeHeld(after: .hungUp, phase: .active)
+ } else if let other = heldCall {
+ // The held call ended (on this Mac, by the other party or on the phone).
+ heldCall = nil
+ recordBackground(other, holdRequest == .endHeld ? .hungUp : .remoteEnded)
+ if holdRequest == .endHeld { holdRequest = nil }
+ }
+ }
+ return
+ }
switch held {
case .heldOnly:
transition(&call, to: .held)
- case .none, .activeAndHeld:
+ self.call = call
+ case .none:
transition(&call, to: .active)
+ self.call = call
+ case .activeAndHeld:
+ if waitingCall != nil || recentlyResolvedWaiting != nil {
+ // The waiting call was taken (on this Mac or on the phone) and the current call held.
+ promoteWaiting(holdingCurrent: true)
+ } else {
+ // A second call we did not see coming (e.g. dialed on the phone): the old one is held.
+ log.log(.state, "second call appeared; the current call is on hold")
+ transition(&call, to: .held)
+ heldCall = call
+ var second = CallInfo(direction: .unknown, callerID: .unknown, phase: .active, startedAt: scheduler.now)
+ second.connectedAt = scheduler.now
+ self.call = second
+ }
+ requestCallList(resetAttempts: true)
}
- self.call = call
+ }
+
+ private var recentlyResolvedWaiting: CallInfo? {
+ guard let resolvedWaiting,
+ scheduler.now.timeIntervalSince(resolvedWaiting.at) <= configuration.waitingCallTakenWindow else { return nil }
+ return resolvedWaiting.call
+ }
+
+ /// The waiting call becomes the active call; the current one is held or ended.
+ private func promoteWaiting(holdingCurrent: Bool) {
+ guard let waiting = waitingCall ?? recentlyResolvedWaiting, var current = call,
+ current.phase == .active || current.phase == .held else { return }
+ waitingCall = nil
+ resolvedWaiting = nil
+ var next = waiting
+ next.phase = .active
+ next.connectedAt = scheduler.now
+ if holdingCurrent {
+ transition(¤t, to: .held)
+ heldCall = current
+ } else {
+ finish(localEndRequestedFor == current.id ? .hungUp : .remoteEnded)
+ endedClearTask?.cancel()
+ endedClearTask = nil
+ }
+ log.log(.state, "call \(next.id.uuidString.prefix(8)) waiting -> active\(holdingCurrent ? "; previous call held" : "")")
+ self.call = next
+ if next.callerID == .unknown { requestCallList(resetAttempts: true) }
+ }
+
+ /// The active call ended; the held call takes its place (`phase`: held, or active when the phone
+ /// resumed it).
+ private func resumeHeld(after reason: CallEndReason, phase: CallPhase) {
+ guard let held = heldCall else { return }
+ heldCall = nil
+ holdRequest = nil
+ finish(reason)
+ endedClearTask?.cancel()
+ endedClearTask = nil
+ var resumed = held
+ transition(&resumed, to: phase)
+ self.call = resumed
+ }
+
+ private func swapActiveAndHeld() {
+ guard var active = call, var held = heldCall, active.phase == .active else { return }
+ transition(&active, to: .held)
+ transition(&held, to: .active)
+ heldCall = active
+ self.call = held
+ }
+
+ /// A call that ended while it was not the shown call (held or waiting): into the recent calls.
+ private func recordBackground(_ background: CallInfo, _ reason: CallEndReason) {
+ var ended = background
+ ended.phase = .ended
+ ended.endedAt = scheduler.now
+ ended.endReason = reason
+ log.log(.state, "call \(ended.id.uuidString.prefix(8)) ended in the background: \(reason.rawValue)")
+ callHistory.record(ended)
+ onCallFinished?(ended)
}
private func callerIDReceived(_ callerID: CallerID) {
@@ -521,6 +721,12 @@ public final class CallEngine {
waitingCall = waiting
}
+ if var held = heldCall, let entry = calls.first(where: { $0.status == .held || $0.status == .heldByResponseAndHold }) {
+ held.callerID = merged(held.callerID, with: CallerID(listedNumber: entry.number))
+ if held.direction == .unknown { held.direction = entry.direction }
+ heldCall = held
+ }
+
guard var call, call.phase.isLive else { return }
if let entry = Self.entry(in: calls, matching: call) {
let listed = CallerID(listedNumber: entry.number)
@@ -547,6 +753,10 @@ public final class CallEngine {
}
private func commandCompleted(_ command: HFPCommand, _ result: CommandResult) {
+ if command == .callHold {
+ holdCompleted(result)
+ return
+ }
guard !result.isSuccess else { return }
switch command {
case .dial:
@@ -566,11 +776,51 @@ public final class CallEngine {
}
case .selectPhonebook, .readPhonebook:
break // Reported as `.callHistory` by the transport.
+ case .callHold:
+ break // Handled above.
case .other:
break // Commands IOBluetooth sends on its own (SMS probing etc.) fail routinely.
}
}
+ /// The phone answered a three-way calling command. Success applies the request (unless an
+ /// indicator already did); the call list then confirms numbers and states.
+ private func holdCompleted(_ result: CommandResult) {
+ guard let request = holdRequest else { return }
+ holdRequest = nil
+ guard result.isSuccess else {
+ lastCommandFailure = CommandFailure(command: .callHold, result: result, date: scheduler.now)
+ if request == .endActive || request == .acceptWaitingEnding { localEndRequestedFor = nil }
+ log.log(.warning, "phone rejected the call hold command (\(request)): \(result)")
+ return
+ }
+ switch request {
+ case .toggle:
+ break // The callheld indicator moves the call between active and held.
+ case .swap:
+ swapActiveAndHeld()
+ case .acceptWaitingHolding:
+ promoteWaiting(holdingCurrent: true)
+ case .acceptWaitingEnding:
+ promoteWaiting(holdingCurrent: false)
+ case .rejectWaiting:
+ if let waiting = waitingCall {
+ waitingCall = nil
+ recordBackground(waiting, .declined)
+ }
+ case .endActive:
+ if let call, heldCall != nil {
+ resumeHeld(after: localEndRequestedFor == call.id ? .hungUp : .remoteEnded, phase: .active)
+ }
+ case .endHeld:
+ if let held = heldCall {
+ heldCall = nil
+ recordBackground(held, .hungUp)
+ }
+ }
+ if phase.isLive { requestCallList(resetAttempts: true) }
+ }
+
private func audioConnectionChanged(isOpen: Bool) {
if isOpen {
// The transport re-reports an open SCO after the SLC is up; a hand-back may be under way.
diff --git a/Sources/PickupCore/HFPTransport.swift b/Sources/PickupCore/HFPTransport.swift
index 2c3fdba..4d90b25 100644
--- a/Sources/PickupCore/HFPTransport.swift
+++ b/Sources/PickupCore/HFPTransport.swift
@@ -94,6 +94,11 @@ public protocol HFPTransport: AnyObject {
/// Reads the newest `count` entries of a call list (`AT+CPBS="DC"`, then `AT+CPBR=1,`).
/// The answer arrives as `.callHistory`. Requests are queued and answered in order.
func readCallHistory(_ list: CallHistoryList, count: Int) throws
+ /// True if the phone supports three-way calling (`+BRSF` and `+CHLD` modes 0, 1 and 2).
+ var supportsCallHold: Bool { get }
+ /// Holds, resumes, swaps or ends calls, or answers/rejects a waiting call. `AT+CHLD=`;
+ /// the answer arrives as `.commandCompleted(.callHold, …)`, the effect as indicator changes.
+ func callHold(_ action: CallHoldAction) throws
}
public extension HFPTransport {
@@ -101,4 +106,11 @@ public extension HFPTransport {
func readCallHistory(_ list: CallHistoryList, count: Int) throws {
throw TransportError.unsupported
}
+
+ /// Transports without three-way calling.
+ var supportsCallHold: Bool { false }
+
+ func callHold(_ action: CallHoldAction) throws {
+ throw TransportError.unsupported
+ }
}
diff --git a/Sources/PickupCore/HFPTypes.swift b/Sources/PickupCore/HFPTypes.swift
index 1011a72..f2d0976 100644
--- a/Sources/PickupCore/HFPTypes.swift
+++ b/Sources/PickupCore/HFPTypes.swift
@@ -144,6 +144,7 @@ public enum HFPCommand: Equatable, Sendable {
case currentCalls // AT+CLCC
case selectPhonebook // AT+CPBS=""
case readPhonebook // AT+CPBR=,
+ case callHold // AT+CHLD= (not the AT+CHLD=? query of the SLC set-up)
case other(String) // e.g. "AT+BRSF", "AT+CIND?"
public init(atCommand line: String) {
@@ -162,6 +163,8 @@ public enum HFPCommand: Equatable, Sendable {
self = .selectPhonebook
} else if command.hasPrefix("AT+CPBR="), command != "AT+CPBR=?" {
self = .readPhonebook
+ } else if command.hasPrefix("AT+CHLD="), command != "AT+CHLD=?" {
+ self = .callHold
} else {
self = .other(HFPCommand.name(of: command))
}
@@ -195,6 +198,17 @@ public enum HFPCommand: Equatable, Sendable {
}
}
+/// Three-way calling commands (`AT+CHLD=`, HFP 1.8 section 4.22). The phone applies them to its
+/// calls: with one call, `2` holds or resumes it; with a held and an active call, `2` swaps them.
+public enum CallHoldAction: Int, Equatable, Sendable {
+ /// `0`: releases the held call, or rejects the waiting call.
+ case releaseHeldOrRejectWaiting = 0
+ /// `1`: ends the active call and takes the held or waiting call.
+ case releaseActiveAcceptOther = 1
+ /// `2`: puts the active call on hold and takes the held or waiting call.
+ case holdActiveAcceptOther = 2
+}
+
/// Final result of an AT command.
public enum CommandResult: Equatable, Sendable {
case ok
diff --git a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
index 4fab8de..eb70006 100644
--- a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
+++ b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
@@ -16,6 +16,8 @@ final class AppleHFPTransportTests: XCTestCase {
var disconnectDropsSLC = false
var onClosed: (@MainActor () -> Void)?
var deviceName: String? = "Pixel 9 Pro"
+ var audioGatewayFeatures: UInt32 = 0
+ var callHoldModes: UInt32 = 0
var underlying: AnyObject { self }
private(set) var calls: [String] = []
private(set) var delegates: [HandsFreeDelegate] = []
@@ -608,6 +610,29 @@ final class AppleHFPTransportTests: XCTestCase {
events.filter { if case .callHistory = $0 { true } else { false } }
}
+ func testCallHoldNeedsThreeWayCallingAndReportsTheAnswer() throws {
+ let transport = makeTransport()
+ let link = connected(transport)
+ XCTAssertFalse(transport.supportsCallHold)
+ XCTAssertThrowsError(try transport.callHold(.holdActiveAcceptOther)) { XCTAssertEqual($0 as? TransportError, .unsupported) }
+ XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CHLD") })
+
+ link.audioGatewayFeatures = 0b1 // three-way calling
+ link.callHoldModes = 0b1011 // CHLD 0, 1 and 2
+ XCTAssertTrue(transport.supportsCallHold)
+ try transport.callHold(.holdActiveAcceptOther)
+ XCTAssertEqual(link.calls.last, "AT+CHLD=2")
+ transport.sentLine("AT+CHLD=2", detail: nil, channel: link.channel)
+ transport.receivedLine("OK", detail: nil)
+ XCTAssertTrue(events.contains(.commandCompleted(.callHold, .ok)))
+ }
+
+ func testCallHoldSupportNeedsModesZeroOneAndTwo() {
+ XCTAssertTrue(AppleHFPTransport.supportsCallHold(features: 0x3ef, holdModes: 0x7f))
+ XCTAssertFalse(AppleHFPTransport.supportsCallHold(features: 0x3ee, holdModes: 0x7f), "no three-way calling bit")
+ XCTAssertFalse(AppleHFPTransport.supportsCallHold(features: 0x1, holdModes: 0b0011), "mode 2 missing")
+ }
+
func testCallHistoryIsSelectedThenReadOneListAtATime() async throws {
let transport = makeTransport()
let link = connected(transport)
diff --git a/Tests/PickupCoreTests/CallHoldTests.swift b/Tests/PickupCoreTests/CallHoldTests.swift
new file mode 100644
index 0000000..9f4f0b1
--- /dev/null
+++ b/Tests/PickupCoreTests/CallHoldTests.swift
@@ -0,0 +1,256 @@
+import XCTest
+@testable import PickupCore
+
+/// Three-way calling: hold/resume, call waiting (hold and take, end and take, reject), swapping and
+/// ending one of two calls. The phone's indicators may arrive before or after its OK.
+@MainActor
+final class CallHoldTests: XCTestCase {
+ private var transport: MockHFPTransport!
+ private var scheduler: ManualScheduler!
+ private var engine: CallEngine!
+ private var finished: [CallInfo] = []
+
+ private let jane = "+49300000123"
+ private let john = "+49300000456"
+
+ override func setUp() async throws {
+ transport = MockHFPTransport()
+ transport.supportsCallHold = true
+ scheduler = ManualScheduler()
+ engine = CallEngine(transport: transport, scheduler: scheduler)
+ engine.onCallFinished = { [weak self] in self?.finished.append($0) }
+ transport.bringUp()
+ }
+
+ private var holdCommands: [CallHoldAction] {
+ transport.commands.compactMap { if case .callHold(let action) = $0 { action } else { nil } }
+ }
+
+ /// Jane's incoming call, answered.
+ private func activeCallWithJane() {
+ transport.emit(.callSetup(.incoming))
+ transport.emit(.currentCalls([HFPCall(index: 1, direction: .incoming, status: .incoming, number: jane)]))
+ transport.emit(.call(true))
+ transport.emit(.callSetup(.none))
+ XCTAssertEqual(engine.phase, .active)
+ }
+
+ /// John calls while the call with Jane is active.
+ private func johnWaits() {
+ transport.emit(.callSetup(.incoming))
+ transport.emit(.callWaiting(john))
+ XCTAssertEqual(engine.waitingCall?.number, john)
+ }
+
+ private func ok() { transport.emit(.commandCompleted(.callHold, .ok)) }
+
+ // MARK: Single call
+
+ func testHoldAndResumeASingleCall() throws {
+ activeCallWithJane()
+ try engine.toggleHold()
+ XCTAssertEqual(holdCommands, [.holdActiveAcceptOther])
+ ok()
+ transport.emit(.callHeld(.heldOnly))
+ XCTAssertEqual(engine.phase, .held)
+
+ try engine.toggleHold()
+ ok()
+ transport.emit(.callHeld(.none))
+ XCTAssertEqual(engine.phase, .active)
+ XCTAssertEqual(engine.call?.number, jane)
+ }
+
+ func testPhonesWithoutThreeWayCallingRefuse() {
+ transport.supportsCallHold = false
+ activeCallWithJane()
+ XCTAssertThrowsError(try engine.toggleHold()) { XCTAssertEqual($0 as? CallEngineError, .unsupported) }
+ XCTAssertFalse(engine.supportsCallHold)
+ }
+
+ // MARK: Call waiting
+
+ func testHoldAndTakeTheWaitingCallOKFirst() throws {
+ activeCallWithJane()
+ johnWaits()
+ try engine.acceptWaitingCall(endingCurrent: false)
+ XCTAssertEqual(holdCommands, [.holdActiveAcceptOther])
+ ok()
+ transport.emit(.callSetup(.none))
+ transport.emit(.callHeld(.activeAndHeld))
+ XCTAssertEqual(engine.call?.number, john)
+ XCTAssertEqual(engine.phase, .active)
+ XCTAssertEqual(engine.heldCall?.number, jane)
+ XCTAssertEqual(engine.heldCall?.phase, .held)
+ XCTAssertNil(engine.waitingCall)
+ }
+
+ func testHoldAndTakeTheWaitingCallIndicatorsFirst() throws {
+ activeCallWithJane()
+ johnWaits()
+ try engine.acceptWaitingCall(endingCurrent: false)
+ transport.emit(.callHeld(.activeAndHeld))
+ transport.emit(.callSetup(.none))
+ ok()
+ XCTAssertEqual(engine.call?.number, john)
+ XCTAssertEqual(engine.heldCall?.number, jane)
+ }
+
+ func testWaitingCallTakenOnThePhoneIsRecognised() {
+ activeCallWithJane()
+ johnWaits()
+ transport.emit(.callSetup(.none)) // no request from this Mac
+ transport.emit(.callHeld(.activeAndHeld))
+ XCTAssertEqual(engine.call?.number, john)
+ XCTAssertEqual(engine.heldCall?.number, jane)
+ }
+
+ func testEndAndTakeTheWaitingCall() throws {
+ activeCallWithJane()
+ johnWaits()
+ try engine.acceptWaitingCall(endingCurrent: true)
+ XCTAssertEqual(holdCommands, [.releaseActiveAcceptOther])
+ transport.emit(.callSetup(.none))
+ ok()
+ XCTAssertEqual(engine.call?.number, john)
+ XCTAssertEqual(engine.phase, .active)
+ XCTAssertNil(engine.heldCall)
+ XCTAssertEqual(finished.map(\.number), [jane])
+ XCTAssertEqual(finished.first?.endReason, .hungUp)
+ }
+
+ func testRejectTheWaitingCall() throws {
+ activeCallWithJane()
+ johnWaits()
+ try engine.rejectWaitingCall()
+ XCTAssertEqual(holdCommands, [.releaseHeldOrRejectWaiting])
+ ok()
+ transport.emit(.callSetup(.none))
+ XCTAssertNil(engine.waitingCall)
+ XCTAssertEqual(engine.call?.number, jane)
+ XCTAssertEqual(engine.phase, .active)
+ XCTAssertEqual(engine.callHistory.entries(.missed).map(\.number), [john], "a rejected waiting call is in the missed list")
+ }
+
+ func testRejectedCommandLeavesTheCallsAlone() throws {
+ activeCallWithJane()
+ johnWaits()
+ try engine.acceptWaitingCall(endingCurrent: false)
+ transport.emit(.commandCompleted(.callHold, .error))
+ XCTAssertEqual(engine.lastCommandFailure?.command, .callHold)
+ XCTAssertEqual(engine.call?.number, jane)
+ XCTAssertEqual(engine.waitingCall?.number, john)
+ XCTAssertNil(engine.heldCall)
+ }
+
+ // MARK: Two calls
+
+ private func janeHeldJohnActive() throws {
+ activeCallWithJane()
+ johnWaits()
+ try engine.acceptWaitingCall(endingCurrent: false)
+ ok()
+ transport.emit(.callSetup(.none))
+ transport.emit(.callHeld(.activeAndHeld))
+ transport.clearCommands()
+ }
+
+ func testSwapCalls() throws {
+ try janeHeldJohnActive()
+ try engine.swapCalls()
+ XCTAssertEqual(holdCommands, [.holdActiveAcceptOther])
+ ok()
+ XCTAssertEqual(engine.call?.number, jane)
+ XCTAssertEqual(engine.phase, .active)
+ XCTAssertEqual(engine.heldCall?.number, john)
+ XCTAssertEqual(engine.heldCall?.phase, .held)
+ }
+
+ func testHangingUpTheActiveCallResumesTheHeldOne() throws {
+ try janeHeldJohnActive()
+ try engine.hangUp()
+ XCTAssertEqual(holdCommands, [.releaseActiveAcceptOther], "not AT+CHUP: the held call comes back")
+ transport.emit(.callHeld(.none))
+ ok()
+ XCTAssertEqual(engine.call?.number, jane)
+ XCTAssertEqual(engine.phase, .active)
+ XCTAssertNil(engine.heldCall)
+ XCTAssertEqual(finished.last?.number, john)
+ XCTAssertEqual(finished.last?.endReason, .hungUp)
+ }
+
+ /// Found by the fuzz test: a phone without three-way calling may still report a second call; hanging
+ /// up must not be blocked by the missing CHLD support.
+ func testHangUpWorksWithASecondCallButNoThreeWayCalling() throws {
+ transport.supportsCallHold = false
+ activeCallWithJane()
+ transport.emit(.callHeld(.activeAndHeld))
+ XCTAssertNotNil(engine.heldCall)
+ transport.clearCommands()
+ XCTAssertNoThrow(try engine.hangUp())
+ XCTAssertEqual(transport.commands, [.endCall])
+ }
+
+ func testEndTheHeldCall() throws {
+ try janeHeldJohnActive()
+ try engine.endHeldCall()
+ XCTAssertEqual(holdCommands, [.releaseHeldOrRejectWaiting])
+ ok()
+ transport.emit(.callHeld(.none))
+ XCTAssertEqual(engine.call?.number, john)
+ XCTAssertEqual(engine.phase, .active)
+ XCTAssertNil(engine.heldCall)
+ XCTAssertEqual(finished.last?.number, jane)
+ }
+
+ func testOtherPartyOfTheActiveCallHangsUp() throws {
+ try janeHeldJohnActive()
+ transport.emit(.callHeld(.heldOnly))
+ XCTAssertEqual(engine.call?.number, jane)
+ XCTAssertEqual(engine.phase, .held, "the held call stays on hold until the user resumes it")
+ XCTAssertNil(engine.heldCall)
+ XCTAssertEqual(finished.last?.endReason, .remoteEnded)
+ }
+
+ func testOtherPartyOfTheHeldCallHangsUp() throws {
+ try janeHeldJohnActive()
+ transport.emit(.callHeld(.none))
+ XCTAssertEqual(engine.call?.number, john)
+ XCTAssertNil(engine.heldCall)
+ XCTAssertEqual(finished.last?.number, jane)
+ XCTAssertEqual(finished.last?.endReason, .remoteEnded)
+ }
+
+ func testAllCallsEnd() throws {
+ try janeHeldJohnActive()
+ transport.emit(.call(false))
+ XCTAssertEqual(engine.phase, .ended)
+ XCTAssertNil(engine.heldCall)
+ XCTAssertEqual(Set(finished.compactMap(\.number)), [jane, john])
+ }
+
+ func testLinkLossEndsBothCalls() throws {
+ try janeHeldJohnActive()
+ transport.emit(.linkStateChanged(.disconnected))
+ XCTAssertNil(engine.heldCall)
+ XCTAssertEqual(finished.filter { $0.endReason == .connectionLost }.count, 2)
+ }
+
+ func testCallListFillsInTheHeldNumber() {
+ activeCallWithJane()
+ transport.emit(.callHeld(.activeAndHeld)) // a second call dialed on the phone
+ XCTAssertEqual(engine.heldCall?.number, jane)
+ transport.emit(.currentCalls([
+ HFPCall(index: 1, direction: .incoming, status: .held, number: jane),
+ HFPCall(index: 2, direction: .outgoing, status: .active, number: john),
+ ]))
+ XCTAssertEqual(engine.call?.number, john)
+ XCTAssertEqual(engine.call?.direction, .outgoing)
+ }
+
+ func testCallHoldCommandIsRecognised() {
+ XCTAssertEqual(HFPCommand(atCommand: "AT+CHLD=2"), .callHold)
+ XCTAssertEqual(HFPCommand(atCommand: "AT+CHLD=?"), .other("AT+CHLD=?"))
+ XCTAssertNil(HFPCommand.responsePrefix(of: "AT+CHLD=1"))
+ }
+}
diff --git a/Tests/PickupCoreTests/Support/TestDoubles.swift b/Tests/PickupCoreTests/Support/TestDoubles.swift
index 8ee3a29..4a92fbb 100644
--- a/Tests/PickupCoreTests/Support/TestDoubles.swift
+++ b/Tests/PickupCoreTests/Support/TestDoubles.swift
@@ -61,6 +61,7 @@ final class MockHFPTransport: HFPTransport {
case connect, disconnect, reconnect, prepareForSleep, resumeAfterSleep, invalidate
case acceptCallOnPhone, endCall, dial(String), sendDTMF(String), transferAudioToPhone, requestCurrentCalls
case readCallHistory(CallHistoryList)
+ case callHold(CallHoldAction)
}
var supportsAudio = false
@@ -137,6 +138,12 @@ final class MockHFPTransport: HFPTransport {
func requestCurrentCalls() throws { try record(.requestCurrentCalls) }
/// Off by default (like a phone that does not share its call history), so other tests see no extra commands.
var supportsCallHistory = false
+ /// Off by default, like a phone without three-way calling.
+ var supportsCallHold = false
+ func callHold(_ action: CallHoldAction) throws {
+ guard supportsCallHold else { throw TransportError.unsupported }
+ try record(.callHold(action))
+ }
func readCallHistory(_ list: CallHistoryList, count: Int) throws {
guard supportsCallHistory else { throw TransportError.unsupported }
try record(.readCallHistory(list))
diff --git a/docs/BLUETOOTH.md b/docs/BLUETOOTH.md
index dd7c0f5..f7f869d 100644
--- a/docs/BLUETOOTH.md
+++ b/docs/BLUETOOTH.md
@@ -17,6 +17,7 @@ Rules for `AppleHFPTransport`:
- **Diagnostics:** every AT line carries the object tag (`[hf#1]`), and connect lines show how many hands-free objects exist. An `AT+BRSF` on a channel the transport does not own is logged as an error, because that is the signature of this crash. Log lines are written with one `write(2)` each, before IOBluetooth processes the data, so the last line before a crash shows what triggered it.
- **Call list reads (`AT+CPBS`/`AT+CPBR`) never overlap IOBluetooth's own commands.** On 2026-10-29 a read started 2 s after the SLC, while IOBluetooth's follow-up `AT+VGS`/`AT+VGM` were still due; they went out 6 s late and IOBluetooth crashed in `-[IOBluetoothHandsFreeDevice processResultCode:]` (`objc_retain` on a freed object) on the next incoming data. Reads that started after those commands never failed. A read therefore waits 10 s after the SLC (`Timeouts.historyQuietPeriod`) and until no command waits for its reply, and the two commands of one list are sent strictly one after the other.
+- **Three-way calling (`AT+CHLD=0/1/2`) is offered only when the phone announces it** (`+BRSF` bit 0 and `+CHLD` modes 0, 1 and 2). The commands go out through `sendATCommand` like the call list reads, but only during a call, long after the SLC set-up. The engine applies a command's effect on the phone's `OK` or on the matching `callheld`/`callsetup` indicator, whichever comes first; the call list (`AT+CLCC`) then confirms numbers and states. Not yet verified on hardware.
### Known edge cases to revisit after hardware tests
- Third reconnect review, item L3: deferred on purpose until hardware tests with the first signed build confirm the open IOBluetooth behaviours.
From 2456eea6cc571e04e4e3c1cbbc29500e56f46be2 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 10:29:22 +0100
Subject: [PATCH 13/20] style: centre the About panel and tighten the battery
percentage
The About panel's description and links are centred, with room above and
below the links. The menu bar draws handset and percentage as one template
image with a small gap, the popover's gauge sits closer to its number, and the
space before the percent sign is a narrow no-break space.
Co-Authored-By: Claude Opus 5.5
---
App/Sources/Pickup/MenuView.swift | 2 +-
App/Sources/Pickup/PhoneBattery.swift | 4 +-
App/Sources/Pickup/StatusItemController.swift | 63 ++++++++++++++-----
.../PickupAppTests/PhoneBatteryTests.swift | 2 +-
4 files changed, 54 insertions(+), 17 deletions(-)
diff --git a/App/Sources/Pickup/MenuView.swift b/App/Sources/Pickup/MenuView.swift
index bac48aa..15584ba 100644
--- a/App/Sources/Pickup/MenuView.swift
+++ b/App/Sources/Pickup/MenuView.swift
@@ -165,7 +165,7 @@ private struct BatteryGauge: View {
var body: some View {
let text = PhoneBattery.percentText(level: level)
- HStack(spacing: 4) {
+ HStack(spacing: 2) {
HStack(spacing: 1) {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.strokeBorder(Color.secondary, lineWidth: 1)
diff --git a/App/Sources/Pickup/PhoneBattery.swift b/App/Sources/Pickup/PhoneBattery.swift
index 8d918d1..7761236 100644
--- a/App/Sources/Pickup/PhoneBattery.swift
+++ b/App/Sources/Pickup/PhoneBattery.swift
@@ -20,8 +20,10 @@ enum PhoneBattery {
/// Filled share of the gauge, 0...1.
static func fraction(level: Int) -> Double { Double(percent(level: level)) / 100 }
- /// "60 %" (German) or "60%" (English), in the user's locale.
+ /// "60 %" (German) or "60%" (English), in the user's locale. The space some locales put before
+ /// the percent sign is a narrow one (half the width), which reads better at small sizes.
static func percentText(level: Int, locale: Locale = .current) -> String {
(Double(percent(level: level)) / 100).formatted(.percent.locale(locale))
+ .replacingOccurrences(of: "\u{00A0}", with: "\u{202F}")
}
}
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index 2a21059..23609f3 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -73,17 +73,17 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
private func updateIcon(connected: Bool, battery: Int?, missed: Bool) {
guard let button = statusItem.button else { return }
- button.image = Self.menuBarImage(connected: connected)
- if let battery {
+ let icon = Self.menuBarImage(connected: connected)
+ if let battery, let icon {
+ // One template image (handset + percentage): NSButton's own image-title gap is too wide.
+ button.image = Self.withPercentage(icon, PhoneBattery.percentText(level: battery))
statusItem.length = NSStatusItem.variableLength
- button.imagePosition = .imageLeading
- button.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize - 1, weight: .regular)
- button.title = PhoneBattery.percentText(level: battery)
} else {
- button.title = ""
- button.imagePosition = .imageOnly
+ button.image = icon
statusItem.length = NSStatusItem.squareLength
}
+ button.title = ""
+ button.imagePosition = .imageOnly
let name = String(localized: "Pickup", comment: "Accessibility label of the menu bar icon. The app name.")
var label = missed
? String(localized: "\(name), missed calls", comment: "Accessibility label of the menu bar icon while it shows the red dot for missed calls. %@ is the app name.")
@@ -99,14 +99,16 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
positionMissedDot()
}
- /// At the top right of the handset, also when the percentage widens the button.
+ /// At the top right of the handset, also when the percentage widens the image.
private func positionMissedDot() {
guard let button = statusItem.button else { return }
let image = (button.cell as? NSButtonCell)?.imageRect(forBounds: button.bounds)
?? NSRect(x: (button.bounds.width - 18) / 2, y: (button.bounds.height - 18) / 2, width: 18, height: 18)
+ // The handset is the image's left 18 pt (the percentage, if shown, follows it).
+ let handset = NSRect(x: image.minX, y: image.midY - 9, width: 18, height: 18)
let size = StatusBadgeDot.diameter
- let y = button.isFlipped ? image.minY - 1 : image.maxY - size + 1
- missedDot.frame = NSRect(x: image.maxX - size + 1, y: y, width: size, height: size)
+ let y = button.isFlipped ? handset.minY - 1 : handset.maxY - size + 1
+ missedDot.frame = NSRect(x: handset.maxX - size + 1, y: y, width: size, height: size)
}
/// The template PDFs from App/Resources/MenuBar (copied into the bundle by scripts/build-app.sh). Outside the
@@ -122,6 +124,25 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
return image
}
+ /// Gap between the handset and the battery percentage in the menu bar.
+ private static let percentageGap: CGFloat = 3
+
+ /// The handset with the percentage to its right, drawn as one template image so the menu bar
+ /// tints both for light, dark and selected.
+ private static func withPercentage(_ icon: NSImage, _ text: String) -> NSImage {
+ let font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize - 1, weight: .regular)
+ let string = NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: NSColor.black])
+ let textSize = string.size()
+ let size = NSSize(width: ceil(icon.size.width + percentageGap + textSize.width), height: max(icon.size.height, ceil(textSize.height)))
+ let image = NSImage(size: size, flipped: false) { _ in
+ icon.draw(in: NSRect(x: 0, y: (size.height - icon.size.height) / 2, width: icon.size.width, height: icon.size.height))
+ string.draw(at: NSPoint(x: icon.size.width + percentageGap, y: (size.height - textSize.height) / 2))
+ return true
+ }
+ image.isTemplate = true
+ return image
+ }
+
/// Shows the chosen global shortcut as the menu bar icon's tooltip.
private func trackShortcutTooltip() {
withObservationTracking {
@@ -294,11 +315,25 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
(AppInfo.repositoryURL, String(localized: "Source code on GitHub", comment: "Link text in the About panel.")),
(AppInfo.websiteURL, nil), // shown as its domain
]
- for case let (string?, text) in links {
- guard let url = URL(string: string), let title = text ?? url.host() else { continue }
+ let shown = links.compactMap { string, text -> (URL, String)? in
+ guard let string, let url = URL(string: string), let title = text ?? url.host() else { return nil }
+ return (url, title)
+ }
+ for (index, (url, title)) in shown.enumerated() {
+ // Centred like the rest of the panel; the links get room above the first and below the last.
+ let style = NSMutableParagraphStyle()
+ style.alignment = .center
+ style.paragraphSpacingBefore = index == 0 ? 10 : 2
+ style.paragraphSpacing = index == shown.count - 1 ? 10 : 0
credits.append(NSAttributedString(string: "\n"))
- credits.append(NSAttributedString(
- string: title, attributes: [.link: url, .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)]))
+ credits.append(NSAttributedString(string: title, attributes: [
+ .link: url, .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), .paragraphStyle: style,
+ ]))
+ }
+ let centered = NSMutableParagraphStyle()
+ centered.alignment = .center
+ credits.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: credits.length)) { value, range, _ in
+ if value == nil { credits.addAttribute(.paragraphStyle, value: centered, range: range) }
}
options[.credits] = credits
NSApp.orderFrontStandardAboutPanel(options: options)
diff --git a/App/Tests/PickupAppTests/PhoneBatteryTests.swift b/App/Tests/PickupAppTests/PhoneBatteryTests.swift
index 10e9b61..7f4e539 100644
--- a/App/Tests/PickupAppTests/PhoneBatteryTests.swift
+++ b/App/Tests/PickupAppTests/PhoneBatteryTests.swift
@@ -12,7 +12,7 @@ final class PhoneBatteryTests: XCTestCase {
func testPercentTextFollowsTheLocale() {
XCTAssertEqual(PhoneBattery.percentText(level: 3, locale: Locale(identifier: "en_US")), "60%")
- XCTAssertEqual(PhoneBattery.percentText(level: 3, locale: Locale(identifier: "de_DE")), "60\u{00A0}%")
+ XCTAssertEqual(PhoneBattery.percentText(level: 3, locale: Locale(identifier: "de_DE")), "60\u{202F}%", "a narrow no-break space")
}
}
From 0664a9ea39a05246bb8072e070f0535eb5e8ee52 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 11:57:27 +0100
Subject: [PATCH 14/20] feat: welcome window and informal German
- A welcome window at the first launch and, while Pickup has no Developer ID
signature, after every update (macOS may reset permissions then). It shows
the phone, Bluetooth, Contacts and notification permissions with their state
and the button that grants them, and what to allow on the phone. Automatic
permission prompts are skipped while it is shown. Centred on the screen.
- German texts address the user with "du" instead of "Sie", including the
privacy usage descriptions.
- The battery percentage in the menu bar is one point smaller.
Co-Authored-By: Claude Opus 5.5
---
App/Resources/InfoPlist.xcstrings | 4 +-
App/Resources/Localizable.xcstrings | 474 ++++++++++++++++--
App/Sources/Pickup/AppModel.swift | 6 +-
App/Sources/Pickup/PickupApp.swift | 7 +-
App/Sources/Pickup/StatusItemController.swift | 36 +-
App/Sources/Pickup/Welcome.swift | 52 ++
App/Sources/Pickup/WelcomeView.swift | 208 ++++++++
.../PickupAppTests/WelcomePolicyTests.swift | 19 +
8 files changed, 752 insertions(+), 54 deletions(-)
create mode 100644 App/Sources/Pickup/Welcome.swift
create mode 100644 App/Sources/Pickup/WelcomeView.swift
create mode 100644 App/Tests/PickupAppTests/WelcomePolicyTests.swift
diff --git a/App/Resources/InfoPlist.xcstrings b/App/Resources/InfoPlist.xcstrings
index 05c431e..1dea09a 100644
--- a/App/Resources/InfoPlist.xcstrings
+++ b/App/Resources/InfoPlist.xcstrings
@@ -32,7 +32,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Pickup verwendet Bluetooth, um sich mit Ihrem Android-Telefon zu verbinden und dessen Anrufe zu steuern."
+ "value": "Pickup verwendet Bluetooth, um sich mit deinem Android-Telefon zu verbinden und dessen Anrufe zu steuern."
}
}
}
@@ -50,7 +50,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Pickup sucht die Namen von Anrufern in Ihren Kontakten."
+ "value": "Pickup sucht die Namen von Anrufern in deinen Kontakten."
}
}
}
diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings
index 64778b5..ce1843e 100644
--- a/App/Resources/Localizable.xcstrings
+++ b/App/Resources/Localizable.xcstrings
@@ -464,7 +464,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Pickup benötigt Ihre Bestätigung in den Systemeinstellungen unter „Allgemein“ > „Anmeldeobjekte“."
+ "value": "Pickup benötigt deine Bestätigung in den Systemeinstellungen unter „Allgemein“ > „Anmeldeobjekte“."
}
}
}
@@ -662,7 +662,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Das Anruf-Audio konnte auf diesem Mac nicht gestartet werden. Es bleibt auf Ihrem Telefon."
+ "value": "Das Anruf-Audio konnte auf diesem Mac nicht gestartet werden. Es bleibt auf deinem Telefon."
}
}
}
@@ -680,7 +680,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Das Anruf-Audio auf diesem Mac wurde unterbrochen. Es läuft wieder auf Ihrem Telefon."
+ "value": "Das Anruf-Audio auf diesem Mac wurde unterbrochen. Es läuft wieder auf deinem Telefon."
}
}
}
@@ -698,7 +698,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Das Anruf-Audio wurde auf Ihr Telefon verlegt."
+ "value": "Das Anruf-Audio wurde auf dein Telefon verlegt."
}
}
}
@@ -716,7 +716,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ihr Telefon ist nicht verbunden."
+ "value": "Dein Telefon ist nicht verbunden."
}
}
}
@@ -770,7 +770,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ihr Telefon unterstützt das nicht."
+ "value": "Dein Telefon unterstützt das nicht."
}
}
}
@@ -788,7 +788,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ihr Telefon konnte diese Aktion nicht ausführen."
+ "value": "Dein Telefon konnte diese Aktion nicht ausführen."
}
}
}
@@ -806,7 +806,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ihrem Telefon"
+ "value": "deinem Telefon"
}
}
}
@@ -932,7 +932,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Wählen Sie zuerst in den Einstellungen Ihr Telefon aus."
+ "value": "Wähle zuerst in den Einstellungen dein Telefon aus."
}
}
}
@@ -1148,7 +1148,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Koppeln Sie Ihr Telefon in den Systemeinstellungen unter „Bluetooth“ und wählen Sie es dann in den Einstellungen von Pickup aus."
+ "value": "Kopple dein Telefon in den Systemeinstellungen unter „Bluetooth“ und wähle es dann in den Einstellungen von Pickup aus."
}
}
}
@@ -1166,7 +1166,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Erlauben Sie Pickup unter „Datenschutz & Sicherheit“ > „Bluetooth“ den Zugriff, damit es mit Ihrem Telefon kommunizieren kann."
+ "value": "Erlaube Pickup unter „Datenschutz & Sicherheit“ > „Bluetooth“ den Zugriff, damit es mit deinem Telefon kommunizieren kann."
}
}
}
@@ -1184,7 +1184,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Stellen Sie sicher, dass Bluetooth eingeschaltet und das Telefon in der Nähe ist. Erlauben Sie diesem Mac auf Nachfrage den Zugriff auf Telefonanrufe (Pixel: Bluetooth-Einstellungen, „Telefonanrufe“)."
+ "value": "Stelle sicher, dass Bluetooth eingeschaltet und das Telefon in der Nähe ist. Erlaube diesem Mac auf Nachfrage den Zugriff auf Telefonanrufe (Pixel: Bluetooth-Einstellungen, „Telefonanrufe“)."
}
}
}
@@ -1328,7 +1328,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ein Klick auf eine Telefonnummer in Safari, Kontakten oder anderen Apps lässt Pickup die Nummer dann auf Ihrem Telefon anrufen."
+ "value": "Ein Klick auf eine Telefonnummer in Safari, Kontakten oder anderen Apps lässt Pickup die Nummer dann auf deinem Telefon anrufen."
}
}
}
@@ -1346,7 +1346,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Anrufernamen stammen aus Ihren Kontakten."
+ "value": "Anrufernamen stammen aus deinen Kontakten."
}
}
}
@@ -1364,7 +1364,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Pickup kann Ihre Kontakte nicht lesen, daher erscheinen Anrufer mit ihrer Nummer."
+ "value": "Pickup kann deine Kontakte nicht lesen, daher erscheinen Anrufer mit ihrer Nummer."
}
}
}
@@ -1418,7 +1418,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Diese Nummer ist ein Dienstcode und muss von Ihnen bestätigt werden."
+ "value": "Diese Nummer ist ein Dienstcode und muss von dir bestätigt werden."
}
}
}
@@ -1436,7 +1436,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Dieser Link möchte einen Dienstcode (z. B. eine Rufumleitung) an Ihr Telefon senden."
+ "value": "Dieser Link möchte einen Dienstcode (z. B. eine Rufumleitung) an dein Telefon senden."
}
}
}
@@ -1454,7 +1454,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Ihr Telefon ist nicht erreichbar"
+ "value": "Dein Telefon ist nicht erreichbar"
}
}
}
@@ -1472,7 +1472,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Warte auf die Verbindung zu Ihrem Telefon …"
+ "value": "Warte auf die Verbindung zu deinem Telefon …"
}
}
}
@@ -1562,7 +1562,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Telefonnummern-Links in Safari und anderen Apps öffnen noch eine andere App. Machen Sie Pickup zur Standard-App, damit diese Anrufe über Ihr Telefon geführt werden."
+ "value": "Telefonnummern-Links in Safari und anderen Apps öffnen noch eine andere App. Mach Pickup zur Standard-App, damit diese Anrufe über dein Telefon geführt werden."
}
}
}
@@ -1616,7 +1616,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Falls macOS die Änderung nicht übernimmt, öffnen Sie FaceTime, wählen Sie „Einstellungen“ und wählen Sie unter „Standard für Anrufe“ die App Pickup."
+ "value": "Falls macOS die Änderung nicht übernimmt, öffne FaceTime, wähle „Einstellungen“ und wähle unter „Standard für Anrufe“ die App Pickup."
}
}
}
@@ -1688,7 +1688,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Führen und beantworten Sie Anrufe Ihres Android-Telefons an Ihrem Mac."
+ "value": "Führe und beantworte Anrufe deines Android-Telefons an deinem Mac."
}
}
}
@@ -1742,7 +1742,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Verbinden Sie Ihr Telefon, um Anrufe zu starten."
+ "value": "Verbinde dein Telefon, um Anrufe zu starten."
}
}
}
@@ -1760,7 +1760,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Beenden Sie zuerst das aktuelle Gespräch."
+ "value": "Beende zuerst das aktuelle Gespräch."
}
}
}
@@ -2138,7 +2138,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Pickup zeigt Anrufernamen an und durchsucht beim Wählen Ihre Kontakte."
+ "value": "Pickup zeigt Anrufernamen an und durchsucht beim Wählen deine Kontakte."
}
}
}
@@ -2192,7 +2192,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Kein Telefon gefunden. Koppeln Sie Ihr Telefon zuerst in den Systemeinstellungen unter „Bluetooth“."
+ "value": "Kein Telefon gefunden. Kopple dein Telefon zuerst in den Systemeinstellungen unter „Bluetooth“."
}
}
}
@@ -2246,7 +2246,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Das ist ein Dienstcode (z. B. eine Rufumleitung). Drücken Sie erneut Return, um ihn an Ihr Telefon zu senden."
+ "value": "Das ist ein Dienstcode (z. B. eine Rufumleitung). Drücke erneut Return, um ihn an dein Telefon zu senden."
}
}
}
@@ -2264,7 +2264,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Noch nicht verfügbar. Vorerst steuert Pickup Ihre Anrufe vom Mac aus, während das Gespräch auf dem Telefon bleibt."
+ "value": "Noch nicht verfügbar. Vorerst steuert Pickup deine Anrufe vom Mac aus, während das Gespräch auf dem Telefon bleibt."
}
}
}
@@ -2552,7 +2552,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Anrufe seit dem Start von Pickup. Um die Anrufliste des Telefons zu sehen, erlauben Sie in den Bluetooth-Einstellungen des Telefons den Zugriff auf Kontakte und Anrufliste."
+ "value": "Anrufe seit dem Start von Pickup. Um die Anrufliste des Telefons zu sehen, erlaube in den Bluetooth-Einstellungen des Telefons den Zugriff auf Kontakte und Anrufliste."
}
}
}
@@ -2683,24 +2683,6 @@
}
}
},
- "Loading recent calls": {
- "comment": "VoiceOver label of the progress indicator while the recent calls are read from the phone.",
- "extractionState": "manual",
- "localizations": {
- "en": {
- "stringUnit": {
- "state": "translated",
- "value": "Loading recent calls"
- }
- },
- "de": {
- "stringUnit": {
- "state": "translated",
- "value": "Anrufliste wird geladen"
- }
- }
- }
- },
"Use the left and right arrow keys or Command-1 to 3 to switch tabs.": {
"comment": "VoiceOver hint of the recent calls tabs in the popover.",
"extractionState": "manual",
@@ -2948,7 +2930,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Der Kontakt konnte nicht an Ihre Kontakte-App übergeben werden."
+ "value": "Der Kontakt konnte nicht an deine Kontakte-App übergeben werden."
}
}
}
@@ -3132,6 +3114,402 @@
}
}
}
+ },
+ "Done": {
+ "comment": "Button that closes the welcome window.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Done"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fertig"
+ }
+ }
+ }
+ },
+ "Bluetooth": {
+ "comment": "Welcome window: row title of the Bluetooth permission.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Bluetooth"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Bluetooth"
+ }
+ }
+ }
+ },
+ "Notifications": {
+ "comment": "Welcome window: row title of the notifications permission.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Notifications"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mitteilungen"
+ }
+ }
+ }
+ },
+ "Allow": {
+ "comment": "Welcome window: button that asks macOS for a permission.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Allow"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Erlauben"
+ }
+ }
+ }
+ },
+ "Open Settings…": {
+ "comment": "Welcome window: button that opens the matching privacy pane in System Settings.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Open Settings…"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Einstellungen öffnen …"
+ }
+ }
+ }
+ },
+ "Welcome to Pickup": {
+ "comment": "Title of the welcome window (window title, read by VoiceOver).",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Welcome to Pickup"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Willkommen bei Pickup"
+ }
+ }
+ }
+ },
+ "Pickup was updated": {
+ "comment": "Title of the welcome window after an update of an unsigned build.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pickup was updated"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pickup wurde aktualisiert"
+ }
+ }
+ }
+ },
+ "On this Mac": {
+ "comment": "Welcome window: section header for the permissions on the Mac.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "On this Mac"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Auf diesem Mac"
+ }
+ }
+ }
+ },
+ "On your phone": {
+ "comment": "Welcome window: section header for the permissions on the Android phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "On your phone"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Auf deinem Telefon"
+ }
+ }
+ }
+ },
+ "Phone calls and call history": {
+ "comment": "Welcome window: title of the row about the permissions on the Android phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Phone calls and call history"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Telefonanrufe und Anrufliste"
+ }
+ }
+ }
+ },
+ "In the phone’s Bluetooth settings, open this Mac and allow “Phone calls” and “Contacts and call history”. Without the call history, Pickup lists only the calls since it started.": {
+ "comment": "Welcome window: what to allow on the Android phone (Pixel wording: Phone calls, Contacts and call history).",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "In the phone’s Bluetooth settings, open this Mac and allow “Phone calls” and “Contacts and call history”. Without the call history, Pickup lists only the calls since it started."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Öffne in den Bluetooth-Einstellungen des Telefons diesen Mac und erlaube „Telefonanrufe“ und „Kontakte und Anrufliste“. Ohne die Anrufliste zeigt Pickup nur die Anrufe seit dem Start."
+ }
+ }
+ }
+ },
+ "Pickup isn’t signed by Apple yet, so macOS treats every update like a new app and may ask for its permissions again. Here you see them all at a glance.": {
+ "comment": "Welcome window text after an update of a build without an Apple Developer ID signature.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pickup isn’t signed by Apple yet, so macOS treats every update like a new app and may ask for its permissions again. Here you see them all at a glance."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pickup ist noch nicht von Apple signiert. macOS behandelt deshalb jedes Update wie eine neue App und fragt die Freigaben eventuell erneut ab. Hier siehst du alle auf einen Blick."
+ }
+ }
+ }
+ },
+ "Pickup isn’t signed by Apple yet. After an update, macOS may ask again; this window then helps you.": {
+ "comment": "Small note at the bottom of the welcome window for builds without an Apple Developer ID signature.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pickup isn’t signed by Apple yet. After an update, macOS may ask again; this window then helps you."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pickup ist noch nicht von Apple signiert. Nach einem Update fragt macOS eventuell erneut; dann hilft dir dieses Fenster."
+ }
+ }
+ }
+ },
+ "Pair it in System Settings › Bluetooth first, then choose it in Pickup.": {
+ "comment": "Welcome window: how to set up the phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Pair it in System Settings › Bluetooth first, then choose it in Pickup."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Kopple es zuerst in den Systemeinstellungen › Bluetooth und wähle es dann in Pickup aus."
+ }
+ }
+ }
+ },
+ "Needed to connect to your phone.": {
+ "comment": "Welcome window: why Pickup needs Bluetooth.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Needed to connect to your phone."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Nötig für die Verbindung zu deinem Telefon."
+ }
+ }
+ }
+ },
+ "Asked when connecting": {
+ "comment": "Welcome window: macOS asks for Bluetooth the first time Pickup connects to the phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Asked when connecting"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Wird beim Verbinden abgefragt"
+ }
+ }
+ }
+ },
+ "Shows names and photos and lets you search your contacts. Optional.": {
+ "comment": "Welcome window: why Pickup asks for Contacts access.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Shows names and photos and lets you search your contacts. Optional."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zeigt Namen und Fotos und ermöglicht die Suche in deinen Kontakten. Optional."
+ }
+ }
+ }
+ },
+ "Tells you about missed calls, with “Call Back”. Optional.": {
+ "comment": "Welcome window: why Pickup asks to send notifications.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tells you about missed calls, with “Call Back”. Optional."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Meldet verpasste Anrufe, mit „Zurückrufen“. Optional."
+ }
+ }
+ }
+ },
+ "Welcome to Pickup!": {
+ "comment": "Headline of the welcome window at the first launch.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Welcome to Pickup!"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Willkommen bei Pickup!"
+ }
+ }
+ }
+ },
+ "With Pickup you control the incoming and outgoing calls of your Android phone, much like Apple does for the iPhone with Continuity. The conversation itself stays on your phone. Please check the permissions below once, then you’re ready to go.": {
+ "comment": "Welcome window text at the first launch. Continuity is Apple’s name for the iPhone feature; keep it.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "With Pickup you control the incoming and outgoing calls of your Android phone, much like Apple does for the iPhone with Continuity. The conversation itself stays on your phone. Please check the permissions below once, then you’re ready to go."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Mit Pickup steuerst du deine ein- und ausgehenden Anrufe deines Android-Smartphones, ähnlich wie das Apple beim iPhone mit Continuity macht. Das Gespräch selbst bleibt dabei aber auf deinem Telefon. Bitte prüfe einmal die Freigaben unten, dann kann es losgehen."
+ }
+ }
+ }
+ },
+ "Loading recent calls": {
+ "comment": "VoiceOver label of the placeholder rows shown while the recent calls are read from the phone.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Loading recent calls"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Letzte Anrufe werden geladen"
+ }
+ }
+ }
+ },
+ "Recent calls aren’t available right now.": {
+ "comment": "Shown instead of the recent calls when the phone has not sent them within 10 seconds or did not answer.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Recent calls aren’t available right now."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Die letzten Anrufe sind gerade nicht verfügbar."
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/App/Sources/Pickup/AppModel.swift b/App/Sources/Pickup/AppModel.swift
index f3bfc41..e132642 100644
--- a/App/Sources/Pickup/AppModel.swift
+++ b/App/Sources/Pickup/AppModel.swift
@@ -104,7 +104,9 @@ final class AppModel {
// MARK: Lifecycle
- func start() {
+ /// `askForPermissions`: ask for Contacts and notifications shortly after launch (false while the
+ /// welcome window offers them).
+ func start(askForPermissions: Bool = true) {
_ = panels
if !TestMode.isActive { MissedCallNotifier.shared.install() }
contacts.reload()
@@ -124,7 +126,7 @@ final class AppModel {
if TestMode.isActive { startTestScenario(); return }
#endif
if isDemo { apply(); return }
- requestPermissionsAfterLaunch()
+ if askForPermissions { requestPermissionsAfterLaunch() }
if let address = selectedAddress { makeEngine(address: address) } else { apply() }
}
diff --git a/App/Sources/Pickup/PickupApp.swift b/App/Sources/Pickup/PickupApp.swift
index ce62090..6418d9c 100644
--- a/App/Sources/Pickup/PickupApp.swift
+++ b/App/Sources/Pickup/PickupApp.swift
@@ -26,9 +26,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@MainActor
func applicationDidFinishLaunching(_ notification: Notification) {
let model = AppModel.shared
- model.start()
+ let welcome = WelcomePolicy.launchReason()
+ // The welcome window asks for the permissions itself; no automatic prompts on top of it.
+ model.start(askForPermissions: welcome == nil)
let statusItem = StatusItemController(model: model)
self.statusItem = statusItem
+ if let welcome {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { statusItem.showWelcome(welcome) }
+ }
retargetSettingsMenuItem()
NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { _ in
MainActor.assumeIsolated { (NSApp.delegate as? AppDelegate)?.retargetSettingsMenuItem() }
diff --git a/App/Sources/Pickup/StatusItemController.swift b/App/Sources/Pickup/StatusItemController.swift
index 23609f3..6ef601d 100644
--- a/App/Sources/Pickup/StatusItemController.swift
+++ b/App/Sources/Pickup/StatusItemController.swift
@@ -12,6 +12,7 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
private let missedDot = StatusBadgeDot()
private let popover = NSPopover()
private var settingsWindow: NSWindow?
+ private var welcomeWindow: NSWindow?
private var keyMonitor: Any?
private var shortcutMonitor: Any?
/// App that was frontmost before the popover activated Pickup; reactivated on close.
@@ -130,7 +131,7 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
/// The handset with the percentage to its right, drawn as one template image so the menu bar
/// tints both for light, dark and selected.
private static func withPercentage(_ icon: NSImage, _ text: String) -> NSImage {
- let font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize - 1, weight: .regular)
+ let font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize - 2, weight: .regular)
let string = NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: NSColor.black])
let textSize = string.size()
let size = NSSize(width: ceil(icon.size.width + percentageGap + textSize.width), height: max(icon.size.height, ceil(textSize.height)))
@@ -294,6 +295,39 @@ final class StatusItemController: NSObject, NSPopoverDelegate {
settingsWindow?.orderFrontRegardless()
}
+ /// The welcome window (first launch, or an unsigned update). Marked as shown right away, so it
+ /// does not come back at the next launch even if the user quits instead of clicking Done.
+ func showWelcome(_ reason: WelcomePolicy.Reason) {
+ WelcomePolicy.markShown()
+ keepActiveOnClose = true
+ closePopover()
+ if welcomeWindow == nil {
+ let view = WelcomeView(reason: reason, model: model,
+ openSettings: { [weak self] in self?.openSettings() },
+ close: { [weak self] in self?.welcomeWindow?.close() })
+ let window = NSWindow(contentViewController: NSHostingController(rootView: view))
+ window.title = String(localized: "Welcome to Pickup")
+ window.identifier = NSUserInterfaceItemIdentifier("welcome-window")
+ window.styleMask = [.titled, .closable]
+ window.titlebarAppearsTransparent = true
+ window.titleVisibility = .hidden // the header shows it; VoiceOver still gets the title
+ window.isReleasedWhenClosed = false
+ welcomeWindow = window
+ }
+ // Centred once SwiftUI has sized the content (centring the empty window put it in a corner).
+ if let window = welcomeWindow, let content = window.contentViewController?.view {
+ content.layoutSubtreeIfNeeded()
+ window.setContentSize(content.fittingSize)
+ if let screen = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame {
+ window.setFrameOrigin(NSPoint(x: (screen.midX - window.frame.width / 2).rounded(),
+ y: (screen.midY - window.frame.height / 2).rounded()))
+ }
+ }
+ NSApp.activate()
+ welcomeWindow?.makeKeyAndOrderFront(nil)
+ welcomeWindow?.orderFrontRegardless()
+ }
+
func showAbout() {
keepActiveOnClose = true
closePopover()
diff --git a/App/Sources/Pickup/Welcome.swift b/App/Sources/Pickup/Welcome.swift
new file mode 100644
index 0000000..ad92d8c
--- /dev/null
+++ b/App/Sources/Pickup/Welcome.swift
@@ -0,0 +1,52 @@
+import Foundation
+import Security
+
+/// When the welcome window appears: at the first launch, and, while Pickup is not signed with a
+/// Developer ID, after every update. An ad-hoc signature changes with each build, so macOS may ask for
+/// the permissions again; the window explains why and offers them in one place.
+enum WelcomePolicy {
+ enum Reason: Equatable {
+ case firstLaunch
+ /// A new version without a Developer ID signature: permissions may have been reset.
+ case updatedUnsigned
+ }
+
+ static let shownForVersionKey = "welcomeShownForVersion"
+
+ /// `shownFor`: the version the window was last shown for (nil = never). Rebuilds of the same
+ /// version (development) do not show it again.
+ static func reason(shownFor: String?, currentVersion: String, developerIDSigned: Bool) -> Reason? {
+ guard let shownFor else { return .firstLaunch }
+ guard !developerIDSigned, shownFor != currentVersion else { return nil }
+ return .updatedUnsigned
+ }
+
+ /// "0.3.0 (42)": version and build, so every release counts as an update.
+ static var currentVersion: String { "\(AppInfo.version) (\(AppInfo.build))" }
+
+ /// True if the running app carries a team identifier (Developer ID or App Store), false for
+ /// ad-hoc signed or unsigned builds.
+ static var isDeveloperIDSigned: Bool {
+ var code: SecCode?
+ guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { return false }
+ var staticCode: SecStaticCode?
+ guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { return false }
+ var info: CFDictionary?
+ guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess,
+ let dictionary = info as? [String: Any] else { return false }
+ return dictionary[kSecCodeInfoTeamIdentifier as String] is String
+ }
+
+ /// Reason to show the window at this launch, from the saved state. Never in tests.
+ static func launchReason(defaults: UserDefaults = .standard) -> Reason? {
+ guard !TestMode.isActive else { return nil }
+ if UserDefaults.standard.bool(forKey: "PickupOpenWelcome") { return .firstLaunch } // visual checks
+ return reason(shownFor: defaults.string(forKey: shownForVersionKey), currentVersion: currentVersion,
+ developerIDSigned: isDeveloperIDSigned)
+ }
+
+ static func markShown(defaults: UserDefaults = .standard) {
+ guard !TestMode.isActive else { return }
+ defaults.set(currentVersion, forKey: shownForVersionKey)
+ }
+}
diff --git a/App/Sources/Pickup/WelcomeView.swift b/App/Sources/Pickup/WelcomeView.swift
new file mode 100644
index 0000000..7758e37
--- /dev/null
+++ b/App/Sources/Pickup/WelcomeView.swift
@@ -0,0 +1,208 @@
+import AppKit
+import SwiftUI
+
+/// First-run and after-update window: what Pickup does and every permission it needs, each with its
+/// state and the button that grants it.
+struct WelcomeView: View {
+ let reason: WelcomePolicy.Reason
+ let model: AppModel
+ let openSettings: () -> Void
+ let close: () -> Void
+
+ private var permissions: PermissionsModel { model.permissions }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ .padding(.horizontal, 24)
+ .padding(.top, 20)
+ Form {
+ Section("On this Mac") {
+ phoneRow
+ bluetoothRow
+ contactsRow
+ notificationsRow
+ }
+ Section("On your phone") {
+ row(symbol: "iphone.gen3", color: .green,
+ title: Text("Phone calls and call history", comment: "Welcome window: title of the row about the permissions on the Android phone."),
+ detail: Text("In the phone’s Bluetooth settings, open this Mac and allow “Phone calls” and “Contacts and call history”. Without the call history, Pickup lists only the calls since it started.",
+ comment: "Welcome window: what to allow on the Android phone (Pixel wording: Phone calls, Contacts and call history).")) {
+ EmptyView()
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .scrollDisabled(true)
+ .fixedSize(horizontal: false, vertical: true)
+ footer
+ .padding(.horizontal, 24)
+ .padding(.bottom, 20)
+ }
+ .frame(width: 520)
+ .task { await permissions.refresh() }
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
+ Task { await permissions.refresh() }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("welcome-window")
+ }
+
+ // MARK: Header and footer
+
+ private var header: some View {
+ HStack(alignment: .top, spacing: 16) {
+ Image(nsImage: NSApp.applicationIconImage)
+ .resizable()
+ .frame(width: 64, height: 64)
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 6) {
+ Group {
+ switch reason {
+ case .firstLaunch:
+ Text("Welcome to Pickup!", comment: "Headline of the welcome window at the first launch.")
+ case .updatedUnsigned:
+ Text("Pickup was updated", comment: "Title of the welcome window after an update of an unsigned build.")
+ }
+ }
+ .font(.title2.weight(.semibold))
+ .accessibilityAddTraits(.isHeader)
+ Group {
+ switch reason {
+ case .firstLaunch:
+ Text("With Pickup you control the incoming and outgoing calls of your Android phone, much like Apple does for the iPhone with Continuity. The conversation itself stays on your phone. Please check the permissions below once, then you’re ready to go.",
+ comment: "Welcome window text at the first launch. Continuity is Apple’s name for the iPhone feature; keep it.")
+ case .updatedUnsigned:
+ Text("Pickup isn’t signed by Apple yet, so macOS treats every update like a new app and may ask for its permissions again. Here you see them all at a glance.",
+ comment: "Welcome window text after an update of a build without an Apple Developer ID signature.")
+ }
+ }
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private var footer: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
+ if reason == .firstLaunch && !WelcomePolicy.isDeveloperIDSigned {
+ Text("Pickup isn’t signed by Apple yet. After an update, macOS may ask again; this window then helps you.",
+ comment: "Small note at the bottom of the welcome window for builds without an Apple Developer ID signature.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 0)
+ Button(action: close) {
+ Text("Done", comment: "Button that closes the welcome window.")
+ .frame(minWidth: 72)
+ }
+ .keyboardShortcut(.defaultAction)
+ .controlSize(.large)
+ .accessibilityIdentifier("welcome-done")
+ }
+ }
+
+ // MARK: Rows
+
+ private var phoneRow: some View {
+ row(symbol: "phone.fill", color: .green,
+ title: Text("Phone"),
+ detail: Text("Pair it in System Settings › Bluetooth first, then choose it in Pickup.",
+ comment: "Welcome window: how to set up the phone.")) {
+ switch model.connection {
+ case .connected(let name):
+ granted(Text("Connected to \(name)"))
+ case .noPhone:
+ Button { openSettings() } label: {
+ Text("Choose Phone…")
+ }
+ case .connecting(let name), .reconnecting(let name), .disconnected(let name):
+ Text(verbatim: name).foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var bluetoothRow: some View {
+ row(symbol: "wave.3.right", color: .blue,
+ title: Text("Bluetooth"),
+ detail: Text("Needed to connect to your phone.", comment: "Welcome window: why Pickup needs Bluetooth.")) {
+ switch permissions.bluetooth {
+ case .allowedAlways:
+ granted(Text("Allowed"))
+ case .notDetermined:
+ Text("Asked when connecting", comment: "Welcome window: macOS asks for Bluetooth the first time Pickup connects to the phone.")
+ .foregroundStyle(.secondary)
+ default:
+ Button { PermissionsModel.openBluetoothPrivacySettings() } label: { Text("Open Settings…", comment: "Welcome window: button that opens the matching privacy pane in System Settings.") }
+ }
+ }
+ }
+
+ private var contactsRow: some View {
+ row(symbol: "person.crop.circle.fill", color: .gray,
+ title: Text("Contacts"),
+ detail: Text("Shows names and photos and lets you search your contacts. Optional.",
+ comment: "Welcome window: why Pickup asks for Contacts access.")) {
+ if permissions.contactsGranted {
+ granted(Text("Allowed"))
+ } else if permissions.contacts == .notDetermined {
+ Button {
+ Task { if await permissions.requestContacts() { model.contacts.reload() } }
+ } label: { Text("Allow", comment: "Welcome window: button that asks macOS for a permission.") }
+ .accessibilityIdentifier("welcome-allow-contacts")
+ } else {
+ Button { PermissionsModel.openContactsPrivacySettings() } label: { Text("Open Settings…") }
+ }
+ }
+ }
+
+ private var notificationsRow: some View {
+ row(symbol: "bell.badge.fill", color: .red,
+ title: Text("Notifications"),
+ detail: Text("Tells you about missed calls, with “Call Back”. Optional.",
+ comment: "Welcome window: why Pickup asks to send notifications.")) {
+ if permissions.notificationsGranted {
+ granted(Text("Allowed"))
+ } else if permissions.notifications == .notDetermined {
+ Button {
+ Task { await permissions.requestNotifications() }
+ } label: { Text("Allow") }
+ .accessibilityIdentifier("welcome-allow-notifications")
+ } else {
+ Button { PermissionsModel.openNotificationSettings() } label: { Text("Open Settings…") }
+ }
+ }
+ }
+
+ private func granted(_ text: Text) -> some View {
+ Label { text } icon: {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(Color(nsColor: .systemGreen))
+ }
+ .foregroundStyle(.secondary)
+ }
+
+ /// Icon in a coloured rounded square (like System Settings), title, explanation and the state or
+ /// action on the right.
+ private func row(symbol: String, color: Color, title: Text, detail: Text,
+ @ViewBuilder trailing: () -> Trailing) -> some View {
+ HStack(alignment: .center, spacing: 12) {
+ Image(systemName: symbol)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(width: 26, height: 26)
+ .background(color.gradient, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
+ .accessibilityHidden(true)
+ VStack(alignment: .leading, spacing: 2) {
+ title.font(.body)
+ detail.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ trailing()
+ .fixedSize()
+ }
+ .accessibilityElement(children: .contain)
+ }
+}
diff --git a/App/Tests/PickupAppTests/WelcomePolicyTests.swift b/App/Tests/PickupAppTests/WelcomePolicyTests.swift
new file mode 100644
index 0000000..57e6141
--- /dev/null
+++ b/App/Tests/PickupAppTests/WelcomePolicyTests.swift
@@ -0,0 +1,19 @@
+import XCTest
+@testable import Pickup
+
+final class WelcomePolicyTests: XCTestCase {
+ func testFirstLaunchAlwaysShowsTheWelcome() {
+ XCTAssertEqual(WelcomePolicy.reason(shownFor: nil, currentVersion: "0.3.0 (42)", developerIDSigned: false), .firstLaunch)
+ XCTAssertEqual(WelcomePolicy.reason(shownFor: nil, currentVersion: "1.0.0 (60)", developerIDSigned: true), .firstLaunch)
+ }
+
+ func testUnsignedUpdatesShowItAgainBecausePermissionsMayBeReset() {
+ XCTAssertEqual(WelcomePolicy.reason(shownFor: "0.3.0 (42)", currentVersion: "0.4.0 (47)", developerIDSigned: false), .updatedUnsigned)
+ XCTAssertNil(WelcomePolicy.reason(shownFor: "0.3.0 (42)", currentVersion: "0.3.0 (42)", developerIDSigned: false),
+ "rebuilds of the same version (development) stay quiet")
+ }
+
+ func testSignedUpdatesKeepTheirPermissionsAndStayQuiet() {
+ XCTAssertNil(WelcomePolicy.reason(shownFor: "1.0.0 (60)", currentVersion: "1.1.0 (65)", developerIDSigned: true))
+ }
+}
From 449683e2e1c1ca6318f32a4aeb0a3432bd89a798 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 11:57:27 +0100
Subject: [PATCH 15/20] fix: never wait forever for the recent calls and show a
skeleton while loading
- A call list command the phone does not answer within 8 s is given up (it
was never written after a failed write, and the dialer showed a spinner
forever). This counts as unanswered, not refused, so no misleading hint
about the phone's permission appears and the next refresh asks again.
- Reads start as soon as IOBluetooth's own AT+VGS/AT+VGM are answered instead
of always waiting 10 s after the link came up (10 s stays the upper limit).
- While the first read runs, the dialer shows skeleton rows with a sweeping
highlight (static with Reduce Motion) for at most 10 s, then a message.
Nothing is stored on the Mac between launches.
Co-Authored-By: Claude Opus 5.5
---
App/Sources/Pickup/CallViewComponents.swift | 7 ++
App/Sources/Pickup/DialerView.swift | 115 ++++++++++++++++--
.../PickupBluetooth/AppleHFPTransport.swift | 57 ++++++++-
Sources/PickupCore/CallEngine.swift | 2 +
Sources/PickupCore/CallHistory.swift | 9 ++
Sources/PickupCore/HFPTransport.swift | 3 +
.../AppleHFPTransportTests.swift | 41 +++++++
Tests/PickupCoreTests/CallHistoryTests.swift | 15 +++
docs/BLUETOOTH.md | 2 +-
9 files changed, 231 insertions(+), 20 deletions(-)
diff --git a/App/Sources/Pickup/CallViewComponents.swift b/App/Sources/Pickup/CallViewComponents.swift
index 703c142..711be3b 100644
--- a/App/Sources/Pickup/CallViewComponents.swift
+++ b/App/Sources/Pickup/CallViewComponents.swift
@@ -64,6 +64,13 @@ extension Color {
/// Outline of the selected row: the state is visible without relying on the faint fill.
static let selectionRing = adaptive(light: 0x0064E1, dark: 0x4DA3FF)
static let selectionFill = adaptive(light: 0x0064E1, dark: 0x4DA3FF, alpha: 0.15)
+ /// Placeholder shapes of the skeleton rows and the light sweeping over them.
+ static let skeleton = Color.primary.opacity(0.08)
+ /// White, stronger in light mode (on the light gray shapes) than in dark mode, like PrimeNG.
+ static let skeletonHighlight = Color(nsColor: NSColor(name: nil) { appearance in
+ let dark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ return NSColor.white.withAlphaComponent(dark ? 0.10 : 0.5)
+ })
/// Active recent calls tab: the system's selection color, derived from the user's accent color
/// (darker than the accent itself, so the white label reaches 4.5:1), with its label color.
static let activeSegment = Color(nsColor: .selectedContentBackgroundColor)
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index 48407cc..aafb3d5 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -38,6 +38,9 @@ struct DialerView: View {
@AppStorage("recentCallsList") private var recentListRaw = CallHistoryList.dialed.rawValue
/// The tab was changed from the keyboard while a row was selected: select the new tab's first row.
@State private var selectFirstRecentRow = false
+ /// The skeleton rows were shown for `skeletonLimit`: a message replaces them until the list arrives.
+ @State private var skeletonExpired = false
+ private static let skeletonLimit: Duration = .seconds(10)
/// Keyboard focus. Tab runs field → tabs → list in the system order (also without Full Keyboard
/// Access); the tabs and the list are one focus stop each.
private enum Focus: Hashable { case field, tabs, list }
@@ -571,20 +574,35 @@ struct DialerView: View {
.accessibilityIdentifier("recent-tabs")
.accessibilityHint(Text("Use the left and right arrow keys or Command-1 to 3 to switch tabs.", comment: "VoiceOver hint of the recent calls tabs in the popover."))
- if rows.isEmpty {
- // Rows stay cached while the phone is asked again; the spinner only shows before the first answer.
- Group {
- if history.isLoading {
- ProgressView().controlSize(.small)
- .accessibilityLabel(Text("Loading recent calls", comment: "VoiceOver label of the progress indicator while the recent calls are read from the phone."))
- } else {
- Text("No calls", comment: "Empty state of a recent calls tab in the popover.")
- .font(.callout).foregroundStyle(Color.rowSubtitle)
- }
+ Group {
+ if !rows.isEmpty {
+ resultList(rows, fill: true)
+ } else if history.isLoading && !skeletonExpired {
+ // First read after launch: placeholder rows in the shape of the real ones, so the list
+ // fades in where they were (nothing jumps). Nothing is saved on the Mac between launches.
+ RecentCallsSkeleton(trailingInset: Self.callButtonSize + 8, callButtonSize: Self.callButtonSize)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .task {
+ try? await Task.sleep(for: Self.skeletonLimit)
+ if !Task.isCancelled { skeletonExpired = true }
+ }
+ } else if history.isLoading || history.lastReadUnanswered {
+ Text("Recent calls aren’t available right now.", comment: "Shown instead of the recent calls when the phone has not sent them within 10 seconds or did not answer.")
+ .font(.callout).foregroundStyle(Color.rowSubtitle)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ Text("No calls", comment: "Empty state of a recent calls tab in the popover.")
+ .font(.callout).foregroundStyle(Color.rowSubtitle)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- resultList(rows, fill: true)
+ }
+ .transition(.opacity)
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: rows.isEmpty)
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: history.isLoading)
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.25), value: skeletonExpired)
+ .onChange(of: history.isLoading) { _, loading in
+ if !loading { skeletonExpired = false } // the next read shows the skeleton again
}
if history.source(recentList) == .session && !history.isLoading && history.phoneRefused {
Text("Calls since Pickup started. To see your phone's call history, allow access to contacts and call history in the phone's Bluetooth settings.",
@@ -906,6 +924,77 @@ private struct ResultRow: View {
}
}
+/// Placeholder rows while the recent calls are read for the first time (like PrimeNG's Skeleton):
+/// avatar, name and detail bars and the call button's circle, with a light sweeping across. Static
+/// with Reduce Motion. VoiceOver hears one "Loading recent calls" element.
+private struct RecentCallsSkeleton: View {
+ let trailingInset: CGFloat
+ let callButtonSize: CGFloat
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ /// Varying bar lengths, so the placeholder reads as a list and not as a pattern.
+ private static let widths: [(title: CGFloat, detail: CGFloat)] = [
+ (0.55, 0.38), (0.42, 0.30), (0.62, 0.44), (0.48, 0.34), (0.58, 0.40), (0.45, 0.32),
+ ]
+
+ var body: some View {
+ rows(Color.skeleton)
+ .overlay {
+ // The band is cut to the shapes drawn opaque: a mask also takes over the opacity, and the
+ // faint shapes themselves let almost none of the band through.
+ if !reduceMotion { ShimmerBand().mask(rows(.black)) }
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Text("Loading recent calls", comment: "VoiceOver label of the placeholder rows shown while the recent calls are read from the phone."))
+ .accessibilityIdentifier("recent-skeleton")
+ }
+
+ private func rows(_ fill: Color) -> some View {
+ VStack(spacing: 2) {
+ ForEach(Self.widths.indices, id: \.self) { index in
+ row(Self.widths[index], fill: fill)
+ }
+ }
+ }
+
+ private func row(_ widths: (title: CGFloat, detail: CGFloat), fill: Color) -> some View {
+ HStack(spacing: 10) {
+ Circle().fill(fill).frame(width: 32, height: 32)
+ GeometryReader { proxy in
+ VStack(alignment: .leading, spacing: 6) {
+ Capsule().fill(fill).frame(width: proxy.size.width * widths.title, height: 10)
+ Capsule().fill(fill).frame(width: proxy.size.width * widths.detail, height: 8)
+ }
+ .frame(maxHeight: .infinity, alignment: .center)
+ }
+ Circle().fill(fill).frame(width: callButtonSize, height: callButtonSize)
+ }
+ .padding(.leading, 8)
+ .padding(.trailing, 8 + 2)
+ .frame(height: 48)
+ }
+}
+
+/// A light band sweeping from left to right (PrimeNG's "wave": 1.2 s, endless). Driven by the clock,
+/// not by a started animation: a repeating animation begun while the popover was still opening was
+/// dropped and the band stood still.
+private struct ShimmerBand: View {
+ private static let period: TimeInterval = 1.2
+
+ var body: some View {
+ TimelineView(.animation) { context in
+ let progress = context.date.timeIntervalSinceReferenceDate
+ .truncatingRemainder(dividingBy: Self.period) / Self.period
+ GeometryReader { proxy in
+ let band = proxy.size.width * 0.5
+ LinearGradient(colors: [.clear, Color.skeletonHighlight, .clear], startPoint: .leading, endPoint: .trailing)
+ .frame(width: band)
+ .offset(x: -band + progress * (proxy.size.width + band))
+ }
+ }
+ .allowsHitTesting(false)
+ }
+}
+
/// Long press (about 0.4 s, mouse included) plus an accessibility action for keys with a second function.
private struct PlusLongPress: ViewModifier {
let enabled: Bool
diff --git a/Sources/PickupBluetooth/AppleHFPTransport.swift b/Sources/PickupBluetooth/AppleHFPTransport.swift
index 05b8b02..0aa23bf 100644
--- a/Sources/PickupBluetooth/AppleHFPTransport.swift
+++ b/Sources/PickupBluetooth/AppleHFPTransport.swift
@@ -51,12 +51,16 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
public var deviceAppearedDelay: TimeInterval = 2.5
/// An SLC that still looks up after an unconfirmed disconnect must answer `AT+CLCC` within this.
public var verifySLC: TimeInterval = 3
- /// No call list is read this soon after the SLC came up: IOBluetooth still sends its own
- /// follow-up commands then (`AT+VGS`, `AT+VGM`), and interleaving ours with them crashed
- /// IOBluetooth on 2026-10-29 (see `readCallHistory`).
+ /// Longest wait for IOBluetooth's own follow-up commands after the SLC (`AT+VGS`, `AT+VGM`)
+ /// before a call list is read anyway. Interleaving ours with them crashed IOBluetooth on
+ /// 2026-10-29 (see `readCallHistory`); usually they are sent and answered much sooner.
public var historyQuietPeriod: TimeInterval = 10
/// How often a waiting call list read checks again whether the link is idle.
public var historyRetry: TimeInterval = 0.5
+ /// A call list command the phone does not answer within this is given up, with the lists still
+ /// queued (seen on 2026-10-30: the command was never written after a failed write of
+ /// IOBluetooth's own `AT+VGM`, and the read waited forever).
+ public var historyCommand: TimeInterval = 8
public init() {}
}
@@ -147,7 +151,13 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
private var historyStep: (list: CallHistoryList, count: Int, reading: Bool)?
/// When the current SLC came up; call list reads wait `timeouts.historyQuietPeriod` after it.
private var slcEstablishedAt: Date?
+ /// IOBluetooth's follow-up commands of the current SLC seen on the wire (they may go out even
+ /// before the SLC counts as up). Once both went out and were answered, call lists may be read.
+ private var slcFollowUpsSent: Set = []
+ private static let slcFollowUps: Set = ["AT+VGS", "AT+VGM"]
private var historyRetryTask: ScheduledTask?
+ /// Gives up the current call list command if the phone never answers it.
+ private var historyTimeoutTask: ScheduledTask?
private var fallbackFlush: ScheduledTask?
private var connectObserver: AnyObject?
@@ -266,6 +276,8 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
fallbackFlush = nil
historyRetryTask?.cancel()
historyRetryTask = nil
+ historyTimeoutTask?.cancel()
+ historyTimeoutTask = nil
connectObserver = nil
// The registry parks the object (closes its link, keeps it alive and listening) and
// remembers a disconnect in flight for the next owner.
@@ -348,9 +360,12 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
/// waiting for its reply.
private func sendNextHistoryCommand() {
guard historyStep == nil, !historyQueue.isEmpty, historyRetryTask == nil else { return }
- let quietUntil = slcEstablishedAt.map { $0.addingTimeInterval(timeouts.historyQuietPeriod) } ?? scheduler.now
+ let followUpsDone = slcFollowUpsSent.isSuperset(of: Self.slcFollowUps)
+ let quietUntil = followUpsDone ? scheduler.now
+ : slcEstablishedAt.map { $0.addingTimeInterval(timeouts.historyQuietPeriod) } ?? scheduler.now
if scheduler.now < quietUntil || tracker.pendingCount > 0 {
- let delay = max(quietUntil.timeIntervalSince(scheduler.now), timeouts.historyRetry)
+ // Check again soon: the follow-ups may be answered long before the quiet period ends.
+ let delay = timeouts.historyRetry
historyRetryTask = scheduler.schedule(after: delay) { [weak self] in
guard let self else { return }
self.historyRetryTask = nil
@@ -365,6 +380,10 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
}
private func sendHistoryCommand(_ line: String) {
+ historyTimeoutTask?.cancel()
+ historyTimeoutTask = scheduler.schedule(after: timeouts.historyCommand) { [weak self] in
+ self?.historyTimedOut()
+ }
do {
try issue("sendATCommand(\(line))") { $0.sendATCommand(line) }
} catch {
@@ -372,9 +391,25 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
}
}
+ /// No answer: this list and the queued ones are reported as not answered (not as refused), so
+ /// the engine asks again later. The link is evidently not taking commands now.
+ private func historyTimedOut() {
+ historyTimeoutTask = nil
+ guard let step = historyStep else { return }
+ log.log(.warning, "no answer to the call list read (\(step.list.rawValue)) within \(timeouts.historyCommand) s; giving up")
+ let lists = [step.list] + historyQueue.map(\.list)
+ historyStep = nil
+ historyQueue.removeAll()
+ for list in lists { emit(.callHistoryUnanswered(list)) }
+ }
+
/// The phone answered the current call list command (nil = failed, discarded or not sent).
private func historyExchangeEnded(_ exchange: ATExchange?) {
guard let step = historyStep else { return }
+ // A late answer to an earlier, given-up command does not belong to this step.
+ if let exchange, exchange.command != (step.reading ? .readPhonebook : .selectPhonebook) { return }
+ historyTimeoutTask?.cancel()
+ historyTimeoutTask = nil
if let exchange, exchange.result.isSuccess, !step.reading {
historyStep = (step.list, step.count, true)
sendHistoryCommand("AT+CPBR=1,\(step.count)")
@@ -662,6 +697,12 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
/// IOBluetooth sent `AT+BRSF` (always the first SLC command) on our link while the transport is
/// disconnected: the phone reconnected on its own and IOBluetooth runs the SLC set-up. Follow
/// it instead of starting a second one.
+ private func noteSLCFollowUp(_ line: String) {
+ let command = line.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+ if command.hasPrefix("AT+BRSF") { slcFollowUpsSent = [] } // a new SLC set-up begins
+ if let name = Self.slcFollowUps.first(where: { command.hasPrefix($0 + "=") }) { slcFollowUpsSent.insert(name) }
+ }
+
private func noteSLCStart(_ line: String) {
guard linkState == .disconnected, !isInvalidated,
line.trimmingCharacters(in: .whitespaces).uppercased().hasPrefix("AT+BRSF") else { return }
@@ -711,7 +752,10 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
historyStep = nil
historyRetryTask?.cancel()
historyRetryTask = nil
+ historyTimeoutTask?.cancel()
+ historyTimeoutTask = nil
slcEstablishedAt = nil
+ slcFollowUpsSent = []
}
emit(.linkStateChanged(state))
}
@@ -771,6 +815,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
// Runs inside IOBluetooth's write (inside its command queue): defer any command.
inCallback {
noteSLCStart(line)
+ noteSLCFollowUp(line)
handle(tracker.outgoing(line, at: scheduler.now))
}
}
@@ -829,7 +874,7 @@ public final class AppleHFPTransport: HFPTransport, ATWriteSink {
/// to an SLC nobody wants (e.g. one completing during a disconnect).
private func emit(_ event: TransportEvent) {
switch event {
- case .indicator, .ring, .callerID, .callWaiting, .currentCalls, .commandCompleted, .callHistory:
+ case .indicator, .ring, .callerID, .callWaiting, .currentCalls, .commandCompleted, .callHistory, .callHistoryUnanswered:
guard linkState == .connecting || linkState == .connected else {
log.log(.state, "dropped \(event) while \(linkState.rawValue) [\(linkTag)]")
return
diff --git a/Sources/PickupCore/CallEngine.swift b/Sources/PickupCore/CallEngine.swift
index f4bdb55..a35a52c 100644
--- a/Sources/PickupCore/CallEngine.swift
+++ b/Sources/PickupCore/CallEngine.swift
@@ -417,6 +417,8 @@ public final class CallEngine {
commandCompleted(command, result)
case .callHistory(let list, let entries):
callHistory.received(list, entries: entries)
+ case .callHistoryUnanswered(let list):
+ callHistory.unanswered(list)
case .audioConnectionChanged(let isOpen):
audioConnectionChanged(isOpen: isOpen)
}
diff --git a/Sources/PickupCore/CallHistory.swift b/Sources/PickupCore/CallHistory.swift
index d3927a7..24784d2 100644
--- a/Sources/PickupCore/CallHistory.swift
+++ b/Sources/PickupCore/CallHistory.swift
@@ -55,6 +55,8 @@ public struct CallHistory: Equatable, Sendable {
public private(set) var phoneRefused = false
/// Lists requested from the phone and not answered yet.
public private(set) var pending: Set = []
+ /// The last read got no answer in time (not a refusal); cleared by the next list that arrives.
+ public private(set) var lastReadUnanswered = false
/// Upper bound for the session lists.
public static let sessionLimit = 50
@@ -91,11 +93,18 @@ public struct CallHistory: Equatable, Sendable {
if let entries {
phoneLists[list] = entries
phoneRefused = false
+ lastReadUnanswered = false
} else {
phoneRefused = true
}
}
+ /// The phone did not answer in time: nothing changes but the pending state.
+ mutating func unanswered(_ list: CallHistoryList) {
+ pending.remove(list)
+ lastReadUnanswered = true
+ }
+
/// The link went down: answers still outstanding will never arrive.
mutating func cancelPending() {
pending.removeAll()
diff --git a/Sources/PickupCore/HFPTransport.swift b/Sources/PickupCore/HFPTransport.swift
index 4d90b25..1e226c0 100644
--- a/Sources/PickupCore/HFPTransport.swift
+++ b/Sources/PickupCore/HFPTransport.swift
@@ -27,6 +27,9 @@ public enum TransportEvent: Equatable, Sendable {
/// A call list read with `readCallHistory`, newest first; nil = the phone refused it (no access
/// to the call history granted), does not support it or did not answer.
case callHistory(CallHistoryList, [CallHistoryEntry]?)
+ /// A call list read got no answer in time (the link is not taking commands). Not a refusal: the
+ /// lists already shown stay, and the next refresh asks again.
+ case callHistoryUnanswered(CallHistoryList)
/// The audio link (SCO) opened or closed. Remote-control transports may still report it,
/// because the phone can open SCO on its own; the core then hands the audio back.
case audioConnectionChanged(isOpen: Bool)
diff --git a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
index eb70006..58ec89e 100644
--- a/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
+++ b/Tests/PickupBluetoothTests/AppleHFPTransportTests.swift
@@ -633,6 +633,29 @@ final class AppleHFPTransportTests: XCTestCase {
XCTAssertFalse(AppleHFPTransport.supportsCallHold(features: 0x1, holdModes: 0b0011), "mode 2 missing")
}
+ /// Regression (2026-10-30): `AT+CPBS` was never written after a failed write of IOBluetooth's own
+ /// command, and the read waited for an answer forever (endless spinner in the dialer).
+ func testUnansweredCallListReadIsGivenUp() throws {
+ let transport = makeTransport()
+ let link = connected(transport)
+ clock.advance(by: 10)
+ try transport.readCallHistory(.dialed, count: 2)
+ try transport.readCallHistory(.missed, count: 2)
+ XCTAssertEqual(link.calls.last, #"AT+CPBS="DC""#)
+ clock.advance(by: 7)
+ XCTAssertFalse(events.contains(.callHistoryUnanswered(.dialed)))
+ clock.advance(by: 1)
+ XCTAssertEqual(events.filter { if case .callHistoryUnanswered = $0 { true } else { false } },
+ [.callHistoryUnanswered(.dialed), .callHistoryUnanswered(.missed)])
+
+ // A late answer to the given-up command is ignored; the next read starts cleanly.
+ transport.sentLine(#"AT+CPBS="DC""#, detail: nil, channel: link.channel)
+ transport.receivedLine("OK", detail: nil)
+ XCTAssertFalse(link.calls.contains("AT+CPBR=1,2"))
+ try transport.readCallHistory(.received, count: 2)
+ XCTAssertEqual(link.calls.last, #"AT+CPBS="RC""#)
+ }
+
func testCallHistoryIsSelectedThenReadOneListAtATime() async throws {
let transport = makeTransport()
let link = connected(transport)
@@ -683,6 +706,24 @@ final class AppleHFPTransportTests: XCTestCase {
XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+CPB") }, [#"AT+CPBS="DC""#])
}
+ /// The read starts as soon as IOBluetooth's follow-up commands went out and were answered, instead
+ /// of always waiting the full quiet period.
+ func testCallHistoryStartsOnceIOBluetoothsFollowUpsAreAnswered() throws {
+ let transport = makeTransport()
+ let link = connected(transport)
+ try transport.readCallHistory(.dialed, count: 5)
+ transport.sentLine("AT+VGS=7", detail: nil, channel: link.channel)
+ transport.receivedLine("OK", detail: nil)
+ clock.advance(by: 0.5)
+ XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "AT+VGM is still due")
+ transport.sentLine("AT+VGM=7", detail: nil, channel: link.channel)
+ clock.advance(by: 0.5)
+ XCTAssertFalse(link.calls.contains { $0.hasPrefix("AT+CPB") }, "AT+VGM waits for its reply")
+ transport.receivedLine("OK", detail: nil)
+ clock.advance(by: 0.5)
+ XCTAssertEqual(link.calls.filter { $0.hasPrefix("AT+CPB") }, [#"AT+CPBS="DC""#], "about 1.5 s after the link came up, not 10 s")
+ }
+
func testCallHistoryNeedsTheWriteHookAndALink() throws {
let untapped = makeTransport(address: "aa-bb-cc-dd-ee-01", assumeOutgoingTap: false)
_ = connected(untapped)
diff --git a/Tests/PickupCoreTests/CallHistoryTests.swift b/Tests/PickupCoreTests/CallHistoryTests.swift
index 0dfb32f..8b76c2e 100644
--- a/Tests/PickupCoreTests/CallHistoryTests.swift
+++ b/Tests/PickupCoreTests/CallHistoryTests.swift
@@ -183,4 +183,19 @@ final class CallHistoryTests: XCTestCase {
XCTAssertFalse(text.contains("Jane"), text)
}
}
+
+ // MARK: Unanswered reads
+
+ /// Regression (2026-10-30): a read the phone never answered left the dialer loading forever.
+ func testUnansweredReadEndsLoadingWithoutCountingAsRefused() {
+ transport.bringUp()
+ scheduler.advance(by: 2)
+ XCTAssertTrue(engine.callHistory.isLoading)
+ for list in CallHistoryList.allCases { transport.emit(.callHistoryUnanswered(list)) }
+ XCTAssertFalse(engine.callHistory.isLoading)
+ XCTAssertFalse(engine.callHistory.phoneRefused, "no answer is not a refusal: no hint about the phone's permission")
+ XCTAssertTrue(engine.callHistory.lastReadUnanswered)
+ transport.emit(.callHistory(.dialed, []))
+ XCTAssertFalse(engine.callHistory.lastReadUnanswered, "a later answer clears it")
+ }
}
diff --git a/docs/BLUETOOTH.md b/docs/BLUETOOTH.md
index f7f869d..24ae640 100644
--- a/docs/BLUETOOTH.md
+++ b/docs/BLUETOOTH.md
@@ -16,7 +16,7 @@ Rules for `AppleHFPTransport`:
- **Outgoing AT lines are routed by channel.** The process-wide write hook hands each line to the transport that owns the channel's hands-free object; lines of parked objects are only logged.
- **Diagnostics:** every AT line carries the object tag (`[hf#1]`), and connect lines show how many hands-free objects exist. An `AT+BRSF` on a channel the transport does not own is logged as an error, because that is the signature of this crash. Log lines are written with one `write(2)` each, before IOBluetooth processes the data, so the last line before a crash shows what triggered it.
-- **Call list reads (`AT+CPBS`/`AT+CPBR`) never overlap IOBluetooth's own commands.** On 2026-10-29 a read started 2 s after the SLC, while IOBluetooth's follow-up `AT+VGS`/`AT+VGM` were still due; they went out 6 s late and IOBluetooth crashed in `-[IOBluetoothHandsFreeDevice processResultCode:]` (`objc_retain` on a freed object) on the next incoming data. Reads that started after those commands never failed. A read therefore waits 10 s after the SLC (`Timeouts.historyQuietPeriod`) and until no command waits for its reply, and the two commands of one list are sent strictly one after the other.
+- **Call list reads (`AT+CPBS`/`AT+CPBR`) never overlap IOBluetooth's own commands.** On 2026-10-29 a read started 2 s after the SLC, while IOBluetooth's follow-up `AT+VGS`/`AT+VGM` were still due; they went out 6 s late and IOBluetooth crashed in `-[IOBluetoothHandsFreeDevice processResultCode:]` (`objc_retain` on a freed object) on the next incoming data. Reads that started after those commands never failed. A read therefore starts only once IOBluetooth's `AT+VGS` and `AT+VGM` of the current SLC went out and no command waits for its reply (usually 0.1 to 8 s after the SLC; once 17 s), or 10 s after the SLC at the latest (`Timeouts.historyQuietPeriod`). The two commands of one list are sent strictly one after the other, and a command the phone does not answer within 8 s (`Timeouts.historyCommand`) is given up: on 2026-10-30 an `AT+CPBS` was never written after a failed write and the read waited forever.
- **Three-way calling (`AT+CHLD=0/1/2`) is offered only when the phone announces it** (`+BRSF` bit 0 and `+CHLD` modes 0, 1 and 2). The commands go out through `sendATCommand` like the call list reads, but only during a call, long after the SLC set-up. The engine applies a command's effect on the phone's `OK` or on the matching `callheld`/`callsetup` indicator, whichever comes first; the call list (`AT+CLCC`) then confirms numbers and states. Not yet verified on hardware.
### Known edge cases to revisit after hardware tests
From aa2029fbe5f62be504249b2f57e219f7f7f11f78 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:04:04 +0100
Subject: [PATCH 16/20] test: fix the recent calls UI tests and size the tabs'
VoiceOver frame
- The tab test expected Right to go to Missed; the order is Outgoing,
Incoming, Missed.
- The segmented picker that VoiceOver sees for the tabs was only as wide as
its labels; it now spans the visible tabs (cursor and hit area match).
- Settings may be 405 pt high: the phone battery row added one row.
Co-Authored-By: Claude Opus 5.5
---
App/Sources/Pickup/DialerView.swift | 4 ++++
App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift | 3 ++-
App/Tests/PickupUITests/Cases/PopoverTests.swift | 5 ++++-
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/App/Sources/Pickup/DialerView.swift b/App/Sources/Pickup/DialerView.swift
index aafb3d5..c9961df 100644
--- a/App/Sources/Pickup/DialerView.swift
+++ b/App/Sources/Pickup/DialerView.swift
@@ -258,6 +258,7 @@ struct DialerView: View {
selectFirstRecentRow = wasInList
recentListRaw = target.rawValue
let newRows = DialerRows.recentRows(model.callHistory.entries(target), list: target)
+
let detail = wasInList ? newRows.first.map(selectionText) : nil
let tabName = target.localizedTabName
announce(newRows.isEmpty
@@ -1083,6 +1084,9 @@ private struct RecentCallsTabs: View {
Text("Recent Calls", comment: "VoiceOver label of the tabs above the recent calls in the popover.")
}
.pickerStyle(.segmented)
+ .labelsHidden()
+ // As wide as the visible tabs: VoiceOver's cursor and hit area match what is on screen.
+ .frame(maxWidth: .infinity)
}
}
}
diff --git a/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift b/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift
index b2cfb2a..3ca3ac0 100644
--- a/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift
+++ b/App/Tests/PickupUITests/Cases/LayoutAccessibilityTests.swift
@@ -23,7 +23,8 @@ final class LayoutAccessibilityTests: PickupUITestCase {
openSettings()
let window = element("settings-window")
let frame = try XCTUnwrap(stableFrame("settings-window"), "[\(language)] no window frame")
- XCTAssertLessThanOrEqual(frame.height, 385, "[\(language)] Settings is \(frame.height) pt high")
+ // 405: the phone battery row (2026-10-30) added one row to the former 385.
+ XCTAssertLessThanOrEqual(frame.height, 405, "[\(language)] Settings is \(frame.height) pt high")
let ids = Array(Set(settingsIdentifiers())).sorted()
XCTAssertGreaterThanOrEqual(ids.count, 4, "[\(language)] only found \(ids)")
assertControlsHaveLabels(in: window)
diff --git a/App/Tests/PickupUITests/Cases/PopoverTests.swift b/App/Tests/PickupUITests/Cases/PopoverTests.swift
index 58345e6..695daf8 100644
--- a/App/Tests/PickupUITests/Cases/PopoverTests.swift
+++ b/App/Tests/PickupUITests/Cases/PopoverTests.swift
@@ -66,9 +66,12 @@ final class PopoverTests: PickupUITestCase {
XCTAssertFalse(element("recent-row-DC-0").isSelected, "the first Tab stops at the tabs")
keyboard.press(Key.tab)
XCTAssertTrue(Wait.until(3) { self.element("recent-row-DC-0").isSelected }, "the second Tab enters the list")
+ // Tabs: Outgoing (DC) → Incoming (RC) → Missed (MC).
keyboard.press(Key.right)
- XCTAssertTrue(Wait.until(3) { self.exists("recent-row-MC-0") && self.element("recent-row-MC-0").isSelected },
+ XCTAssertTrue(Wait.until(3) { self.exists("recent-row-RC-0") && self.element("recent-row-RC-0").isSelected },
"Right switches to the next tab and keeps the keyboard in the list")
+ keyboard.press(Key.right)
+ XCTAssertTrue(Wait.until(3) { self.exists("recent-row-MC-0") && self.element("recent-row-MC-0").isSelected })
keyboard.press(Key.left)
XCTAssertTrue(Wait.until(3) { self.exists("recent-row-RC-0") && self.element("recent-row-RC-0").isSelected })
keyboard.press(Key.left)
From 85ea01ef6ba825c59f61b994023313c5f9866d88 Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:34:02 +0100
Subject: [PATCH 17/20] test: screenshots show recent calls and call waiting
The scripted phone lists more invented recent calls and, with
-PickupTestCallHold YES, supports three-way calling (off by default, so the
call-waiting UI test still covers phones without it). The capture script waits
for the recent calls and records the waiting-call card and the held call; the
generator uses a colourful synthetic wallpaper and a hero cropped to its
content (popover, banner, call window) without empty space.
Co-Authored-By: Claude Opus 5.5
---
.../Pickup/Testing/ScriptedPhone.swift | 44 ++++++++++++++--
scripts/capture-screenshots.sh | 22 ++++----
scripts/generate-screenshots.swift | 51 +++++++++++--------
3 files changed, 81 insertions(+), 36 deletions(-)
diff --git a/App/Sources/Pickup/Testing/ScriptedPhone.swift b/App/Sources/Pickup/Testing/ScriptedPhone.swift
index 72c61ce..700b342 100644
--- a/App/Sources/Pickup/Testing/ScriptedPhone.swift
+++ b/App/Sources/Pickup/Testing/ScriptedPhone.swift
@@ -85,15 +85,53 @@ final class ScriptedTransport: HFPTransport {
/// The phone's call lists (invented): it shares them like a Pixel that was granted access.
func readCallHistory(_ list: CallHistoryList, count: Int) throws {
try requireLink()
+ // The first entries are what the UI tests check; the rest fill the list for screenshots. Numbers without a
+ // name match the invented contacts (`ContactsDirectory.loadDemo`) or stay unknown.
let entries: [CallHistoryEntry] = switch list {
case .dialed: [CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: Self.john, name: "John Roe"),
- CallHistoryEntry(number: Self.jane, name: "Jane Doe")]
- case .received: [CallHistoryEntry(number: Self.john, name: "John Roe")]
- case .missed: [CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: "")]
+ CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: "+491700000111", count: 2),
+ CallHistoryEntry(number: "+4700000444"), CallHistoryEntry(number: "+49890000555"),
+ CallHistoryEntry(number: "+49300000222")]
+ case .received: [CallHistoryEntry(number: Self.john, name: "John Roe"), CallHistoryEntry(number: Self.jane, name: "Jane Doe"),
+ CallHistoryEntry(number: "+49400000333"), CallHistoryEntry(number: "+4700000444")]
+ case .missed: [CallHistoryEntry(number: Self.jane, name: "Jane Doe"), CallHistoryEntry(number: ""),
+ CallHistoryEntry(number: "+491700000111"), CallHistoryEntry(number: Self.john, name: "John Roe")]
}
later { [self] in emit(.callHistory(list, Array(entries.prefix(count)))) }
}
+ /// Three-way calling, only with `-PickupTestCallHold YES` (screenshots of the waiting-call card).
+ /// Off by default, so the call-waiting UI test keeps covering phones without it.
+ var supportsCallHold: Bool { UserDefaults.standard.bool(forKey: "PickupTestCallHold") }
+
+ /// `AT+CHLD` like a Pixel: OK first, then the indicators.
+ func callHold(_ action: CallHoldAction) throws {
+ try requireLink()
+ guard supportsCallHold else { throw TransportError.unsupported }
+ later { [self] in
+ emit(.commandCompleted(.callHold, .ok))
+ let waiting = callList.contains { $0.status == .waiting }
+ let held = callList.contains { $0.status == .held }
+ switch action {
+ case .holdActiveAcceptOther:
+ callList = callList.map { call in
+ var call = call
+ call.status = call.status == .active ? .held : .active
+ return call
+ }
+ if waiting { emit(.callSetup(.none)) }
+ emit(.callHeld(callList.count > 1 ? .activeAndHeld : (callList.first?.status == .held ? .heldOnly : .none)))
+ case .releaseActiveAcceptOther:
+ callList = callList.filter { $0.status != .active }.map { var call = $0; call.status = .active; return call }
+ if waiting { emit(.callSetup(.none)) }
+ if held { emit(.callHeld(.none)) }
+ case .releaseHeldOrRejectWaiting:
+ callList = callList.filter { $0.status != (waiting ? .waiting : .held) }
+ if waiting { emit(.callSetup(.none)) } else if held { emit(.callHeld(.none)) }
+ }
+ }
+ }
+
// MARK: Phone side (driven by the scenario)
func ringIncoming(number: String?) {
diff --git a/scripts/capture-screenshots.sh b/scripts/capture-screenshots.sh
index 1a8797b..34cf2c4 100755
--- a/scripts/capture-screenshots.sh
+++ b/scripts/capture-screenshots.sh
@@ -25,11 +25,7 @@ mkdir -p "$OUT" "$(dirname "$HELPER")"
for appearance in ${PICKUP_SHOT_APPEARANCES:-Light Dark}; do
rm -f "$OUT"/*-"$(echo "$appearance" | tr '[:upper:]' '[:lower:]')"-*.png
done
-# The popover follows the menu bar's (system) appearance, not the app's launch arguments.
-if [ "$(defaults read -g AppleInterfaceStyle 2>/dev/null || true)" = Dark ]; then
- echo "capture: note: the Mac is in Dark Mode, so the 'light' popover captures will be dark and are skipped by" \
- "generate-screenshots (switch to Light Mode yourself for light popover images)." >&2
-fi
+# The popover follows the menu bar's appearance: capture each appearance with the Mac in that mode.
swiftc -O "$ROOT/scripts/capture-screenshots-helper.swift" -o "$HELPER"
PID=""
@@ -66,11 +62,12 @@ for appearance in ${PICKUP_SHOT_APPEARANCES:-Light Dark}; do
for lang in en de; do
echo "== $appearance $lang"
# Popover: keypad open, search for a name.
- launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenPopover YES -PickupDemoQuery ja -PickupDemoKeypad YES
+ launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenPopover YES \
+ -PickupDemoQuery ja -PickupDemoKeypad YES
sleep 2.2; shoot "popover-search-$a-$lang"; stop
- # Popover: collapsed, empty search.
+ # Popover: empty search field, the phone's recent calls (read about 2 s after the link came up).
launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenPopover YES
- sleep 2.2; shoot "popover-empty-$a-$lang"; stop
+ sleep 4; shoot "popover-empty-$a-$lang"; stop
# Incoming banner (Jane Doe), then the HUD with a running timer, then the ended state.
launch "$appearance" "$lang" -PickupTestScenario incoming-accept
sleep 1.8; shoot "banner-$a-$lang"
@@ -82,11 +79,14 @@ for appearance in ${PICKUP_SHOT_APPEARANCES:-Light Dark}; do
fi
stop
[ "$lang" = en ] || continue
- # Call waiting: Jane is active, John Roe is waiting.
- launch "$appearance" "$lang" -PickupTestScenario call-waiting -PickupTestFastClock YES
+ # Call waiting: Jane is active, John Roe is waiting (a phone with three-way calling), then John is
+ # taken and Jane is on hold.
+ launch "$appearance" "$lang" -PickupTestScenario call-waiting -PickupTestFastClock YES -PickupTestCallHold YES
sleep 1.8; "$HELPER" press "$PID" banner-accept
sleep 3.2; "$HELPER" advance
- sleep 1.2; shoot "waiting-$a-$lang"; stop
+ sleep 1.2; shoot "waiting-$a-$lang"
+ "$HELPER" press "$PID" hud-waiting-hold-accept
+ sleep 2.5; shoot "held-$a-$lang"; stop
# Settings.
launch "$appearance" "$lang" -PickupTestScenario idle -PickupOpenSettings YES
# Activate Pickup so the window is captured active (coloured traffic lights, accent colours).
diff --git a/scripts/generate-screenshots.swift b/scripts/generate-screenshots.swift
index dca4280..6c47a7f 100644
--- a/scripts/generate-screenshots.swift
+++ b/scripts/generate-screenshots.swift
@@ -94,17 +94,18 @@ final class Canvas {
let small = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: sRGB,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
let full = CGRect(x: 0, y: 0, width: w, height: h)
- let base: [CGColor] = look == .light ? [rgb(0xFBF9F6), rgb(0xF0EBE5)] : [rgb(0x1E1A17), rgb(0x0E0C0B)]
+ let base: [CGColor] = look == .light ? [rgb(0xE9EEFF), rgb(0xFFEDE3)] : [rgb(0x0F1433), rgb(0x1C0E22)]
let linear = CGGradient(colorsSpace: sRGB, colors: base as CFArray, locations: [0, 1])!
small.drawLinearGradient(linear, start: CGPoint(x: 0, y: full.maxY), end: CGPoint(x: full.maxX, y: 0),
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation])
let size = max(full.width, full.height)
- // Soft glows: brand orange low right, warm peach top left, a lighter centre.
+ // Colourful glows, like a modern macOS wallpaper: brand orange low right, violet top left, sky blue
+ // top right, rose low left (y = 0 is the bottom).
let glows: [(CGFloat, CGFloat, CGFloat, CGColor)] = look == .light
- ? [(0.86, 0.02, 0.75, rgb(0xF2571A, 0.18)), (0.08, 0.95, 0.65, rgb(0xFFB48C, 0.26)),
- (0.45, 0.45, 0.55, rgb(0xFFFFFF, 0.35))]
- : [(0.90, 0.0, 0.70, rgb(0xFF6A2B, 0.22)), (0.05, 1.0, 0.60, rgb(0x5A3220, 0.20)),
- (0.45, 0.55, 0.50, rgb(0x2B2622, 0.30))]
+ ? [(0.92, 0.05, 0.70, rgb(0xFF7A3D, 0.42)), (0.05, 0.95, 0.70, rgb(0x8B7CFF, 0.40)),
+ (0.85, 1.00, 0.55, rgb(0x5BC8FF, 0.34)), (0.10, 0.05, 0.55, rgb(0xFF8FB1, 0.30))]
+ : [(0.92, 0.05, 0.70, rgb(0xFF6A2B, 0.50)), (0.05, 0.95, 0.70, rgb(0x6A4CFF, 0.50)),
+ (0.85, 1.00, 0.55, rgb(0x1FA3C9, 0.34)), (0.10, 0.05, 0.55, rgb(0xC23A8A, 0.36))]
for (fx, fy, fr, color) in glows {
let center = CGPoint(x: fx * full.width, y: fy * full.height)
let radial = CGGradient(colorsSpace: sRGB, colors: [color, color.copy(alpha: 0)!] as CFArray,
@@ -224,25 +225,29 @@ func feature(_ images: [CGImage], width: Int, look: Look, name: String, gap: CGF
c.write(name)
}
-/// Hero: the top-right corner of a Mac. Menu bar with the Pickup glyph, the incoming-call banner where macOS
-/// shows it, and (when a capture in this appearance exists) the popover open below the glyph.
+/// Hero: the top-right corner of a Mac, cropped to the content. Menu bar with the Pickup glyph, the popover with
+/// the recent calls open below it, and the incoming-call banner where macOS shows it, with the call window of
+/// a second caller below the banner.
func hero(look: Look, name: String) {
let suffix = look == .light ? "light" : "dark"
let banner = require("banner-\(suffix)-en-1")
- let pop = popover("popover-search-\(suffix)-en-1", look: look)
- let width = 2400, bar: CGFloat = 56, margin: CGFloat = 64
- let height = pop.map { Int(bar + 12 + CGFloat($0.height) + 110) } ?? 600
+ let pop = popover("popover-empty-\(suffix)-en-1", look: look) ?? require("popover-empty-\(suffix)-en-1")
+ let call = load("waiting-\(suffix)-en-1")
+ let bar: CGFloat = 56, margin: CGFloat = 120, gap: CGFloat = 110
+ let columnW = max(CGFloat(banner.width), CGFloat(call?.width ?? 0))
+ let width = Int(margin + CGFloat(pop.width) + gap + columnW + margin)
+ let columnH = CGFloat(banner.height) + (call.map { 70 + CGFloat($0.height) } ?? 0)
+ let height = Int(bar + 6 + max(CGFloat(pop.height), 24 + columnH) + 120)
let c = Canvas(width: width, height: height, look: look)
c.wallpaper()
- let bannerX = CGFloat(width) - margin - CGFloat(banner.width)
- if let pop {
- let glyphX = bannerX - 96 - CGFloat(pop.width) / 2
- menuBar(c, height: bar, glyphX: glyphX, highlighted: true)
- c.window(pop, x: (glyphX - CGFloat(pop.width) / 2).rounded(), y: bar + 6)
- } else {
- menuBar(c, height: bar, glyphX: bannerX - 140, highlighted: false)
+ let popX = margin
+ menuBar(c, height: bar, glyphX: popX + CGFloat(pop.width) / 2, highlighted: true)
+ c.window(pop, x: popX, y: bar + 6)
+ let columnX = popX + CGFloat(pop.width) + gap
+ c.window(banner, x: (columnX + (columnW - CGFloat(banner.width)) / 2).rounded(), y: bar + 30)
+ if let call {
+ c.window(call, x: (columnX + (columnW - CGFloat(call.width)) / 2).rounded(), y: bar + 30 + CGFloat(banner.height) + 70)
}
- c.window(banner, x: bannerX, y: bar + 30)
c.write(name)
}
@@ -266,7 +271,8 @@ func lightAndDark(name: String) {
}
try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
-hero(look: .light, name: "hero.png")
+// The README uses the dark images: on macOS 26 a capture of the popover window alone renders its Liquid Glass
+// without a backdrop, which is flat grey in light mode. Banner, call window and Settings exist in both.
hero(look: .dark, name: "hero-dark.png")
lightAndDark(name: "light-dark.png")
for look in [Look.light, .dark] {
@@ -276,9 +282,10 @@ for look in [Look.light, .dark] {
feature([require("hud-\(s)-en-1"), require("hud-ended-\(s)-en-1")], width: 1600, look: look, name: "call-hud-\(s).png")
feature([require("settings-\(s)-en-1")], width: 1600, look: look, name: "settings-\(s).png")
if let waiting = load("waiting-\(s)-en-1") {
- feature([waiting], width: 1600, look: look, name: "call-waiting-\(s).png", padY: 170)
+ feature([waiting] + [load("held-\(s)-en-1")].compactMap { $0 }, width: 1600, look: look,
+ name: "call-waiting-\(s).png", padY: 170)
}
- for lang in ["en", "de"] {
+ for lang in ["en", "de"] where look == .dark {
let tag = lang == "en" ? "" : "-de"
if let empty = popover("popover-empty-\(s)-\(lang)-1", look: look),
let search = popover("popover-search-\(s)-\(lang)-1", look: look) {
From fc7ed604028629d328032c35e46215b09ba8316d Mon Sep 17 00:00:00 2001
From: David Eiken <84112+macx@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:34:02 +0100
Subject: [PATCH 18/20] docs: new screenshots, features in the README and a
HISTORY.md
- Fresh README images: recent calls, call waiting and hold, Settings with the
battery row, on the new wallpaper. The README uses the dark images (a capture
of the popover alone renders its Liquid Glass flat grey in light mode).
- The feature list covers recent calls, hold/swap/call waiting, the missed-call
dot with Call Back, the battery level, the resizable popover and the welcome
window.
- HISTORY.md: hand-written notes per version for release texts and the website
(CHANGELOG.md stays the generated list).
Co-Authored-By: Claude Opus 5.5
---
HISTORY.md | 51 +++++++++++++++++++++++++
README.md | 20 ++++++----
docs/images/README.md | 10 ++---
docs/images/banner-dark.png | Bin 175654 -> 127775 bytes
docs/images/banner-de-dark.png | Bin 177656 -> 131500 bytes
docs/images/banner-de-light.png | Bin 111268 -> 98573 bytes
docs/images/banner-light.png | Bin 111548 -> 94978 bytes
docs/images/call-hud-dark.png | Bin 270579 -> 165772 bytes
docs/images/call-hud-light.png | Bin 129151 -> 106900 bytes
docs/images/call-waiting-dark.png | Bin 231569 -> 246578 bytes
docs/images/call-waiting-light.png | Bin 116222 -> 150148 bytes
docs/images/hero-dark.png | Bin 440082 -> 276387 bytes
docs/images/hero.png | Bin 146675 -> 183479 bytes
docs/images/light-dark.png | Bin 415268 -> 291094 bytes
docs/images/popover-search-dark.png | Bin 280697 -> 246783 bytes
docs/images/popover-search-de-dark.png | Bin 282101 -> 247224 bytes
docs/images/settings-dark.png | Bin 190218 -> 165653 bytes
docs/images/settings-light.png | Bin 177974 -> 137957 bytes
18 files changed, 68 insertions(+), 13 deletions(-)
create mode 100644 HISTORY.md
diff --git a/HISTORY.md b/HISTORY.md
new file mode 100644
index 0000000..cbde46e
--- /dev/null
+++ b/HISTORY.md
@@ -0,0 +1,51 @@
+# History
+
+What changed in each version of Pickup, in plain words. `CHANGELOG.md` is the technical list generated from the
+commits at release time; this file is written by hand and can be used for release notes, the website or posts.
+
+## 0.3.0 (upcoming)
+
+**Recent calls in the menu bar.** With an empty search field, the popover shows your phone's recent calls in three
+tabs: Outgoing, Incoming and Missed. Every row has its own call button; unknown numbers can be added to the
+contacts app you use on your Mac (Contacts, Cardhop, …), and ⌘C copies a number. The list comes straight from the
+phone's call log over Bluetooth and is never stored on the Mac. While it loads for the first time, placeholder rows
+show where it will appear.
+
+**Call waiting, hold and swap.** When a second call comes in, the call window offers *Hold & Accept*, *End & Accept*
+and *Decline*. A call can be put on hold and resumed, and with two calls you can swap between them or end the one on
+hold. Works with phones that support three-way calling (the Pixel does).
+
+**Never miss a call.** A missed call puts a small red dot on the menu bar icon, and the notification now has a
+*Call Back* button.
+
+**Your phone's battery.** Shown next to "Connected to …" in the popover, as a percentage in the menu bar, or not at
+all (Settings).
+
+**More comfortable.**
+- The popover can be made wider by dragging its edge (up to twice its width); search and lists grow with it.
+- Full keyboard control of the recent calls: Tab, the arrow keys, Return, Esc, ⌘1–3.
+- Round keypad keys with more room below the search field.
+- A welcome window explains the permissions Pickup needs, on first launch and, while Pickup is not yet signed by
+ Apple, after updates.
+- The German version now says "du".
+- The About window links to the source code and to the author's website.
+
+**Fixes.** A crash right after connecting (call lists were read while macOS was still setting up the link), a
+recent-calls spinner that could run forever, and a number of accessibility improvements (contrast, VoiceOver
+announcements, keyboard focus).
+
+## 0.2.0 (2026-10-23)
+
+- The menu bar icon brings an open call window to the front; Return ends a focused call.
+- Larger text and higher contrast in the call windows.
+- Hanging up shows its progress, and the call window closes once the phone has ended the call.
+
+## 0.1.0 (2026-10-13)
+
+The first public version.
+- Incoming call banner with name, number and photo from your contacts, with Accept and Decline.
+- Call window with the running time and End Call.
+- Dial from the menu bar: a Phone-style popover with keypad and contact search.
+- Phone number links (`tel:`, `callto:`) place the call on your phone after a confirmation.
+- Missed call notifications, a global shortcut, launch at login, automatic reconnects.
+- Native Liquid Glass design for macOS 26, light and dark, English and German.
diff --git a/README.md b/README.md
index 13b1cd8..462a5e6 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,7 @@
Your phone rings, your Mac shows who is calling. Accept, decline, dial. Nothing else in the way.
-
-
-
-
+
## Why Pickup
@@ -25,20 +22,27 @@ Your phone rings, your Mac shows who is calling. Accept, decline, dial. Nothing
## Features
- **Incoming call banner** with caller name (from your Contacts), number and photo, with **Accept** and **Decline**.
-- **Call window** with the running time and **End Call**, plus a note when a second call is waiting.
+- **Call window** with the running time and **End Call**. **Hold**, **Swap** between two calls, and when a second call comes in: **Hold & Accept**, **End & Accept** or **Decline** (with phones that support three-way calling).
+- **Recent calls** from your phone (Outgoing, Incoming, Missed), with a call button on every row, redial, **Add to Contacts** for unknown numbers and full keyboard control. Nothing is stored on your Mac.
- **Dial from the menu bar:** a Phone-style popover with a collapsible keypad and **search by name**. Arrow keys and Return, or just type digits and the keypad opens by itself.
- **Phone number links:** click a `tel:` or `callto:` link anywhere and Pickup places the call on your phone (always after a confirmation).
-- **Missed call notifications**, a **global shortcut** to open the popover from anywhere, **launch at login**.
+- **Missed calls** as a red dot on the menu bar icon and as a notification with **Call Back**, the phone's **battery level** in the popover or the menu bar, a **global shortcut** to open the popover from anywhere, **launch at login**.
- **Reconnects by itself** when your phone goes out of range, and after the Mac wakes from sleep.
+- A **resizable** popover and a **welcome window** that explains the permissions Pickup needs.
- Native **Liquid Glass** design for macOS 26, light and dark, English and German, keyboard and VoiceOver friendly.
-
-
+
+
+
+
+
+What changed in each version: [HISTORY.md](HISTORY.md).
+
## Requirements
- A Mac with **Apple Silicon** running **macOS 26 (Tahoe) or newer**.
diff --git a/docs/images/README.md b/docs/images/README.md
index 5f158d8..6b46166 100644
--- a/docs/images/README.md
+++ b/docs/images/README.md
@@ -1,7 +1,7 @@
# README images
-- `hero.png` / `hero-dark.png`: the top-right corner of a Mac: menu bar with the Pickup glyph, the incoming-call banner and (when a capture in that appearance exists) the open popover. `light-dark.png`: banner and call HUD in both appearances.
-- `banner-{light,dark}.png` (German: `banner-de-*`): incoming call. `call-hud-{light,dark}.png`: active call with timer and the ended state. `call-waiting-*`: a second caller during a call.
-- `popover-search-dark.png` (German: `popover-search-de-dark.png`): the empty popover and a name search with the keypad open. `settings-{light,dark}.png`: Settings.
-- Everything is invented test data (scripted "Test Phone", Jane Doe, John Roe); the wallpaper and menu bar are synthetic.
+- `hero-dark.png`: the top-right corner of a Mac, cropped to the content: menu bar with the Pickup glyph, the open popover with the recent calls, the incoming-call banner and the call window with a second caller waiting. `light-dark.png`: banner and call HUD in both appearances.
+- `banner-{light,dark}.png` (German: `banner-de-*`): incoming call. `call-hud-{light,dark}.png`: active call with timer and the ended state. `call-waiting-*`: a second caller during a call (three-way calling) and the call on hold with Swap.
+- `popover-search-dark.png` (German: `popover-search-de-dark.png`): the popover with the recent calls and a name search with the keypad open. `settings-{light,dark}.png`: Settings.
+- Everything is invented test data (scripted "Test Phone", Jane Doe, John Roe, Max Mustermann); the colourful wallpaper and the menu bar are synthetic.
- Regenerate: `PICKUP_SKIP_VERIFY=1 PICKUP_NO_SANDBOX=1 scripts/build-app.sh`, then `scripts/capture-screenshots.sh` (takes over the screen for about a minute, hands off), then `scripts/generate-screenshots.sh`.
-- The popover follows the menu bar's appearance, not the app's: the light popover images (and the popover in `hero.png`) need a capture run with the Mac in Light Mode.
+- Capture each appearance with the Mac in that mode (`PICKUP_SHOT_APPEARANCES=Light` or `Dark`). Known limit on macOS 26: a capture of the popover window alone renders its Liquid Glass without a backdrop, which looks right in dark mode but flat grey in light mode, so the hero and popover images are dark only.
diff --git a/docs/images/banner-dark.png b/docs/images/banner-dark.png
index 2e3f5800c1baa9b2def8588176677d1235b49823..59bade83117062d4734570fd4ee53bf2e8ce08c1 100644
GIT binary patch
literal 127775
zcmV*fKv2JlP))f<7pOLK0A*o{tic=Nsy)H!Lk1
zO09C}dkBiYviA+GDtoajL~Kx`Qxvoyfuzj@60&c|=C~*l!9zC%8XzXvKS|3Ac*yjue`DQqGjxa=A{!qgN;|zqvb7$-b
z*#7{jl9kHQBBo#}yn%5M9sgL8$OkdK25B=$qXYVDLc6U&dTIKRJ(5Q3;1RtYYc-56
zYaG-`cR>yW4GbF@
zeU3;|{LM4P1+&ViCO%ER9R0iZFGS=U=n>+S7#5qc9hlK@tuBvLoo%!KsXPNltUxMG
zC{p#vP(UMQIU<
z1qu`>P@q780tE^bC{Un4fdU(b62K}@pg@5F1qu`>P@q780tE^bD6na`Dk!)>(9(t(
zD<@%6s-AX5EJHJGm62`6u3=iKpe+Hh!1M`A!DxY%1yhowIS<0)23W)>ST(sC{X;K<
z94nx0jrC83W|yG9beu4DT(oivVrycBXFpf7DB-+vNjJ=9hC~7?YPmpx`6OZe>
zqBB=nPseaIa=A6<@j{p8F^ch3C^@K#tMC7O{oi?ydmdDHx?M7l*3`WV+h`1Jh8fS~
z1af7)$ax3bF(dH7bg`Q3%uc1IQ*7Zwol0!1ps|-*hh{r)b9%&f
zipDcXPJTa?9Hw!jFbJSk4=Z?6s)mfMbID;6s}4Xe!_k0LK)Y>xHkdlwm1lwbT!P?Y
z=~f-GJ;Nj9_T4^gY@Jd0kMp&w&&bWaGOKJZcOk>@Qme7EGNEM9Uvu8*8mDH9KK3=?
z0T7$d3?w*v)Ox10x7EEBVt!n7U~%U{j%`9Q!eXI`k=_>5JW3&Te>A!bQXMm$8se`=
z#v}JmIS_+QjN!Q+=5(vmS99n`QpjM3$g
zBB3xSt8*NoxaFoMQuM+o2Ul;@oK0rr=+5ooFxwDF|bg8Podl^RfgAJ8v@Czq7ZYIZk;Xj7A?7Dp2OML-{v*`Ygha
zY=4LKuk2}nn0P}*V-^aH49^|)R)91%ZE46^_C{2?jy!Ba4>C0C43_hmTmx#Mk0{ch
zFcFP4sEE?t0iT#Uc67}j^d%1wRRKgldA1%uFNwh$h1#khJT(u7sS)mxj0Py`#eC=|
z4A3xfN9fA2ffdB@CJUIb%O?G!lott%DKJio;nCD4XkM$9Y2~brcgp4H5+eGFmx#(f
zE)7ngE1)ybX^PqzQ_MZ_i#{PK_B>TtPGrt>cC
zK_(1cY6;
zjFt9*XXV#yOwg?Eo)-T%r`?;agiwppu`QsvUby}-5gi+T*Zf@@shSk`dwSNrK6
ziyo{!vSsuMi%(TxrrgsAj_b%hDBj|1zAIokUTd%9qV5XV^jT@*p2Kk2tf!b+63rG&
zdFWxbIJ_`#r!;Xd3h%7l^b)|jPA-1*Z6wotXdk}5MG#t
zRp7M3NFgMbW)Po)(S9mkF^-W|Sf=D_=E4EVP{KlP7(>~pN*-I1k4e-g?_>AVN
zoY8dhC3J;M^nhF<<&qewK!I}+=;v&nT=#Q47e#gXzGOB}{VKj@QFv!@?qKY^dYRT!
z2MoPiROhRVIy{}Ft@P7cAJ!gQS;)UYfdY91lVtLon1h>7RBVCm9m@iTHri|n`JQ+c
zhkt>&WZjR|9=3Bu{2Pchr$pK-9w<9q!Z3*aHqh5Rxq@CpTAdAF7c;&T((>n3&+3FCjQzF`~)9nVrL3?56@b<=bqI
zT#2&_taH4^UdcuNTuU!-J^iQ==N%Q7ynd8Su$Vkgwa(dyXN?~(b{GPU
zD&t(*WUoCAVT*)3I_-Ux(^atsiUFVGzfo7gbY
zI5QCWb%c#sEi=;kR@Eb!#5uAdH15}&?i4pS7c|p`xauRoxX}?m^s3)7skw)P2|KEt
zMx)KG?T4u49Z`huz(qfxNIWL6>~0&F0ZXP6>$noChS
z>3d1ajfm4TM&ya2X0`ASXp8b8@^47%HDq63aHt)F=y45MBZhyZsAWwr0BQgoaT)*>
z0%+R_-EVXl^B@_0TunBKE_%SFeSBp1CWFuim4m^MUE){96WI~ZsAA+DBsyf>7fcS3
z^TJ4^ij(J+z=9)2f@U^SA?VgGQ@wlG)|rUKorartabpA2>>`fEwYnUKhz_ogkLYF#
zpgwnY&QiU8W`s%gBN!n!BnqGuk*%#o+y2dZ&}<(X4F^ivD?)Vja@rI^m}3-4p;}B^
z`kGpcu`p}DH72`Zbx|#E#_VqtwbiNGGcMgR7<*RZ3m$Olc}JdaZ2DNekLnJbjZZ2M
zHkEG=CgNYqw9@c7apSn;Hix*qf|CxbGG+)eVuTJ=(OKUUI%!RI32_ZLALYS-IO}+j^C4^c^wq2g*DkUf-gtJ^W
zjGB}vC4^f&-o*DZx!7f11y3P|T=q{BwqK`&P$R3n`Zq^n?u8mC>$-QF^=mW%9v9plSK=$9Zgg3vj5<*_C+g+Nt7bp;wC#8w|DRrhM
zUAgS~^-axL^pmlMvp|74bbd6sS0v{t)Ys&(TZLbdOBbSPV;L?H0veYOBfjq1P2zbP
z{z}~D_3NI!G9AVdH(Pr#6BXtX&ufD#bQM34;`g1Em0H7>|+%SSW58?V3K{5!iM6SLM&Ct_zjt|EXp
zCf|zPewJ{xak`Vaz5j}U*j~t@*v=wNu_V`;WqfEZ$oP50n`e*A!8vyCV~pyU(%gCu
zHizWK%JAaCCY0IaZPu>2>3YOEGC=JWuH2!Xo;4izGRa_AiNP+=KppXXi#$0mGeL~U
zmDfNlw2+A%MIW#l{hNA>)pXioIc}A6vltLX@NLecwI1ZjhIHW+Si_X)TfBZ}%xVvl
zxb{P?0$k~*gC)ksk$4XJtk$y;Ceb;@>afr(x8_V<8m_|=*@4^YdaX_0CW|?ItYf#)
zzwuy@HWGSjKg`FFdC1?2PcS~La=qDy1qQC%dJ>l->c}+38|&&F>q_~w0*o7_K98E~
zG4|?8nZ_HROfeXrAz`F@w9m$7%uFbJ%53MZk}Xa_5w!8q!ZwUk=Z@9cg68!h2LB?T
zp>Q&`K0zo$RzBYKmFzu{N
z3hmu0qe!zSxa2~rl6e$&4pc`PfQc%Z)Eg`I1h)AITW8h^2`g-Y_uv?70?O
zvzBj>b7$n-C+MdhfxtwY61sp`NnudoNVKt$aO(-W?F4sQ0s(Q1YB3ZdMNM~vFZ&Xp
zdfGT&=!{MEgJ{};mdnMC-0af-U@?#h0gfbRj8Xl~o?M(P?NOXLWQt)~ZDnF;arE_X
zHwF>w&4gSYo4yQf>kuj8vo%LWNq5%Ih0$kh(*a`FLw^EomQ0cWO3$=neeUt=8CMrn
zVp8#g0J6F8xqKKZ<`2@yjok-h-=?FFJO<}%H1Rri43z$;fqH2CI<9c~WC1kTN7Ouo
zYECka4W-lxqFoOY@>Q~>jc~C%r5R&;QcXRT0I6^*#)VRUucLk|lJ84&?#(V)54fws
z|H9)P;Yk8n*1;Tq*^n_r{ngJjCo+aOCa$xvRY*hTS_OmA0wSX_3Fv3;ql$XOGqW_0
z(Cir`IpnQtoDw(C_?k4HO8hSyDj}Sg3BFWs7Ffd^usBlJ6QH;jR3x8Ew;z;1*u<$Y
z*^!u|2j}r_$?3@^S%*W)P?=&d#a^v1TCn`sjRGCUZ_Fzp6*-$qR0=*f
zY$jka`H$0sOV(3)OB4$`jvxC#8lrAx0Lu%E0-H1QO9|m3|7dN`IF~1Fbr`tn{+P+$
zJG!|#vc4AaEhU5pC{5f8TxYv$MBtPr?m2aJ6j?IJQ?ia!%7=aHPD>N_0++;*)-M*A
zrC!!n7o~}NfmQ6E+3O^J@tQSppM6}ViTfxTkO^(pb=imu$(TogPZemLf2MP@h@OyUNmoLeLA^f3#ILSdoubk5bF`7+BBo@*JQoO
z?%Z$iWVB-18(QhQor)<&oe)meepBz~vPh*ux!bg<%#H!aWygc=*HcsFv+!?R!9XbD
znTl%G#~$C};1ARgJ(?1!n#x@trq{eTLR^H5wUdq##=
zO>Xn67BRkRVV39B9~cSTgAPX?PP0W;=yPbrpNcPyXubzTTtiY^lhRa=5jl(w
zQA64y4M2@{5+RzD}%y&m1;pR@z#Wms4b9u~7y
zk4-|$FHR*Dbmcad%9#~Ib}@O&@#qR#|7+um4Wf}MRg|}rustA^r-r6a4{8u;bQQ}P
z!Wc_>!yzmB6mzPBnC!fAG8G=aO>Gdr2
zvP(jyVXf=`PNLs@Dy4*Q(}V)6rFm?;8B0ZGdn(7*7ptBl6puD9HOOLWSYIDeSfyc&
z+en!SrNBxCf~|yb=nH8AWtJQ(1!=SR+E5BKUJ2?_ZSGP+xKRhA9KeY=?6h_!S0$@-
za=Y*nLX;9hkaa)n2k11
z$~M=2TumTGQGx~Hp}=W0+Y-9vdOPcVcr>#ym-d=8p*G}{q1fjI3LM1@St5f9Y~MUA
za1=$=Jw|4+!mrE>Tp0BXh(;laXnP_i)^e}x59aDgkzj*b?Z8ZKZh-;?PQsBJvvg8&
zzjJNJ>XaA#eg;a&TC5XeqzXb}cr5|bMF18@?nr7a
zelYB`-Na~AB2{gc62bxnM&w`iWaP_C{in{p=c1rV--WMgPa~Vi(J$Ni)!mqHQjkXBUnpo{hGSy)^uYOLNgGTlSH-`1J)+NW!A#+7?0`t%j{s3b6mxaUCv?5eSRy-E;cT9$OH69Nt}P{alho{xlL*S3o&;Az;trhrq#7^Ha1J3MVw2%9q5;5kdz+$9TvyLg8+6Vny+P8&
zo+vNBhI?XrJDD_I4%&rL&~RM^NzT}6^fP2K2lCiIbFN=$rq^Z-P$=R=J#&NQh-`>E
z7pK829I)nX0dvAcCh|wFQ?${Q(Vj%IZk+i5JeBCM+Buo{E*BlDYLHoQw!U~a;`4N3
z?@F^Lof)Y%UlengCiW6=Ub#+n3KE
zi=q6XXNW{Illt*^G7USm&j7@2W5)MOSsqeDE#oj0d$7KF9*N<`@I@Hk<5*M-j0Wh0
zPm^G10kAyJ@y#gJHx{u$;u-0n^1y0xca|sPGXqTb7K`&sZN>zt
z4YffU6x!3&X?m;*H76lWj@24jH3=9OWc@8O+>?#IZ(9tF`h`JQM!`>TZJIm=G5WPZ
z?-9gGs5msw(PQJ@`PKF+2a1}h=wVH_)=Vp=DoA8{fenFnApk|$`XqXoQewo&aZXnl
z&nk$D*qPmic3pc3H66lIS_q@(N9BMl!5%0iF=Fa<^a5fevA_($XfQ)x!sL4^DaTaw
z7p$p|mYBZwriC_xKKd#FqZ-L!N=>a59>|o#VTgGtJQr+RXn8kW5Kpj0bQm)Z`n|3a
zO-t(QxBgc3rUT0FsSLXY)0
zpz(nrz=d)97R9z&LO9@gtrS8lIZQNCF2xMVWuIp&y(uMx+hx2`LU=|3F)zpal{Ejn
z-K^c>;9|$CLotyfrT2>7ya+^;Chi3aj0lF({O^oy0Xaq!_d+Kp<5Cpt=jWJthGl_S
zE@XOm0LJ(M73b61v%A2_Jd*Lt5}Q@q+iKg^KRLSAY0^Ab280anI2c
zd93;R$Q#vY<~!yH$^u2M7C2Y9QV+eH`_~GOCm`tTfQeV
zWQ)cwPz2B{$9Q>@rwc@n76`Mxcql&bg~;#}zMyeUIi6y^4o8%NzZCU*p;HtK6gV6H
zEoSn0xiuH0zN~>B?a`ePJxEeD(+W>%32V+REdi_o1w`kd6^hRa99O=}f&v3Qg+9I#
z53vHCh0;9J8N-E3k4_+oHD;xV^Q6wzqyf0`%CG`Q(uCG8T5C|(mIITjm3fvomj3mz
z(7j;TW)zq(RJKVCv$X}x=eYO|1qu|nP%s3rCVTlUmK^h%F|XgQJ(lFg!R(T7k<-U5
zWJzh#n&dg-`fEBC`P%upgw_)KKZps!SO>r>Ox2Mh|R+AECF~Ud-+?WnmsUcxI%3
z{jqEhY-{;G)tuX{+~RdLG^^BR9de@fB%M02=%HM{nY_TS{Hy`3mWww=J
z=Hc|>JYOr?G1SePAe?Ihc-PC%ndtx+S&rPu;M%atWR$w~a
zm7mEyuuBgY
z%@Me?lTaRd7>R08ITcv=C=bg(kxU$k%F#&v7Amy1TICE8p=cd)MNi1OMm90Se1viq
z9-Auv<8lL-fzh}ifXzrbRckn|x(uV^?Zppo7b>F#o`|EY2#>T3{4d64T0S$AD-L
zcBX^PDbE!NR>Nazq8JaajM|h+MPR|FhZLd?JVZz#l{bo7Yy!4p6rrMOp?tY7_iC)P
z?%evSBcm8666F7wvShQ%xVgr-^jB0Ts3dyosEdE>#U>wOi3JE86Un5e2$hwf_{PdS
zE+~SJI+et?z0jm&L$>H^+kj89BlGKTIZ8w^qfLfPkqtny@iuB`M&$-VFAr!DIL;CM
zskUQ+P(_SRkR~@=BB4y#=TSl&iI^fzh_$}%pFUK(h(`0DBQIN)>&;Y88eJvWFZ2WB
z$HzVvv_DK&W+%l^kYJ!kJsm`7H?8yk;da+hN-rD|GpNcVp^79QXd
z6i=T)g1BM~6YQ}l4z*$EohBk~x7Hg=x6&r@(7gkcJ5b}JI3hBX5Lz=s^?;YPgmCR#
zRkb9i?s0>mFvsBf$RX!L`8Z9M+nP8Osq17g(zj-btgl9<*SW!Yx7=vka
za>872?cD-rXc4S>Dp^X>c#2p5YgdXK%x7lUk3(GWk*Riwxdbw+7EEWav(dgsC?VYP
zti}|B+
zuPZchmlQhLT5Ic5wccHiU5i0;TuPZOKMSm>rmV+R80MO)#D#+PV!y`v8<^I8iSJ@(m}$LP>}5W1
zfm1nDyuKWnR8pNFKecsI-dz?hKPR~@dM#)Q0a@3-ej0Ms<60sIin=aP;Q0Jo`0g^~
z?HQ`CHOS3nx-SwApkKNeTgMsqUjkT{C|K9#fF%JhF4#}5KwE*C9&>SE3LKLruNl2<
zAfR8MK!GiKB#F7E#eXhPU?aM_z|nAC+?1oJBW&ydN&u_C_0%w>RPb6H_QDFA))#wC
zab^a9m5~9*F3_?SKo+<_%Nj;X#5(dbSFvw_;(%Re{$2ZkIVA^*CNW4i>nY}1=Nj`x
zX*N(ifFhSJ4X!d$3TJ(5GqjGy&Ew-A=IvdGOixuL_lB*Vv~GKun%ZWbF1j836gZ}Y
zrdXb19GP0Xl+5SZKgh&(
zJdOOJfznk%Y}y4Sjwdt{%2`q38c9o#(>Y>pEcOb-1a=%6C(2
zP7dUdw`XK-Us*{G$x~Lx&k!JO&j!=wn1klXn>|FvX^+1Nl;Hrf@Jcr^=u*!s;k1L%
zvbPS{1gD=$1eTxzMJPVSij4m~p$RdR6Xm2Vz_J*J(Xn4s2^(|fSo=bZnR^OD4zW9U
zf8F^e*>1PTm>}R9LJmr90iocBeq_;DVNx_$MQwAWwoc?%8B7<_uIIoBOC>5I{`c5N
z3|S=uNdRc{27^Ca3zVpsB(?d>jH4aaLCl&ONvEqZBBmOHF#1T6Am_C%myG$?Toh**
zpqY%HPIaMI=VdKCEryGM!JFW!YF8Qm?6h)R#4$Y}L@hIG>fA+A$$Z@YQDV4@ZMzN{
zhGSD18IQ{5nEQf^o^d6shJX;o5J|7RGf1A^j+z3aI3ka3p4%8Kf_}~Z8Er|h-GF#Ab#|A=m}tQplMm7hXzK9v&%@}Ta97Sa$G_t$^Xd)FuI*UqwyGj!Db7+
zVkC%5#lb^*;``X+q~G^At~9qERjbjQ?k__SH4j-UTgy9IWWo=P?pOT5;=i*`+cKCB
zii7@(bL5oynJR8)ctxWUT6