From fdb2095ced33bd1334908c526de61d17c687345a Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 08:27:23 +0100 Subject: [PATCH] fix(native): a widget tap opens its page every time, also after iOS ended the page A widget or Live Activity tap reached the UI as Capacitor's appUrlOpen, or as the launch URL. When iOS had ended the web view's process in the background, the event went to the old page's listeners and was lost, and the reloaded page found the same link in getLaunchUrl that it had already followed once and skipped it: the second tap on the packing widget opened the start page. The binary now takes com.liketrek.trek://navigate links itself and hands them over like a quick action, held until the UI takes it and kept for a page that is still loading. It reports the feature appLinks, and the UI only follows the link through Capacitor for binaries without it. --- client/ios/App/App/SceneDelegate.swift | 31 ++++++++++++++++++++++-- client/ios/App/App/TrekShellPlugin.swift | 5 ++-- client/src/native/nativeApp.test.ts | 16 +++++++++++- client/src/native/nativeApp.ts | 19 +++++++++------ client/src/native/trekShell.ts | 7 ++++-- 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/client/ios/App/App/SceneDelegate.swift b/client/ios/App/App/SceneDelegate.swift index 2666a7df0..4dfa23c5b 100644 --- a/client/ios/App/App/SceneDelegate.swift +++ b/client/ios/App/App/SceneDelegate.swift @@ -17,6 +17,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { for context in connectionOptions.urlContexts where context.url.isFileURL { TrekSharedFiles.receive(context.url) } + // Launched by a tap on a widget or a Live Activity: held like a quick action. + // Capacitor still hears of the link, the UI ignores it there (feature appLinks). + for context in connectionOptions.urlContexts { + if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) } + } SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) } @@ -40,7 +45,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // com.liketrek.trek://share: the share sheet left an item for the import. let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" } if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() } - let others = URLContexts.subtracting(files).subtracting(handoffs) + // com.liketrek.trek://navigate/: a widget or Live Activity tap. Through + // Capacitor it was an event a page reloading after the system ended its process + // never heard, and that page then took the same link for one already followed. + let appLinks = URLContexts.filter { TrekQuickActions.appLinkPath($0.url) != nil } + for context in appLinks { + if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) } + } + let others = URLContexts.subtracting(files).subtracting(handoffs).subtracting(appLinks) if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) } } @@ -104,10 +116,25 @@ enum TrekQuickActions { static func open(_ item: UIApplicationShortcutItem) -> Bool { guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false } - if let onOpen { onOpen(path) } else { pending = path } + open(path: path) return true } + /// A path to open: at once when the UI listens, else held for it to take. + /// Widget and Live Activity taps come this way too, so each is followed once. + static func open(path: String) { + if let onOpen { onOpen(path) } else { pending = path } + } + + /// The path in com.liketrek.trek://navigate/, the link widgets and Live Activities open. + static func appLinkPath(_ url: URL) -> String? { + let prefix = "com.liketrek.trek://navigate" + guard url.absoluteString.hasPrefix(prefix) else { return nil } + let path = String(url.absoluteString.dropFirst(prefix.count)) + if path.isEmpty { return "/" } + return path.hasPrefix("/") ? path : nil + } + /// The action the app was launched with, once. static func take() -> String? { defer { pending = nil } diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index e022ee418..b7681e96b 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -69,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an /// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing. static var features: [String] { - var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"] + var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "appLinks", "clientCertificate", "filePreview"] if canAddWalletPasses { names.append("wallet") } if TrekWatchLink.isAvailable { names.append("watch") } return names @@ -94,8 +94,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr TrekKeyCommands.onPress = { [weak self] id in self?.notifyListeners("keyCommand", data: ["id": id]) } + // Kept until a listener takes it: a tap while the page reloads reaches the new page. TrekQuickActions.onOpen = { [weak self] path in - DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) } + DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path], retainUntilConsumed: true) } } } diff --git a/client/src/native/nativeApp.test.ts b/client/src/native/nativeApp.test.ts index 219c53a63..5cdd8c307 100644 --- a/client/src/native/nativeApp.test.ts +++ b/client/src/native/nativeApp.test.ts @@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { closeTopOverlay } from '../mobile/overlayStack'; import { isEffectivelyOffline } from '../sync/networkMode'; -import { checkAppUpdate } from './capabilities'; +import { checkAppUpdate, hasNativeFeature } from './capabilities'; import { installNativeGeolocation } from './geolocation'; import { prepareKeyboardFields } from './keyboardFields'; import { scrollToTop, startNativeApp } from './nativeApp'; @@ -28,6 +28,7 @@ vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() })); vi.mock('./capabilities', () => ({ checkAppUpdate: vi.fn(async () => false), loadNativeCapabilities: vi.fn(async () => ({})), + hasNativeFeature: vi.fn(() => false), })); vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() })); vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) })); @@ -51,6 +52,7 @@ beforeEach(() => { vi.mocked(isNativeIos).mockReturnValue(false); vi.mocked(isEffectivelyOffline).mockReturnValue(false); vi.mocked(keepOfflineShellFresh).mockResolvedValue(); + vi.mocked(hasNativeFeature).mockReturnValue(false); document.documentElement.classList.remove('dark'); document.documentElement.removeAttribute('data-native-app'); document.documentElement.removeAttribute('data-native-ios'); @@ -210,6 +212,18 @@ describe('startNativeApp', () => { expect(openPushTarget).not.toHaveBeenCalled(); }); + it('leaves widget links to the binary when it hands them over as quick actions', async () => { + sessionStorage.clear(); + vi.mocked(hasNativeFeature).mockImplementation((feature) => feature === 'appLinks'); + vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'com.liketrek.trek://navigate/trips/5?tab=listen' }); + startNativeApp(); + await vi.waitFor(() => expect(hasNativeFeature).toHaveBeenCalledWith('appLinks')); + await Promise.resolve(); + expect(App.getLaunchUrl).not.toHaveBeenCalled(); + expect(vi.mocked(App.addListener).mock.calls.some(([name]) => name === 'appUrlOpen')).toBe(false); + expect(openPushTarget).not.toHaveBeenCalled(); + }); + it('leaves the start page alone for a launch without a TREK link', async () => { sessionStorage.clear(); vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'https://example.com/elsewhere' }); diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index adec0cf89..a1d96762a 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar'; import { closeTopOverlay } from '../mobile/overlayStack'; import { useSettingsStore } from '../store/settingsStore'; import { isEffectivelyOffline } from '../sync/networkMode'; -import { checkAppUpdate, loadNativeCapabilities } from './capabilities'; +import { checkAppUpdate, hasNativeFeature, loadNativeCapabilities } from './capabilities'; import { installNativeGeolocation } from './geolocation'; import { fadeInImages } from './imageFade'; import { keepFieldInView } from './keepFieldInView'; @@ -54,13 +54,18 @@ export function startNativeApp(): void { // A quick action on the app icon opens its page, also the one the app was launched with. void loadNativeCapabilities().then(() => startQuickActions()); - // A widget or Live Activity tap opens com.liketrek.trek://navigate/. - void App.addListener('appUrlOpen', ({ url }) => { - const prefix = `${APP_URL_SCHEME}://navigate`; - if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/'); + // A widget or Live Activity tap opens com.liketrek.trek://navigate/. A + // binary with the feature appLinks hands it over as a quick action instead, + // held until taken, so it is only followed here for older binaries. + void loadNativeCapabilities().then(() => { + if (hasNativeFeature('appLinks')) return; + void App.addListener('appUrlOpen', ({ url }) => { + const prefix = `${APP_URL_SCHEME}://navigate`; + if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/'); + }); + // A tap that launched the app comes as the launch URL, not as appUrlOpen. + void openLaunchLink(); }); - // A tap that launched the app comes as the launch URL, not as appUrlOpen. - void openLaunchLink(); // A tap on the status bar scrolls the list under it back to the top, as in // every iOS app (the status-bar plugin fires statusTap on iOS). diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 23319ddc2..dac4c60b7 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -211,9 +211,12 @@ export interface TrekShellPlugin { ): Promise<{ remove: () => Promise }>; /** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */ setQuickActions(options: { items: NativeQuickAction[] }): Promise; - /** Feature quickActions: the action the app was launched with, before the UI ran. */ + /** + * Feature quickActions: the action the app was launched with, before the UI ran. + * With the feature appLinks also a widget or Live Activity tap, as its path. + */ takeQuickAction(): Promise<{ path: string | null }>; - /** Feature quickActions: an action chosen while the app was running. */ + /** Feature quickActions: an action chosen (with appLinks: a widget tapped) while the app was running. */ addListener( event: 'quickAction', listener: (payload: { path: string }) => void