Repository navigation
fix(ios): recognize and answer the localized Open in launch confirmation #4160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
232f6fc
1511ffb
36b85c9
81577da
cdba0c1
f862d9e
9435060
b5eddc6
af69d17
f1a8f05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: []) | ||
|
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]) { | ||
|
thymikee marked this conversation as resolved.
Resolved
|
||
| let placeholder = Self.appNamePlaceholder | ||
| self.wordings = wordings.compactMap { wording in | ||
|
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
|
||
| } | ||
|
|
||
| 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) | ||
|
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 { | ||
|
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 | ||
|
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 | ||
|
thymikee marked this conversation as resolved.
Resolved
|
||
| /// it keeps the permission prompts' vocabulary. | ||
| static let launchConfirmationTable = ( | ||
| path: "/System/Library/Frameworks/CoreServices.framework", | ||
| table: "SchemeApproval" | ||
| ) | ||
|
|
||
|
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
|
||
|
|
||
| /// 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 | ||
| } | ||
|
|
||
|
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
|
||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.