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.
This commit is contained in:
Maurice
2026-11-13 08:27:23 +01:00
parent d6a6b49b6b
commit fdb2095ced
5 changed files with 64 additions and 14 deletions
+29 -2
View File
@@ -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/<path>: 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/<path>, 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 }
+3 -2
View File
@@ -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) }
}
}
+15 -1
View File
@@ -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' });
+12 -7
View File
@@ -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/<path>.
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/<path>. 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).
+5 -2
View File
@@ -211,9 +211,12 @@ export interface TrekShellPlugin {
): Promise<{ remove: () => Promise<void> }>;
/** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */
setQuickActions(options: { items: NativeQuickAction[] }): Promise<void>;
/** 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