Files
readest/apps/readest-app/src/hooks/useAppUrlIngress.ts
T
loveheaven 9180767ba4 fix(android): deliver Open-with intents reliably on cold start and re-mount (#4527)
Tapping an EPUB in the system file browser and choosing Readest could
silently fail to open the book in two distinct scenarios on Android:

1. Cold launch — the system delivers the ACTION_VIEW intent to
   onCreate / onNewIntent before the JS layer has finished hydrating
   and called addPluginListener('native-bridge', 'shared-intent', ...).
   The upstream Tauri Plugin.trigger() drops events when the per-event
   listener list is empty, so the intent vanishes. Fix this in
   NativeBridgePlugin by queueing emits whose event has no listener,
   then overriding registerListener so the queue is drained whenever
   a listener becomes available.

2. React strict-mode re-mount — useAppUrlIngress had a one-shot
   listened.current ref guard meant to avoid double registration. In
   strict mode (and any subsequent effect re-run) the cleanup
   unregister()'d the underlying native plugin listener but the next
   mount short-circuited on the ref and never re-registered. The
   shared-intent listener list ended up empty for the rest of the
   session, so any subsequent Open-with intent went into the queue and
   never came out. Drop the guard and let the effect register on every
   mount; cleanup balances each registration.
2026-06-10 19:27:56 +02:00

123 lines
4.8 KiB
TypeScript

import { useEffect } from 'react';
import { addPluginListener, PluginListener } from '@tauri-apps/api/core';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { useEnv } from '@/context/EnvContext';
import { isTauriAppPlatform } from '@/services/environment';
import { eventDispatcher } from '@/utils/event';
interface SingleInstancePayload {
args: string[];
cwd: string;
}
interface OpenFilesPayload {
files: string[];
}
interface SharedIntentPayload {
urls: string[];
/**
* Android-only. Distinguishes "Open with Readest" (`VIEW` — the user
* tapped a file in their file browser and chose Readest) from "Send to
* Readest" (`SEND` / `SEND_MULTIPLE` — share-sheet capture). We forward
* it on the `app-incoming-url` event so consumers can pick the right
* import strategy: VIEW should open the file directly without writing
* it to the library, SEND should ingest it like a sync capture.
*/
action?: 'VIEW' | 'SEND';
}
/**
* Single ingress point for incoming URLs from the operating system.
*
* Subscribes to every Tauri channel that can deliver a URL on any platform:
* - `single-instance` event — Win/Linux deep link, macOS open-file
* - `open-files` event — macOS in-app open-files
* - `shared-intent` plugin — Android "Share to Readest" intent
* - `onOpenUrl` — iOS / Android / macOS via Tauri v2
*
* Re-broadcasts every URL list as the `app-incoming-url` event. Consumers
* subscribe to the event instead of the underlying channels, which:
* - decouples them from platform specifics
* - sidesteps a Tauri Android quirk where multiple `onOpenUrl`
* subscribers don't all fire
* - keeps the channel-subscription code in exactly one place
*
* Existing consumers:
* - `useOpenWithBooks` — file imports
* - `useOpenAnnotationLink` — annotation deep links
*
* Cold-start URLs (`getCurrent()`) are intentionally NOT read here. Cold-
* start handling is consumer-specific (a launching file goes through the
* library init flow; an annotation jumps the reader), so each consumer
* reads `getCurrent()` itself when it needs to.
*/
export function useAppUrlIngress() {
const { appService } = useEnv();
useEffect(() => {
if (!isTauriAppPlatform() || !appService) return;
// Note: removed an old `listened.current` ref guard that tried to
// make this effect a one-shot. In React strict mode (dev) the effect
// mounts → cleans up → mounts again. With the guard, the second
// mount short-circuited (ref still true) and DID NOT re-register
// any listeners — but the previous cleanup had already
// `unregister()`ed the underlying native plugin listener
// (NativeBridgePlugin's listeners["shared-intent"] map). Net result:
// the app ended up with zero shared-intent listeners on the native
// side, so any "Open with Readest" intent that arrived AFTER cold
// start was silently dropped (event got queued by our pending-events
// workaround but, with no future register call, never replayed).
// Letting the effect re-run on every mount cycle keeps the JS-side
// and native-side listener bookkeeping in lockstep.
const dispatch = (urls: string[], action?: 'VIEW' | 'SEND') => {
if (!urls.length) return;
console.log('App incoming URL:', urls, 'action:', action);
eventDispatcher.dispatch('app-incoming-url', { urls, action });
};
const unlistenSingleInstance = getCurrentWindow().listen<SingleInstancePayload>(
'single-instance',
({ payload }) => {
const url = payload.args?.[1];
if (url) dispatch([url]);
},
);
const unlistenOpenFiles = getCurrentWindow().listen<OpenFilesPayload>(
'open-files',
({ payload }) => {
if (payload.files?.length) dispatch(payload.files);
},
);
// FIXME: register/unregister of this plugin listener has caused freezes
// on iOS in the past, so it's gated to Android. The Tauri v2 onOpenUrl
// listener below covers iOS.
let unlistenSharedIntent: Promise<PluginListener> | null = null;
if (appService?.isAndroidApp) {
unlistenSharedIntent = addPluginListener<SharedIntentPayload>(
'native-bridge',
'shared-intent',
(payload) => {
if (payload.urls?.length) dispatch(payload.urls, payload.action);
},
);
}
const unlistenOpenUrl = onOpenUrl((urls) => {
if (urls?.length) dispatch(urls);
});
return () => {
unlistenSingleInstance.then((f) => f());
unlistenOpenFiles.then((f) => f());
unlistenOpenUrl.then((f) => f());
unlistenSharedIntent?.then((f) => f.unregister());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
}