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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,56 +37,47 @@
/// preferred language the app localizes into, so every preferred language takes part.
static let system = fromSystemStrings(languages: Locale.preferredLanguages)

/// The string tables that word the system permission prompts: notifications, privacy access
/// (including App Tracking Transparency), and location.
private static let promptStringTables = [
("/System/Library/PrivateFrameworks/UserNotificationsServer.framework", "Localizable"),
("/System/Library/PrivateFrameworks/TCC.framework", "Localizable"),
("/System/Library/Frameworks/CoreLocation.framework", "locationd")
]

/// Pairs each English prompt string with the same key's value in each of `languages`. Every
/// table must read, or none counts: a missing table could hide the translation that makes a
/// label in another table claim both actions.
/// Pairs each English prompt string with the same key's value in each of `languages`.
static func fromSystemStrings(languages: [String]) -> RunnerAlertLabelVocabulary {
// The simulated OS, taken from where this process's Foundation loaded; the simulator process
// would otherwise read the host's /System.
let foundationPath = "/System/Library/Frameworks/Foundation.framework"
guard let foundation = Bundle(identifier: "com.apple.Foundation")?.bundlePath, foundation.hasSuffix(foundationPath)
else { return unavailable(source: foundationPath, languages: languages) }
let root = String(foundation.dropLast(foundationPath.count))
composed(
permission: RunnerSystemPromptStrings.read(RunnerSystemPromptStrings.promptTables, languages: languages),
launchConfirmation: RunnerSystemPromptStrings.read([RunnerSystemPromptStrings.launchConfirmationTable], languages: languages),
languages: languages
)
}

Check notice on line 47 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAlertLabelVocabulary.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: SpringBoard's SchemeApproval table is parsed twice per process through two independent reads

`RunnerAlertLabelVocabulary.fromSystemStrings` reads `[launchConfirmationTable]` through `RunnerSystemPromptStrings.read` and keeps only the two button keys, and `RunnerLaunchConfirmationPrompt.fromSystemStrings` reads the same single table again to build its wordings. Both are `static let` and both are pre-warmed in `RunnerTests.swift` (lines 321-322), so the same `.strings` file is re-parsed once per preferred localization in each reader. The shared `RunnerSystemPromptStrings` the PR introduces is the natural single owner of that read. ### Also raised here Also raised at this location by senior-developer: CoreServices/SchemeApproval table is read twice at startup and can log the same failure twice — `RunnerAlertLabelVocabulary.fromSystemStrings` (RunnerAlertLabelVocabulary.swift:41-47) and `RunnerLaunchConfirmationPrompt.fromSystemStrings` (RunnerLaunchConfirmationPrompt.swift) each call `RunnerSystemPromptStrings.read([launchConfirmationTable], ...)` independently, and `RunnerTests.swift:321-322` prewarms both on separate global queues. The two consumers therefore parse the same `.strings` files twice per session and, when the table is missing, emit two `AGENT_DEVICE_RUNNER_SYSTEM_PROMPT_STRINGS_UNAVAILABLE` lines for the same source (subsystems `alert-vocabulary-launch-confirmation` and `launch-confirmation`), which reads as two distinct problems in a device log. The two reads also need not agree: nothing keeps the vocabulary's labels and the prompt's wordings sourced from one read, so a mid-session failure that only one of the two sees leaves the vocabulary translating labels the matcher does not recognize, or the reverse. Also raised at this location by swift-ios: `marksLaunchConfirmation` runs on every `alert get`, paying for an unreadable string table on each read — `handleAlert`'s read branch calls `marksLaunchConfirmation` unconditionally (RunnerTests+Alert.swift:147), which evaluates `RunnerLaunchConfirmationPrompt.system`. The first access parses eve…

/// Every permission table must read, or none of them counts: a missing table could hide the
/// translation that makes a label in another table claim both actions. The launch confirmation's
/// table is independent of them. Only its open and cancel labels are taken, so they stay when the
/// permission read fails, and the permission vocabulary stays whole when that table is missing.
static func composed(
Comment thread
thymikee marked this conversation as resolved.
Resolved
permission: Result<[RunnerSystemPromptStrings], RunnerSystemPromptStrings.UnavailableSource>,
launchConfirmation: Result<[RunnerSystemPromptStrings], RunnerSystemPromptStrings.UnavailableSource>,
languages: [String]
) -> RunnerAlertLabelVocabulary {
var tables: [RunnerSystemPromptStrings] = []
switch permission {
case .failure(let failure):
RunnerSystemPromptStrings.logUnavailable(failure, subsystem: "alert-vocabulary", languages: languages)
case .success(let read):
tables = read
}
switch launchConfirmation {
case .failure(let failure):
RunnerSystemPromptStrings.logUnavailable(failure, subsystem: "alert-vocabulary-launch-confirmation", languages: languages)
case .success(let read):
tables += read.map { $0.limited(to: RunnerSystemPromptStrings.launchConfirmationLabelKeys) }
}
var translations: [(english: String, localized: String)] = []
for (path, table) in promptStringTables {
guard let bundle = Bundle(path: root + path),
let english = strings(in: bundle, table: table, localization: "en")
else { return unavailable(source: path, languages: languages) }
let localizations = Set(languages.compactMap {
Bundle.preferredLocalizations(from: bundle.localizations, forPreferences: [$0]).first
}).subtracting(["en"])
for localization in localizations {
guard let localized = strings(in: bundle, table: table, localization: localization)
else { return unavailable(source: "\(path) \(localization)", languages: languages) }
for (key, value) in english {
if let translation = localized[key] { translations.append((english: value, localized: translation)) }
for table in tables {
for strings in table.localized.values {
for (key, value) in table.english {
if let translation = strings[key] { translations.append((english: value, localized: translation)) }
}
}
}
return RunnerAlertLabelVocabulary(translations: translations)

Check warning on line 79 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAlertLabelVocabulary.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: Splitting the tables relaxes the cross-table "both roles claim it" safety rule

`fromSystemStrings` used to fail as a whole when any of the four tables could not be read, precisely so that a label whose translation spans accept and dismiss stayed unknown (#​3627, "a translation both roles claim stays unknown", and the removed comment "a missing table could hide the translation that makes a label in another table claim both actions"). `composed` now treats the two reads independently: a permission read that fails contributes nothing, while `SchemeApproval`'s `SCHEME_APPROVAL_OPEN_BUTTON`/`SCHEME_APPROVAL_DO_NOT_OPEN_BUTTON` translations still register. The ambiguity filter in `RunnerAlertLabelVocabulary.init` only sees the translations that were actually read, so on a runtime where the permission table for a preferred localization cannot be read, a word that is SpringBoard's `Open` label in that language resolves to `accept` even if the unreadable permission table would have claimed the same word as a decline label. `alert accept` then taps it on a permission prompt where the old runner (English-only, or both tables read) refused. The failure needs one language whose open/cancel word also appears as the opposite role in a permission table, plus that table being unreadable, so nothing fails today — but the invariant is now enforced per table rather than across the vocabulary, and neither the doc comment on `launchConfirmationTable` nor `commands.md` states that trade-off (the docs only describe label _survival_, not the lost ambiguity check).
}

private static func unavailable(source: String, languages: [String]) -> RunnerAlertLabelVocabulary {
NSLog(
"AGENT_DEVICE_RUNNER_ALERT_VOCABULARY_UNAVAILABLE source=%@ languages=%@",
source, languages.joined(separator: ",")
)
return .english
}

private static func strings(in bundle: Bundle, table: String, localization: String) -> [String: String]? {
guard let url = bundle.url(forResource: table, withExtension: "strings", subdirectory: nil, localization: localization)
else { return nil }
return NSDictionary(contentsOf: url) as? [String: String]
}
}
#else
extension RunnerAlertLabelVocabulary {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

/// SpringBoard's `Open in “<App>”?` launch confirmation, recognized by the OS's own wording of its
/// title and its two buttons. A prompt counts only when both buttons are exactly the open and
/// cancel labels of one language, so an app alert that happens to start the same way is not it.
struct RunnerLaunchConfirmationPrompt {
/// One language's wording, as SpringBoard's `SchemeApproval` strings give it.
struct Wording {
let titleFormat: String
let open: String
let cancel: String
}

static let unavailable = RunnerLaunchConfirmationPrompt(wordings: [])
Comment thread
thymikee marked this conversation as resolved.
Resolved

private static let appNamePlaceholder = try! NSRegularExpression(pattern: "%(?:\\d+\\$)?(?:\\[[^\\]]*\\])?@")

private let wordings: [(title: NSRegularExpression, open: String, cancel: String)]

/// A wording whose title format has no app-name placeholder is skipped, because its pattern would
/// match a title with no app name. A placeholder is `%@`, a positional `%1$@`, or Apple's
/// bracketed object format `%[tt]@`, as Finnish words it.
init(wordings: [Wording]) {
Comment thread
thymikee marked this conversation as resolved.
Resolved
let placeholder = Self.appNamePlaceholder
self.wordings = wordings.compactMap { wording in
Comment thread
thymikee marked this conversation as resolved.
Resolved
let format = wording.titleFormat as NSString
let slots = placeholder.matches(in: wording.titleFormat, range: NSRange(location: 0, length: format.length))
guard !slots.isEmpty else { return nil }
var pattern = ""
var from = 0
for slot in slots {
pattern += NSRegularExpression.escapedPattern(for: format.substring(with: NSRange(location: from, length: slot.range.location - from)))
pattern += ".+"
from = slot.range.location + slot.range.length
}
pattern += NSRegularExpression.escapedPattern(for: format.substring(from: from))
guard let title = try? NSRegularExpression(pattern: "^\(pattern)$") else { return nil }
return (title, Self.normalized(wording.open), Self.normalized(wording.cancel))
}

Check notice on line 39 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerLaunchConfirmationPrompt.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: A positional title format builds a matcher that demands the wrong literal text

`RunnerLaunchConfirmationPrompt.init` splits the title format on every placeholder match and escapes the literal text between matches, joining them with `.+`. A format with two positional references to the same argument — e.g. `Avataanko %1$[tt]@-apissa?` (or any `%n$@` used twice) — yields the pattern `^Avataanko .+\-apissa?.+\-apissa?$`, because the text after the second slot is appended again. The rendered title (`Avataanko Testi-apissa?`) then never matches, so the prompt is silently not recognized for that language and `open` reports `alert-unrecognized`. The new test `testLaunchConfirmationWordingAcceptsBracketedObjectFormats` only covers `%1$[tt]@` used once, so the case is not caught. The code already treats several slots as one app-name slot (`".+"` per slot, and the added test title substitutes the app name into every match), so the minimal fix is to skip the duplicated trailing literal when the slots are positional references to a single argument — e.g. deduplicate the position set and build the pattern from the distinct positions — or to document that a format with a repeated positional yields no matcher. As written, an OS format variant that Apple ships later turns the feature off for one language with no diagnostic.
}

func recognizes(title: String, buttons: [String]) -> Bool {
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
let titleRange = NSRange(title.startIndex..., in: title)
let labels = buttons.map(Self.normalized)
guard labels.count == 2 else { return false }
return wordings.contains { wording in
wording.title.firstMatch(in: title, range: titleRange) != nil
&& Set(labels) == [wording.open, wording.cancel]
}
}

private static func normalized(_ label: String) -> String {
label.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

#if os(iOS) && targetEnvironment(simulator)
Comment thread
thymikee marked this conversation as resolved.
Resolved
extension RunnerLaunchConfirmationPrompt {
/// English and every preferred language, read once from the OS strings. Unreadable strings
/// recognize nothing, so the prompt stays an unrecognized alert.
static let system = fromSystemStrings(languages: Locale.preferredLanguages)

static func fromSystemStrings(languages: [String]) -> RunnerLaunchConfirmationPrompt {
Comment thread
thymikee marked this conversation as resolved.
Resolved
switch RunnerSystemPromptStrings.read([RunnerSystemPromptStrings.launchConfirmationTable], languages: languages) {
case .failure(let failure):
RunnerSystemPromptStrings.logUnavailable(failure, subsystem: "launch-confirmation", languages: languages)
return .unavailable
case .success(let tables):
guard let table = tables.first else { return .unavailable }
let wordings = ([table.english] + table.localized.values).compactMap { strings -> Wording? in
guard let title = strings["SCHEME_APPROVAL_PROMPT_TITLE_NO_SOURCE"],
let open = strings["SCHEME_APPROVAL_OPEN_BUTTON"],
let cancel = strings["SCHEME_APPROVAL_DO_NOT_OPEN_BUTTON"]
else { return nil }
return Wording(titleFormat: title, open: open, cancel: cancel)
}
return RunnerLaunchConfirmationPrompt(wordings: wordings)
}
}
}
#else
extension RunnerLaunchConfirmationPrompt {
static let system = unavailable
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Foundation

#if os(iOS) && targetEnvironment(simulator)
/// One OS string table read in English and in each preferred language that is not English.
struct RunnerSystemPromptStrings {
let english: [String: String]
/// The table's strings by localization, English excluded.
let localized: [String: [String: String]]

/// The string tables that word the system permission prompts: notifications, privacy access
Comment thread
thymikee marked this conversation as resolved.
Resolved
/// (including App Tracking Transparency), and location.
static let promptTables = [
(path: "/System/Library/PrivateFrameworks/UserNotificationsServer.framework", table: "Localizable"),
(path: "/System/Library/PrivateFrameworks/TCC.framework", table: "Localizable"),
(path: "/System/Library/Frameworks/CoreLocation.framework", table: "locationd")
]

/// SpringBoard's launch confirmation. It is read apart from `promptTables`, so a runtime without
Comment thread
thymikee marked this conversation as resolved.
Resolved
/// it keeps the permission prompts' vocabulary.
static let launchConfirmationTable = (
path: "/System/Library/Frameworks/CoreServices.framework",
table: "SchemeApproval"
)

Comment thread
thymikee marked this conversation as resolved.
Resolved
/// The only keys of the launch confirmation table that the alert vocabulary takes, so another key
/// of an undocumented table cannot join the accept or dismiss labels.
static let launchConfirmationLabelKeys: Set<String> = ["SCHEME_APPROVAL_OPEN_BUTTON", "SCHEME_APPROVAL_DO_NOT_OPEN_BUTTON"]

Check notice on line 27 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSystemPromptStrings.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: SchemeApproval keys are hard-coded in two places

The three `SCHEME_APPROVAL_*` key names are written literally in RunnerSystemPromptStrings.launchConfirmationLabelKeys (lines 26-27) and again in RunnerLaunchConfirmationPrompt.fromSystemStrings. The two lists have to agree: if the title key were only listed in the label-key set, or the open/cancel keys spelled differently in one place, the launch-confirmation matcher would silently build zero wordings and the prompt would stay alert-unrecognized with no error. Put the keys in one place (e.g. named statics on RunnerSystemPromptStrings) and reference them from both.

/// Reads `tables` from the simulated OS, or names the source that did not read. The simulated OS
/// is taken from where this process's Foundation loaded; the simulator process would otherwise
/// read the host's /System. A table reads in every preferred language or not at all, because a
/// missing translation could hide the one that makes a label claim both actions. The read fails
/// as a whole when any listed table fails, so pass tables that must stand or fall together.
static func read(
_ tables: [(path: String, table: String)],
languages: [String]
) -> Result<[RunnerSystemPromptStrings], UnavailableSource> {
let foundationPath = "/System/Library/Frameworks/Foundation.framework"
guard let foundation = Bundle(identifier: "com.apple.Foundation")?.bundlePath, foundation.hasSuffix(foundationPath)
else { return .failure(UnavailableSource(source: foundationPath)) }
let root = String(foundation.dropLast(foundationPath.count))
var read: [RunnerSystemPromptStrings] = []
for (path, table) in tables {
guard let bundle = Bundle(path: root + path), let english = strings(in: bundle, table: table, localization: "en")
else { return .failure(UnavailableSource(source: path)) }
let localizations = Set(languages.compactMap {
Bundle.preferredLocalizations(from: bundle.localizations, forPreferences: [$0]).first
}).subtracting(["en"])
var localized: [String: [String: String]] = [:]
for localization in localizations {
guard let strings = strings(in: bundle, table: table, localization: localization)
else { return .failure(UnavailableSource(source: "\(path) \(localization)")) }
localized[localization] = strings
}
read.append(RunnerSystemPromptStrings(english: english, localized: localized))
}
return .success(read)
}

/// This table restricted to `keys`.
func limited(to keys: Set<String>) -> RunnerSystemPromptStrings {
RunnerSystemPromptStrings(
english: english.filter { keys.contains($0.key) },
localized: localized.mapValues { $0.filter { keys.contains($0.key) } }
)
}

struct UnavailableSource: Error {
let source: String
}

Comment thread
thymikee marked this conversation as resolved.
Resolved
static func logUnavailable(_ failure: UnavailableSource, subsystem: String, languages: [String]) {
NSLog(
"AGENT_DEVICE_RUNNER_SYSTEM_PROMPT_STRINGS_UNAVAILABLE subsystem=%@ source=%@ languages=%@",
subsystem, failure.source, languages.joined(separator: ",")
)
}

Check warning on line 78 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSystemPromptStrings.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: Runner log marker for an unavailable vocabulary is renamed with no note

The old `unavailable(source:languages:)` helper emitted `AGENT_DEVICE_RUNNER_ALERT_VOCABULARY_UNAVAILABLE source=… languages=…`. Every path that used it now goes through `RunnerSystemPromptStrings.logUnavailable`, which emits a different key and adds a subsystem: `AGENT_DEVICE_RUNNER_SYSTEM_PROMPT_STRINGS_UNAVAILABLE subsystem=… source=… languages=…`. Nothing else in this checkout matches the old marker, but it is a machine-scraped diagnostic line that disappears from runner logs with this PR, and no doc or CHANGELOG mentions the rename. ### Also raised here Also raised at this location by maintainability: This is the shared reader's only log line, and it replaces `AGENT_DEVICE_RUNNER_ALERT_VOCABULARY_UNAVAILABLE` that #​4… — This is the shared reader's only log line, and it replaces `AGENT_DEVICE_RUNNER_ALERT_VOCABULARY_UNAVAILABLE` that #​4006 introduced. The string is a machine-read marker (the `AGENT_DEVICE_RUNNER_*` prefix, NSLog on the device path), and the rename changes both the marker and the field order without carrying the old one. Anything that still greps the old marker — device-verification notes, daemon-side log triage, issue threads — loses the signal silently, and the two log sites that used to be distinguishable by subsystem now differ only by the `subsystem=` field appended to a new marker. Either keep the established marker or state in the PR body that the marker changed so readers of old logs know.
private static func strings(in bundle: Bundle, table: String, localization: String) -> [String: String]? {
guard let url = bundle.url(forResource: table, withExtension: "strings", subdirectory: nil, localization: localization)
else { return nil }
return NSDictionary(contentsOf: url) as? [String: String]
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,26 @@
}
}

let title = preferredAlertTitle(alert.root, buttons: alert.buttons)
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
let items = alert.buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines) }
return Response(
ok: true,
data: DataPayload(
message: preferredAlertTitle(alert.root, buttons: alert.buttons),
items: alert.buttons.map { $0.label.trimmingCharacters(in: .whitespacesAndNewlines) }
message: title,
items: items,
launchConfirmationPrompt: marksLaunchConfirmation(alert, title: title, items: items) ? true : nil
)
)
}

/// The launch confirmation is SpringBoard's own modal. An app alert can carry the same wording
/// and buttons, and `open` accepts a marked alert, so the mark needs the SpringBoard source too.
func marksLaunchConfirmation(_ alert: RunnerAlert, title: String, items: [String]) -> Bool {
alert.source == .blockingSystemModal
&& alert.ownerApp === springboard
&& RunnerLaunchConfirmationPrompt.system.recognizes(title: title, buttons: items)
}

Check notice on line 158 in apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: The new mark is added to every alert read, including the `alert get` / `alert wait` payload, not only the open probe

`marksLaunchConfirmation` is evaluated in the shared alert-read response builder (RunnerTests+Alert.swift:140-147), so `launchConfirmationPrompt` appears in the payload of `alert get` and `alert wait` as well as in the launch-confirmation probe. The PR description flags this as intended-but-questionable ("Say so if you want it stripped"). The consumer in this checkout reads the raw runner record (`alertIfPresent` in packages/platform-apple/src/alert.ts:63 returns the runner's JSON verbatim, and `isLaunchConfirmation` reads the field straight off it), so the field is now part of the public `alert get` output shape with no entry in `AlertInfo` (packages/contracts/src/alert-contract.ts:190-196), which is the declared shape for alert reads. Anyone consuming the typed `AlertInfo` sees a payload field that its contract does not declare. Documented in commands.md line 445, so this is a contract-shape gap rather than an undocumented change.

@MainActor
func activateAlertButton(
_ alert: RunnerAlert,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,9 @@ struct DataPayload: Codable {
var text: String?
var found: Bool?
var items: [String]?
/// Set on an alert read that is SpringBoard's `Open in “<App>”?` launch confirmation, in the
Comment thread
thymikee marked this conversation as resolved.
Resolved
Comment thread
thymikee marked this conversation as resolved.
Resolved
/// device's language; absent otherwise.
var launchConfirmationPrompt: Bool?
var nodes: [PresentedNode]?
var truncated: Bool?
var qualityPayload: SnapshotQualityPayload? = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ final class RunnerTests: XCTestCase {
listener = try Self.makeRunnerListener(port: configuration.port)
// Reads the OS prompt strings off the alert path, so the first accept/dismiss deadline does not pay for it.
DispatchQueue.global(qos: .utility).async { _ = RunnerAlertLabelVocabulary.system }
DispatchQueue.global(qos: .utility).async { _ = RunnerLaunchConfirmationPrompt.system }
listener?.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
Expand Down
Loading
Loading