diff --git a/client/ios/App/App/AppDelegate.swift b/client/ios/App/App/AppDelegate.swift
index 6fd58372c..ce1e849c6 100644
--- a/client/ios/App/App/AppDelegate.swift
+++ b/client/ios/App/App/AppDelegate.swift
@@ -60,6 +60,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
+ // MARK: - Hardware keyboard
+
+ /// The UI's shortcuts for the screen on show (TrekKeyCommands), in the app's
+ /// menu: the overlay a held Command key brings up lists them there.
+ override func buildMenu(with builder: UIMenuBuilder) {
+ super.buildMenu(with: builder)
+ guard builder.system == .main, !TrekKeyCommands.current.isEmpty else { return }
+ let parent: UIMenu.Identifier = builder.menu(for: .application) != nil ? .application : .root
+ builder.insertChild(TrekKeyCommands.menu(), atStartOfMenu: parent)
+ }
+
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
diff --git a/client/ios/App/App/Info.plist b/client/ios/App/App/Info.plist
index db57f50f3..ba7090150 100644
--- a/client/ios/App/App/Info.plist
+++ b/client/ios/App/App/Info.plist
@@ -160,6 +160,8 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
+ UIApplicationSupportsIndirectInputEvents
+
UIViewControllerBasedStatusBarAppearance
diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift
index 07070021e..2c37e7982 100644
--- a/client/ios/App/App/TrekBridgeViewController.swift
+++ b/client/ios/App/App/TrekBridgeViewController.swift
@@ -50,6 +50,87 @@ class TrekBridgeViewController: CAPBridgeViewController {
// the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false
}
+
+ /// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
+ /// in the main menu, which sends their action up the responder chain from
+ /// the web view, and this controller is the first stop that answers it.
+ @objc func trekKeyCommand(_ sender: UIKeyCommand) {
+ guard let id = sender.propertyList as? String else { return }
+ TrekKeyCommands.onPress?(id)
+ }
+}
+
+// MARK: - Hardware keyboard (TrekShell.setKeyCommands)
+
+/// The UI's keyboard shortcuts as key commands in the app's main menu. There
+/// they show in the overlay a held Command key brings up and, on iPadOS 26, in
+/// the menu bar, under the names the UI gives them in its language. Each screen
+/// replaces the list (mobile/keyboard/MKeyboardHost.tsx), and the menu is
+/// rebuilt with it, so only what works here and now is ever offered.
+enum TrekKeyCommands {
+ struct Command {
+ let id: String
+ let title: String
+ let input: String
+ let modifiers: UIKeyModifierFlags
+
+ init?(raw: JSObject) {
+ guard let id = raw["id"] as? String, let title = raw["title"] as? String,
+ let input = raw["input"] as? String, !input.isEmpty else { return nil }
+ var flags: UIKeyModifierFlags = []
+ for name in raw["modifiers"] as? [String] ?? [] {
+ switch name {
+ case "command": flags.insert(.command)
+ case "shift": flags.insert(.shift)
+ case "option": flags.insert(.alternate)
+ case "control": flags.insert(.control)
+ default: break
+ }
+ }
+ self.id = id
+ self.title = title
+ self.input = Command.key(input)
+ self.modifiers = flags
+ }
+
+ private static func key(_ name: String) -> String {
+ switch name {
+ case "left": return UIKeyCommand.inputLeftArrow
+ case "right": return UIKeyCommand.inputRightArrow
+ case "up": return UIKeyCommand.inputUpArrow
+ case "down": return UIKeyCommand.inputDownArrow
+ case "escape": return UIKeyCommand.inputEscape
+ default: return name
+ }
+ }
+ }
+
+ static let menuIdentifier = UIMenu.Identifier("com.liketrek.trek.shortcuts")
+ private(set) static var current: [Command] = []
+ static var onPress: ((String) -> Void)?
+
+ static func set(_ commands: [Command]) {
+ current = commands
+ UIMenuSystem.main.setNeedsRebuild()
+ }
+
+ /// The commands as one menu, without a title of its own: it joins the app's
+ /// menu, where iPadOS lists the shortcuts under the app's name.
+ static func menu() -> UIMenu {
+ let children = current.map { command -> UIKeyCommand in
+ let key = UIKeyCommand(
+ title: command.title,
+ action: #selector(TrekBridgeViewController.trekKeyCommand(_:)),
+ input: command.input,
+ modifierFlags: command.modifiers,
+ propertyList: command.id
+ )
+ // ⌘[ and ⌘, belong to the app here, not to the web view's editing.
+ key.wantsPriorityOverSystemBehavior = true
+ return key
+ }
+ return UIMenu(title: "", identifier: menuIdentifier, options: .displayInline, children: children)
+ }
}
/// The remembered server and the offline copy of its start page.
diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift
index 2a5f7a06d..534288545 100644
--- a/client/ios/App/App/TrekShellPlugin.swift
+++ b/client/ios/App/App/TrekShellPlugin.swift
@@ -48,6 +48,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -57,7 +58,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// The native features by name, so the UI can turn each on by itself.
/// "watch" only where WatchConnectivity is (an iPhone, not an iPad), so the UI never builds a snapshot for nothing.
static var features: [String] {
- ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet"]
+ ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "wallet", "keyCommands"]
+ (TrekWatchLink.isAvailable ? ["watch"] : [])
}
@@ -72,6 +73,22 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
TrekStore.watchUpdates()
TrekWatchLink.shared.onAction = { [weak self] in self?.notifyListeners("watchAction", data: [:]) }
+ TrekKeyCommands.onPress = { [weak self] id in
+ self?.notifyListeners("keyCommand", data: ["id": id])
+ }
+ }
+
+ // MARK: - Hardware keyboard
+
+ /// The UI's keyboard shortcuts for the screen it shows (mobile/keyboard),
+ /// named in its language, as key commands in the app's menu.
+ @objc func setKeyCommands(_ call: CAPPluginCall) {
+ let raw = call.getArray("commands", JSObject.self) ?? []
+ let commands = raw.compactMap { TrekKeyCommands.Command(raw: $0) }
+ DispatchQueue.main.async {
+ TrekKeyCommands.set(commands)
+ call.resolve()
+ }
}
// MARK: - Widgets and the travel-day Live Activity