1/**2 * Opt-in helpers for installed-app launch handling and safe client reuse.3 *4 * @module5 */67/** Launch data delivered by a browser `LaunchQueue`. */8export type InstalledAppLaunchParameters = {9 targetURL?: string;10 files?: readonly FileSystemHandle[];11};1213/** Minimal browser launch-queue seam used by the recipe. */14export type InstalledAppLaunchQueue = {15 setConsumer(consumer: (parameters: InstalledAppLaunchParameters) => void): void;16};1718/** A parsed launch URL proven to stay inside the configured application scope. */19export type InstalledAppLaunchTarget = {20 url: string;21};2223/** Stable rejection that never contains the received launch URL. */24export type InstalledAppLaunchIssue =25 | "missing-target"26 | "invalid-target"27 | "credentialed-target"28 | "outside-origin"29 | "outside-scope";3031/** Accepted in-scope launch target or value-free rejection. */32export type InstalledAppLaunchResult =33 | { ok: true; target: InstalledAppLaunchTarget }34 | { ok: false; issue: InstalledAppLaunchIssue };3536/** Options for registering one launch-queue consumer. */37export type InstallLaunchConsumerOptions = {38 /** Absolute application scope URL, normally derived from `import.meta.env.BASE_URL`. */39 scope: string | URL;40 /** Override the browser queue for tests. */41 queue?: InstalledAppLaunchQueue;42 /** Receives only parsed same-origin, in-scope launch targets. */43 onLaunch(target: InstalledAppLaunchTarget): void;44 /** Receives a value-free reason for rejected external input. */45 onRejected?(issue: InstalledAppLaunchIssue): void;46};4748/** Result of feature-detecting and registering the launch consumer. */49export type InstalledLaunchConsumer = {50 supported: boolean;51 dispose(): void;52};5354/** Options for registering one raw browser launch-queue consumer. */55export type InstallLaunchQueueConsumerOptions = {56 /** Override the browser queue for tests. */57 queue?: InstalledAppLaunchQueue;58 /** Receives browser launch parameters without trusting or interpreting them. */59 onLaunch(parameters: InstalledAppLaunchParameters): void;60};6162const owners = new WeakMap<InstalledAppLaunchQueue, symbol>();6364function configuredScope(value: string | URL): URL {65 const scope = value instanceof URL ? new URL(value.href) : new URL(value);66 if (67 scope.protocol !== "https:" && scope.protocol !== "http:" || scope.username ||68 scope.password || scope.search || scope.hash || !scope.pathname.endsWith("/")69 ) {70 throw new TypeError(71 "launch scope must be an absolute HTTP(S) URL without credentials, query, or fragment and with a trailing slash",72 );73 }74 return scope;75}7677function browserLaunchQueue(): InstalledAppLaunchQueue | undefined {78 return (globalThis as typeof globalThis & { launchQueue?: InstalledAppLaunchQueue }).launchQueue;79}8081/** Parse one browser-provided launch URL against an exact origin and path scope. */82export function parseInstalledAppLaunchTarget(83 targetURL: string | undefined,84 scopeValue: string | URL,85): InstalledAppLaunchResult {86 if (!targetURL) return { ok: false, issue: "missing-target" };87 const scope = configuredScope(scopeValue);88 let target: URL;89 try {90 target = new URL(targetURL);91 } catch {92 return { ok: false, issue: "invalid-target" };93 }94 if (target.username || target.password) return { ok: false, issue: "credentialed-target" };95 if (target.origin !== scope.origin) return { ok: false, issue: "outside-origin" };96 if (!target.pathname.startsWith(scope.pathname)) {97 return { ok: false, issue: "outside-scope" };98 }99 return { ok: true, target: { url: target.href } };100}101102/**103 * Feature-detect and install one early launch-queue consumer.104 *105 * The most recently installed consumer owns the queue. Disposing an older106 * development/HMR generation cannot replace the newer consumer; disposing the107 * current owner installs a no-op consumer so stale callbacks stop handling108 * launches.109 */110export function installInstalledAppLaunchConsumer(111 options: InstallLaunchConsumerOptions,112): InstalledLaunchConsumer {113 const scope = configuredScope(options.scope);114 return installInstalledAppLaunchQueueConsumer({115 queue: options.queue,116 onLaunch(parameters) {117 const result = parseInstalledAppLaunchTarget(parameters.targetURL, scope);118 if (result.ok) options.onLaunch(result.target);119 else options.onRejected?.(result.issue);120 },121 });122}123124/**125 * Feature-detect and install one raw launch-queue consumer.126 *127 * This is the composition seam used by recipes such as file handling. The most128 * recently installed consumer owns the queue, including across different lofi129 * launch recipes. Treat every delivered parameter as untrusted input.130 */131export function installInstalledAppLaunchQueueConsumer(132 options: InstallLaunchQueueConsumerOptions,133): InstalledLaunchConsumer {134 const queue = options.queue ?? browserLaunchQueue();135 if (!queue) return { supported: false, dispose() {} };136 const owner = Symbol("lofi-launch-consumer");137 let active = true;138 owners.set(queue, owner);139 queue.setConsumer((parameters) => {140 if (!active || owners.get(queue) !== owner) return;141 options.onLaunch(parameters);142 });143 return {144 supported: true,145 dispose() {146 if (!active) return;147 active = false;148 if (owners.get(queue) !== owner) return;149 owners.delete(queue);150 queue.setConsumer(() => {});151 },152 };153}