Files
Kenichi Saito 236016ed8a fix(ios): support remote-hosted alerts on physical devices (#1232)
* fix(ios): probe remote-hosted system modals (AccessorySetupKit picker) when the springboard mirror yields no hittable actions

* fix(ios): fail closed on host state, guard dismissal re-query, unit-test probe routing

Addresses review on #1232:
- Gate the remote-host probe to a foreground host
  (RemoteHostedSystemModalPolicy.isEligibleHostState); background/unknown hosts
  fail closed instead of substituting an unrelated action tree.
- Wrap the alert-resolution fallback query in safeElementsQuery so a dismissed
  remote host raising kAXErrorServerNotFound is absorbed.
- Extract routing/gating into RemoteHostedSystemModalPolicy and add
  simulator-free unit tests under AGENT_DEVICE_RUNNER_UNIT_TESTS.

* refactor(ios): centralize blocking system modal resolution

* fix(ios): bound alert dismissal rechecks

* feat(ios): enable alerts on physical devices

* fix(ios): bound alert system modal resolution

* test(ios): add AccessorySetupKit picker fixture

* fix(ios): validate remote-hosted system modal interactions

* chore: keep pnpm checks non-interactive

* fix(ios): share alert command deadline

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-14 17:19:17 +02:00

113 lines
3.1 KiB
Swift

import AccessorySetupKit
import CoreBluetooth
import ExpoModulesCore
import UIKit
public final class AccessorySetupLabModule: Module {
public func definition() -> ModuleDefinition {
Name("AccessorySetupLab")
AsyncFunction("showPickerAsync") { (promise: Promise) in
guard #available(iOS 18.0, *) else {
promise.reject(
Exception(
name: "UnsupportedOperation",
description: "AccessorySetupKit requires iOS 18 or later."
)
)
return
}
AccessorySetupController.shared.showPicker(promise: promise)
}.runOnQueue(.main)
}
}
@available(iOS 18.0, *)
private final class AccessorySetupController {
static let shared = AccessorySetupController()
private let session = ASAccessorySession()
private var isActivated = false
private var isPickerPresented = false
func showPicker(promise: Promise) {
guard !isPickerPresented else {
promise.reject(
Exception(
name: "PickerAlreadyPresented",
description: "The accessory picker is already presented."
)
)
return
}
guard
let serviceUuid = firstInfoPlistString(forKey: "NSAccessorySetupBluetoothServices")
else {
promise.reject(
Exception(
name: "MissingAccessoryService",
description:
"The development client is missing its AccessorySetupKit test service configuration."
)
)
return
}
let descriptor = ASDiscoveryDescriptor()
descriptor.bluetoothServiceUUID = CBUUID(string: serviceUuid)
descriptor.bluetoothNameSubstring = firstInfoPlistString(
forKey: "NSAccessorySetupBluetoothNames"
)
let productImage = UIImage(
systemName: "dot.radiowaves.left.and.right",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .regular)
) ?? UIImage()
let displayItem = ASPickerDisplayItem(
name: descriptor.bluetoothNameSubstring ?? "Test accessory",
productImage: productImage,
descriptor: descriptor
)
if #available(iOS 26.0, *) {
let settings = ASPickerDisplaySettings.default
settings.discoveryTimeout = .short
session.pickerDisplaySettings = settings
}
let presentPicker = { [weak self] in
guard let self else { return }
session.showPicker(for: [displayItem]) { [weak self] error in
self?.isPickerPresented = false
if let error {
promise.reject(
Exception(name: "AccessoryPickerFailed", description: error.localizedDescription)
)
} else {
promise.resolve(nil)
}
}
}
isPickerPresented = true
if isActivated {
presentPicker()
return
}
session.activate(on: .main) { [weak self] event in
guard let self, event.eventType == .activated else { return }
isActivated = true
presentPicker()
}
}
private func firstInfoPlistString(forKey key: String) -> String? {
let values = Bundle.main.object(forInfoDictionaryKey: key) as? [String]
return values?.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
}
}