Launch handling and client reuse
Use this recipe when an installed single-window app should focus its existing client and
deliberately route a new launch. The
launch_handler manifest member
and Window.launchQueue have limited, experimental browser support. Unsupported browsers retain
their ordinary new-window or existing-window behavior.
Do not use client reuse as an authentication boundary. A target URL is external input even when the browser obtained it from an installed-app launch.
Choose a client mode
Add this opt-in member to public/manifest.webmanifest:
{
"launch_handler": {
"client_mode": ["focus-existing", "auto"]
}
}
The ordered array gives supporting browsers a fallback:
focus-existingfocuses an existing app window without navigating it. The app handles the target throughlaunchQueue; if no client exists, the browser opens one.navigate-existingfocuses and navigates an existing client. Use it only when replacing that client's current view is expected.navigate-newopens another client for every launch.autoleaves the choice to the browser and is the portable final fallback.
Lofi validates the optional member and rejects unknown or repeated modes. The starter omits it.
Register the consumer early
Create an island mounted from the root layout so every installed route can receive a queued launch:
import { useEffect, useState } from "preact/hooks";
import {
type InstalledAppLaunchIssue,
installInstalledAppLaunchConsumer,
} from "@nzip/lofi/recipes/launch-handler";
export default function LaunchRouter() {
const [supported, setSupported] = useState<boolean>();
const [issue, setIssue] = useState<InstalledAppLaunchIssue>();
useEffect(() => {
const consumer = installInstalledAppLaunchConsumer({
scope: new URL(import.meta.env.BASE_URL, location.origin),
onLaunch(target) {
// target.url is proven same-origin and inside the configured path scope.
const next = new URL(target.url);
history.replaceState(null, "", `${next.pathname}${next.search}${next.hash}`);
dispatchEvent(new PopStateEvent("popstate"));
},
onRejected: setIssue,
});
setSupported(consumer.supported);
return consumer.dispose;
}, []);
if (supported === false) return null; // ordinary browser launch is the fallback
if (issue) return <p role="alert">The requested app destination is not valid.</p>;
return null;
}
The browser retains launches until a consumer is registered, but mounting this island from only one leaf route would miss launches handled elsewhere in the app. The helper allows one active consumer per queue. A stale development/HMR cleanup cannot overwrite a newer generation, and disposing the current owner leaves a no-op consumer.
The helper rejects missing or malformed targets, credential-bearing URLs, other origins, and paths outside the exact configured scope. Rejection reasons contain no received URL. Product routing must still decide whether the in-scope view exists and whether the current user may see it.
Install, update, and fallback
Manifest launch behavior may not update immediately on every platform. Test both a fresh install and
an existing installation after changing client_mode; some platforms require reinstalling before
the OS launch behavior changes. Removing launch_handler restores browser-selected launch behavior
without changing lofi data.
Do not force focus or close duplicate windows in unsupported browsers. Ordinary links, shortcuts, and direct navigations remain the complete fallback.
Verification checklist
- Launch with a valid same-origin URL inside the deployment base and route the existing client.
- Reject another origin, an out-of-scope path, a credential-bearing URL, and malformed input with a fixed message that does not echo the target.
- Confirm a browser without
launchQueuefollows its ordinary launch behavior without an error. - Register a new development generation, dispose the older one, and verify the new consumer remains active.
- Test fresh install, existing install, update, offline launch, and removal of the manifest member.