clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(pwa): add launch handler recipe

Dami committed b425ace
Add a safe launchQueue consumer with exact origin/scope validation and HMR ownership, validate optional launch_handler metadata, document client-mode tradeoffs, and cover supported, unsupported, rejected, and offline browser launches.
commit
b425acec526e75cc9756cce016de046e795f61a4
parent
4ffbb3d5cc9382888b722acb872d5001e21825a1
author
Dami <dami.etoile@gmail.com>
17 files changed, +569 -15
Mapps/reference/deno.json1+
Mdeno.json1+
Mdocs/deployment.md3+-
Mdocs/getting-started.md19++--
Mdocs/recipes/README.md2+
Adocs/recipes/launch-handler.md98+++++++++++++++++++++
Mpackage/astro/mod.ts15+++-
Mpackage/astro/mod_test.ts1+
Mpackage/create_core.ts4+
Mpackage/create_core_test.ts9++-
Apackage/recipes/launch-handler.ts127++++++++++++++++++++++++++++
Apackage/recipes/launch-handler_test.ts99++++++++++++++++++++++
Mpackage/testdata/starter.snapshot.json2+-
Mpackage/tooling/pwa-validation.ts41+++++++++
Mpackage/tooling/pwa-validation_test.ts23+++++
Mtools/doc_lint.ts1+
Mtools/golden.ts138++++++++++++++++++++++++++++++-
M apps/reference/deno.json +1 -0
M deno.json +1 -0
M docs/deployment.md +2 -1
M docs/getting-started.md +10 -9
@@ -62,15 +62,16 @@ allowing framework fixes to arrive through a package upgrade.
6262 6363 The final package map is intentionally small:6464 65-| Import | Supported use |66-| ------------------------------ | --------------------------------------------------- |67-| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68-| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69-| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70-| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71-| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |72-| `@nzip/lofi/testing` | Local-first Playwright helpers |73-| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |65+| Import | Supported use |66+| ----------------------------------- | --------------------------------------------------- |67+| `@nzip/lofi` | App configuration, runtime, session, stores, PWA |68+| `@nzip/lofi/access` | Private, direct-share, and fixed-role group helpers |69+| `@nzip/lofi/preact` | Package-owned Preact hooks and diagnostic UI |70+| `@nzip/lofi/astro` | Package-owned Astro/Vite integration |71+| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |72+| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |73+| `@nzip/lofi/testing` | Local-first Playwright helpers |74+| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |7475 7576 All of these imports resolve through the single version pinned in `deno.json`. Upgrade that pin to7677 receive framework fixes; do not copy package files into `src/` or import unpublished internal paths.
M docs/recipes/README.md +2 -0
A docs/recipes/launch-handler.md +98 -0
@@ -0,0 +1,98 @@
1+# Launch handling and client reuse2+3+Use this recipe when an installed single-window app should focus its existing client and4+deliberately route a new launch. The5+[`launch_handler` manifest member](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/launch_handler)6+and `Window.launchQueue` have limited, experimental browser support. Unsupported browsers retain7+their ordinary new-window or existing-window behavior.8+9+Do not use client reuse as an authentication boundary. A target URL is external input even when the10+browser obtained it from an installed-app launch.11+12+## Choose a client mode13+14+Add this opt-in member to `public/manifest.webmanifest`:15+16+```json17+{18+ "launch_handler": {19+ "client_mode": ["focus-existing", "auto"]20+ }21+}22+```23+24+The ordered array gives supporting browsers a fallback:25+26+- `focus-existing` focuses an existing app window without navigating it. The app handles the target27+ through `launchQueue`; if no client exists, the browser opens one.28+- `navigate-existing` focuses and navigates an existing client. Use it only when replacing that29+ client's current view is expected.30+- `navigate-new` opens another client for every launch.31+- `auto` leaves the choice to the browser and is the portable final fallback.32+33+Lofi validates the optional member and rejects unknown or repeated modes. The starter omits it.34+35+## Register the consumer early36+37+Create an island mounted from the root layout so every installed route can receive a queued launch:38+39+```tsx40+import { useEffect, useState } from "preact/hooks";41+import {42+ type InstalledAppLaunchIssue,43+ installInstalledAppLaunchConsumer,44+} from "@nzip/lofi/recipes/launch-handler";45+46+export default function LaunchRouter() {47+ const [supported, setSupported] = useState<boolean>();48+ const [issue, setIssue] = useState<InstalledAppLaunchIssue>();49+50+ useEffect(() => {51+ const consumer = installInstalledAppLaunchConsumer({52+ scope: new URL(import.meta.env.BASE_URL, location.origin),53+ onLaunch(target) {54+ // target.url is proven same-origin and inside the configured path scope.55+ const next = new URL(target.url);56+ history.replaceState(null, "", `${next.pathname}${next.search}${next.hash}`);57+ dispatchEvent(new PopStateEvent("popstate"));58+ },59+ onRejected: setIssue,60+ });61+ setSupported(consumer.supported);62+ return consumer.dispose;63+ }, []);64+65+ if (supported === false) return null; // ordinary browser launch is the fallback66+ if (issue) return <p role="alert">The requested app destination is not valid.</p>;67+ return null;68+}69+```70+71+The browser retains launches until a consumer is registered, but mounting this island from only one72+leaf route would miss launches handled elsewhere in the app. The helper allows one active consumer73+per queue. A stale development/HMR cleanup cannot overwrite a newer generation, and disposing the74+current owner leaves a no-op consumer.75+76+The helper rejects missing or malformed targets, credential-bearing URLs, other origins, and paths77+outside the exact configured scope. Rejection reasons contain no received URL. Product routing must78+still decide whether the in-scope view exists and whether the current user may see it.79+80+## Install, update, and fallback81+82+Manifest launch behavior may not update immediately on every platform. Test both a fresh install and83+an existing installation after changing `client_mode`; some platforms require reinstalling before84+the OS launch behavior changes. Removing `launch_handler` restores browser-selected launch behavior85+without changing lofi data.86+87+Do not force focus or close duplicate windows in unsupported browsers. Ordinary links, shortcuts,88+and direct navigations remain the complete fallback.89+90+## Verification checklist91+92+1. Launch with a valid same-origin URL inside the deployment base and route the existing client.93+2. Reject another origin, an out-of-scope path, a credential-bearing URL, and malformed input with a94+ fixed message that does not echo the target.95+3. Confirm a browser without `launchQueue` follows its ordinary launch behavior without an error.96+4. Register a new development generation, dispose the older one, and verify the new consumer remains97+ active.98+5. Test fresh install, existing install, update, offline launch, and removal of the manifest member.
M package/astro/mod.ts +14 -1
@@ -61,7 +61,7 @@ const preactFiles = [
6161 "use-device-capabilities.ts",6262 ] as const;6363 64-const recipeFiles = ["web-share.ts"] as const;64+const recipeFiles = ["launch-handler.ts", "web-share.ts"] as const;6565 6666 async function readPackageFile(path: string): Promise<string> {6767 const url = new URL(path, import.meta.url);
@@ -77,6 +77,13 @@ function renderConfig(projectRoot: string): string {
7777 const runtimeEntry = join(projectRoot, ".lofi", "package", "runtime", "mod.ts");7878 const accessEntry = join(projectRoot, ".lofi", "package", "access", "mod.ts");7979 const preactEntry = join(projectRoot, ".lofi", "package", "preact", "mod.ts");80+ const launchHandlerRecipe = join(81+ projectRoot,82+ ".lofi",83+ "package",84+ "recipes",85+ "launch-handler.ts",86+ );8087 const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");8188 return `import preact from "@astrojs/preact";8289 import { defineConfig } from "astro/config";
@@ -117,6 +124,9 @@ export default defineConfig({
117124 },118125 resolve: {119126 alias: [127+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/launch-handler$/, replacement: ${128+ JSON.stringify(launchHandlerRecipe)129+ } },120130 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${121131 JSON.stringify(webShareRecipe)122132 } },
@@ -130,6 +140,9 @@ export default defineConfig({
130140 { find: /^npm:jazz-tools@[^/]+\\/passkey-backup$/, replacement: "jazz-tools/passkey-backup" },131141 { find: /^npm:jazz-tools@[^/]+\\/passphrase$/, replacement: "jazz-tools/passphrase" },132142 { find: /^npm:jazz-tools@[^/]+$/, replacement: "jazz-tools" },143+ { find: "@nzip/lofi/recipes/launch-handler", replacement: ${144+ JSON.stringify(launchHandlerRecipe)145+ } },133146 { find: "@nzip/lofi/recipes/web-share", replacement: ${JSON.stringify(webShareRecipe)} },134147 { find: "@nzip/lofi/preact", replacement: ${JSON.stringify(preactEntry)} },135148 { find: "@nzip/lofi/access", replacement: ${JSON.stringify(accessEntry)} },
M package/astro/mod_test.ts +1 -0
M package/create_core.ts +4 -0
M package/create_core_test.ts +7 -2
@@ -106,8 +106,8 @@ Deno.test("createProject materializes the complete starter snapshot", async () =
106106 .filter(([name]) => name.startsWith("@nzip/lofi/"))107107 .map(([, specifier]) => String(specifier));108108 assert(109- lofiSpecifiers.length === 12,110- `expected one package prefix, access, two integrations, six commands, one recipe, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 13,110+ `expected one package prefix, access, two integrations, six commands, two recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.3/")),
@@ -151,6 +151,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
151151 "./preact",152152 "./preview",153153 "./provision",154+ "./recipes/launch-handler",154155 "./recipes/web-share",155156 "./test",156157 "./testing",
@@ -159,6 +160,10 @@ Deno.test("package manifest and generated version stay coupled", () => {
159160 assertEquals(packageManifest.exports["./access"], "./package/access/mod.ts");160161 assertEquals(packageManifest.exports["./astro"], "./package/astro/mod.ts");161162 assertEquals(packageManifest.exports["./preact"], "./package/preact/mod.ts");163+ assertEquals(164+ packageManifest.exports["./recipes/launch-handler"],165+ "./package/recipes/launch-handler.ts",166+ );162167 assertEquals(163168 packageManifest.exports["./recipes/web-share"],164169 "./package/recipes/web-share.ts",
A package/recipes/launch-handler.ts +127 -0
@@ -0,0 +1,127 @@
1+/**2+ * Opt-in helpers for installed-app launch handling and safe client reuse.3+ *4+ * @module5+ */6+7+/** Launch data delivered by a browser `LaunchQueue`. */8+export type InstalledAppLaunchParameters = {9+ targetURL?: string;10+ files?: readonly FileSystemHandle[];11+};12+13+/** Minimal browser launch-queue seam used by the recipe. */14+export type InstalledAppLaunchQueue = {15+ setConsumer(consumer: (parameters: InstalledAppLaunchParameters) => void): void;16+};17+18+/** A parsed launch URL proven to stay inside the configured application scope. */19+export type InstalledAppLaunchTarget = {20+ url: string;21+};22+23+/** Stable rejection that never contains the received launch URL. */24+export type InstalledAppLaunchIssue =25+ | "missing-target"26+ | "invalid-target"27+ | "credentialed-target"28+ | "outside-origin"29+ | "outside-scope";30+31+/** Accepted in-scope launch target or value-free rejection. */32+export type InstalledAppLaunchResult =33+ | { ok: true; target: InstalledAppLaunchTarget }34+ | { ok: false; issue: InstalledAppLaunchIssue };35+36+/** Options for registering one launch-queue consumer. */37+export 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+};47+48+/** Result of feature-detecting and registering the launch consumer. */49+export type InstalledLaunchConsumer = {50+ supported: boolean;51+ dispose(): void;52+};53+54+const owners = new WeakMap<InstalledAppLaunchQueue, symbol>();55+56+function configuredScope(value: string | URL): URL {57+ const scope = value instanceof URL ? new URL(value.href) : new URL(value);58+ if (59+ scope.protocol !== "https:" && scope.protocol !== "http:" || scope.username ||60+ scope.password || scope.search || scope.hash || !scope.pathname.endsWith("/")61+ ) {62+ throw new TypeError(63+ "launch scope must be an absolute HTTP(S) URL without credentials, query, or fragment and with a trailing slash",64+ );65+ }66+ return scope;67+}68+69+function browserLaunchQueue(): InstalledAppLaunchQueue | undefined {70+ return (globalThis as typeof globalThis & { launchQueue?: InstalledAppLaunchQueue }).launchQueue;71+}72+73+/** Parse one browser-provided launch URL against an exact origin and path scope. */74+export function parseInstalledAppLaunchTarget(75+ targetURL: string | undefined,76+ scopeValue: string | URL,77+): InstalledAppLaunchResult {78+ if (!targetURL) return { ok: false, issue: "missing-target" };79+ const scope = configuredScope(scopeValue);80+ let target: URL;81+ try {82+ target = new URL(targetURL);83+ } catch {84+ return { ok: false, issue: "invalid-target" };85+ }86+ if (target.username || target.password) return { ok: false, issue: "credentialed-target" };87+ if (target.origin !== scope.origin) return { ok: false, issue: "outside-origin" };88+ if (!target.pathname.startsWith(scope.pathname)) {89+ return { ok: false, issue: "outside-scope" };90+ }91+ return { ok: true, target: { url: target.href } };92+}93+94+/**95+ * Feature-detect and install one early launch-queue consumer.96+ *97+ * The most recently installed consumer owns the queue. Disposing an older98+ * development/HMR generation cannot replace the newer consumer; disposing the99+ * current owner installs a no-op consumer so stale callbacks stop handling100+ * launches.101+ */102+export function installInstalledAppLaunchConsumer(103+ options: InstallLaunchConsumerOptions,104+): InstalledLaunchConsumer {105+ const queue = options.queue ?? browserLaunchQueue();106+ if (!queue) return { supported: false, dispose() {} };107+ const scope = configuredScope(options.scope);108+ const owner = Symbol("lofi-launch-consumer");109+ let active = true;110+ owners.set(queue, owner);111+ queue.setConsumer((parameters) => {112+ if (!active || owners.get(queue) !== owner) return;113+ const result = parseInstalledAppLaunchTarget(parameters.targetURL, scope);114+ if (result.ok) options.onLaunch(result.target);115+ else options.onRejected?.(result.issue);116+ });117+ return {118+ supported: true,119+ dispose() {120+ if (!active) return;121+ active = false;122+ if (owners.get(queue) !== owner) return;123+ owners.delete(queue);124+ queue.setConsumer(() => {});125+ },126+ };127+}
A package/recipes/launch-handler_test.ts +99 -0
@@ -0,0 +1,99 @@
1+import {2+ type InstalledAppLaunchParameters,3+ type InstalledAppLaunchQueue,4+ installInstalledAppLaunchConsumer,5+ parseInstalledAppLaunchTarget,6+} from "./launch-handler.ts";7+8+function assert(condition: unknown, message: string): asserts condition {9+ if (!condition) throw new Error(message);10+}11+12+Deno.test("launch target parser accepts only exact-origin URLs inside path scope", () => {13+ const valid = parseInstalledAppLaunchTarget(14+ "https://app.example/field-notes/tasks/?id=1#selected",15+ "https://app.example/field-notes/",16+ );17+ assert(valid.ok, `valid launch failed: ${JSON.stringify(valid)}`);18+ assert(19+ valid.target.url === "https://app.example/field-notes/tasks/?id=1#selected",20+ "valid launch URL drifted",21+ );22+23+ const origin = parseInstalledAppLaunchTarget(24+ "https://other.example/field-notes/",25+ "https://app.example/field-notes/",26+ );27+ assert(!origin.ok && origin.issue === "outside-origin", "foreign origin passed");28+29+ const scope = parseInstalledAppLaunchTarget(30+ "https://app.example/other/",31+ "https://app.example/field-notes/",32+ );33+ assert(!scope.ok && scope.issue === "outside-scope", "out-of-scope path passed");34+35+ const credentialed = parseInstalledAppLaunchTarget(36+ "https://user:secret@app.example/field-notes/",37+ "https://app.example/field-notes/",38+ );39+ assert(40+ !credentialed.ok && credentialed.issue === "credentialed-target",41+ "credentialed URL passed",42+ );43+});44+45+Deno.test("launch target parser rejects missing, malformed, and unsafe scope configuration", () => {46+ const missing = parseInstalledAppLaunchTarget(undefined, "https://app.example/");47+ assert(!missing.ok && missing.issue === "missing-target", "missing launch target passed");48+ const malformed = parseInstalledAppLaunchTarget("not a URL", "https://app.example/");49+ assert(!malformed.ok && malformed.issue === "invalid-target", "malformed target passed");50+ let rejected = false;51+ try {52+ parseInstalledAppLaunchTarget("https://app.example/", "https://app.example/no-slash");53+ } catch (error) {54+ rejected = error instanceof TypeError;55+ }56+ assert(rejected, "ambiguous scope configuration passed");57+});58+59+Deno.test("launch consumer feature-detects unsupported browsers", () => {60+ const consumer = installInstalledAppLaunchConsumer({61+ scope: "https://app.example/",62+ onLaunch: () => {63+ throw new Error("unsupported consumer ran");64+ },65+ });66+ assert(!consumer.supported, "missing launchQueue was reported as supported");67+ consumer.dispose();68+});69+70+Deno.test("latest launch consumer owns delivery and stale disposal is harmless", () => {71+ let current: (parameters: InstalledAppLaunchParameters) => void = () => {};72+ const queue: InstalledAppLaunchQueue = {73+ setConsumer(consumer) {74+ current = consumer;75+ },76+ };77+ const calls: string[] = [];78+ const first = installInstalledAppLaunchConsumer({79+ queue,80+ scope: "https://app.example/app/",81+ onLaunch: () => calls.push("first"),82+ });83+ const second = installInstalledAppLaunchConsumer({84+ queue,85+ scope: "https://app.example/app/",86+ onLaunch: (target) => calls.push(target.url),87+ onRejected: (issue) => calls.push(issue),88+ });89+ first.dispose();90+ current({ targetURL: "https://app.example/app/tasks/" });91+ current({ targetURL: "https://other.example/app/tasks/" });92+ assert(93+ calls.join(",") === "https://app.example/app/tasks/,outside-origin",94+ `unexpected launch delivery: ${calls.join(",")}`,95+ );96+ second.dispose();97+ current({ targetURL: "https://app.example/app/ignored/" });98+ assert(calls.length === 2, "disposed consumer still handled a launch");99+});
M package/testdata/starter.snapshot.json +1 -1
M package/tooling/pwa-validation.ts +41 -0
@@ -17,6 +17,12 @@ type ImageResource = {
1717 const manifestFile = "manifest.webmanifest";1818 const syntheticOrigin = "https://lofi.invalid";1919 const displayModes = new Set(["browser", "fullscreen", "minimal-ui", "standalone"]);20+const launchClientModes = new Set([21+ "auto",22+ "focus-existing",23+ "navigate-existing",24+ "navigate-new",25+]);2026 const imageTypes = new Map([2127 ["image/jpeg", new Set([".jpeg", ".jpg"])],2228 ["image/png", new Set([".png"])],
@@ -287,6 +293,41 @@ async function parseAndValidateManifest(
287293 }288294 }289295 296+ if (manifest.launch_handler !== undefined) {297+ const label = `${manifestFile}: launch_handler`;298+ const handler = manifest.launch_handler;299+ if (!isObject(handler)) {300+ issues.push(issue(`${label} must be an object`, remediation));301+ } else {302+ const unknown = Object.keys(handler).filter((member) => member !== "client_mode");303+ if (unknown.length > 0) {304+ issues.push(issue(`${label} contains unsupported members`, remediation));305+ }306+ const modes = typeof handler.client_mode === "string"307+ ? [handler.client_mode]308+ : Array.isArray(handler.client_mode)309+ ? handler.client_mode310+ : undefined;311+ if (!modes || modes.length === 0) {312+ issues.push(313+ issue(`${label}.client_mode must be a mode or non-empty mode array`, remediation),314+ );315+ } else {316+ if (!modes.every((mode) => typeof mode === "string" && launchClientModes.has(mode))) {317+ issues.push(318+ issue(319+ `${label}.client_mode supports auto, focus-existing, navigate-existing, or navigate-new`,320+ remediation,321+ ),322+ );323+ }324+ if (new Set(modes).size !== modes.length) {325+ issues.push(issue(`${label}.client_mode must not repeat modes`, remediation));326+ }327+ }328+ }329+ }330+290331 const manifestUrl = new URL(`${syntheticOrigin}${basePath}${manifestFile}`);291332 let startUrl: URL | undefined;292333 let scope: URL | undefined;
M package/tooling/pwa-validation_test.ts +23 -0
M tools/doc_lint.ts +1 -0
M tools/golden.ts +137 -1
@@ -392,7 +392,7 @@ async function assertGeneratedBoundary(
392392 const lofiImports = Object.entries(config.imports).filter(([name]) =>393393 name === "@nzip/lofi" || name.startsWith("@nzip/lofi/")394394 );395- assert(lofiImports.length === 13, `expected 13 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 14, `expected 14 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;
@@ -423,6 +423,7 @@ async function assertProductionOutput(projectRoot: string) {
423423 for (424424 const required of [425425 "index.html",426+ "launch/index.html",426427 "share/index.html",427428 "manifest.webmanifest",428429 "sw.js",
@@ -451,9 +452,14 @@ async function assertProductionOutput(projectRoot: string) {
451452 enctype: string;452453 params: { title: string; text: string; url: string };453454 };455+ launch_handler: { client_mode: string[] };454456 };455457 assert(manifest.share_target.action === "./share/", "share target action drifted");456458 assert(manifest.share_target.method === "GET", "share target method drifted");459+ assert(460+ manifest.launch_handler.client_mode.join(",") === "focus-existing,auto",461+ "launch handler client mode drifted",462+ );457463 const expected = new Map([458464 [459465 "icon-192.png",
@@ -695,6 +701,64 @@ export default function ShareReceiver() {
695701 );696702 }697703 704+async function installLaunchHandlerRecipe(projectRoot: string) {705+ const manifestPath = join(projectRoot, "public", "manifest.webmanifest");706+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));707+ manifest.launch_handler = { client_mode: ["focus-existing", "auto"] };708+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);709+ await Deno.writeTextFile(710+ join(projectRoot, "src", "pages", "launch.astro"),711+ `---712+import LaunchReceiver from "../islands/LaunchReceiver.tsx";713+import Shell from "../layouts/Shell.astro";714+import "../styles/global.css";715+---716+717+<Shell title="Installed app launch">718+ <LaunchReceiver client:load />719+</Shell>720+`,721+ );722+ await Deno.writeTextFile(723+ join(projectRoot, "src", "islands", "LaunchReceiver.tsx"),724+ `import { useEffect, useState } from "preact/hooks";725+import {726+ installInstalledAppLaunchConsumer,727+ type InstalledAppLaunchIssue,728+} from "@nzip/lofi/recipes/launch-handler";729+730+export default function LaunchReceiver() {731+ const [supported, setSupported] = useState<boolean>();732+ const [target, setTarget] = useState("");733+ const [issue, setIssue] = useState<InstalledAppLaunchIssue>();734+ useEffect(() => {735+ const consumer = installInstalledAppLaunchConsumer({736+ scope: new URL(import.meta.env.BASE_URL, location.origin),737+ onLaunch: (next) => {738+ setIssue(undefined);739+ setTarget(next.url);740+ },741+ onRejected: (next) => {742+ setTarget("");743+ setIssue(next);744+ },745+ });746+ setSupported(consumer.supported);747+ return consumer.dispose;748+ }, []);749+ return (750+ <section class="island">751+ <h1>Installed app launch</h1>752+ <output data-launch-support>{supported === undefined ? "checking" : supported ? "supported" : "unsupported"}</output>753+ {target && <p data-launch-target>{target}</p>}754+ {issue && <p role="alert">The requested app destination is not valid.</p>}755+ </section>756+ );757+}758+`,759+ );760+}761+698762 async function main() {699763 const { source, version } = parseArgs(Deno.args);700764 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {
@@ -939,6 +1003,7 @@ async function main() {
9391003 dev = undefined;9401004 9411005 await stage("install web-share recipe", () => installWebShareRecipe(projectRoot!));1006+ await stage("install launch-handler recipe", () => installLaunchHandlerRecipe(projectRoot!));9421007 9431008 await stage(9441009 "build",
@@ -1121,6 +1186,77 @@ async function main() {
11211186 description: "task app after share recipe exercise",11221187 });11231188 });1189+ await stage("launch-handler recipe browser", async () => {1190+ const unsupportedPage = await context!.newPage();1191+ try {1192+ await unsupportedPage.addInitScript(() => {1193+ Object.defineProperty(globalThis, "launchQueue", {1194+ configurable: true,1195+ value: undefined,1196+ });1197+ });1198+ await unsupportedPage.goto(`${origin}launch/`, { waitUntil: "domcontentloaded" });1199+ await unsupportedPage.locator("[data-launch-support]").waitFor({ state: "visible" });1200+ assert(1201+ await unsupportedPage.locator("[data-launch-support]").textContent() === "unsupported",1202+ "missing launchQueue was not reported as unsupported",1203+ );1204+ } finally {1205+ await unsupportedPage.close();1206+ }1207+1208+ await page!.addInitScript(() => {1209+ let consumer: (parameters: { targetURL?: string }) => void = () => {};1210+ Object.defineProperty(globalThis, "launchQueue", {1211+ configurable: true,1212+ value: {1213+ setConsumer(next: typeof consumer) {1214+ consumer = next;1215+ },1216+ },1217+ });1218+ Object.defineProperty(globalThis, "__LOFI_DELIVER_LAUNCH__", {1219+ configurable: true,1220+ value: (targetURL: string) => consumer({ targetURL }),1221+ });1222+ });1223+ await context!.setOffline(true);1224+ try {1225+ await page!.goto(`${origin}launch/`, { waitUntil: "domcontentloaded" });1226+ await page!.locator("[data-launch-support]").waitFor({ state: "visible" });1227+ assert(1228+ await page!.locator("[data-launch-support]").textContent() === "supported",1229+ "mock launchQueue was not consumed",1230+ );1231+ await page!.evaluate((targetURL) => {1232+ const deliver = (globalThis as typeof globalThis & {1233+ __LOFI_DELIVER_LAUNCH__?: (targetURL: string) => void;1234+ }).__LOFI_DELIVER_LAUNCH__;1235+ deliver?.(targetURL);1236+ }, `${origin}launch/?item=1#selected`);1237+ await page!.locator("[data-launch-target]").waitFor({ state: "visible" });1238+ assert(1239+ await page!.locator("[data-launch-target]").textContent() ===1240+ `${origin}launch/?item=1#selected`,1241+ "valid offline launch target was not normalized",1242+ );1243+ await page!.evaluate(() => {1244+ const deliver = (globalThis as typeof globalThis & {1245+ __LOFI_DELIVER_LAUNCH__?: (targetURL: string) => void;1246+ }).__LOFI_DELIVER_LAUNCH__;1247+ deliver?.("https://outside.invalid/private");1248+ });1249+ await page!.getByRole("alert").waitFor({ state: "visible" });1250+ const rejected = await page!.locator("body").innerText();1251+ assert(!rejected.includes("outside.invalid"), "rejected launch target leaked into the UI");1252+ } finally {1253+ await context!.setOffline(false);1254+ }1255+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1256+ await waitForReady(page!, taskAppReady, undefined, {1257+ description: "task app after launch-handler exercise",1258+ });1259+ });11241260 await stage("waiting worker update browser", async () => {11251261 const workerPath = join(projectRoot!, "dist", "sw.js");11261262 const worker = await Deno.readTextFile(workerPath);