feat(pwa): add related app discovery recipe
- commit
- 13547cd3f6c9bfff129e022f03640cd66ef6bf33
- parent
- 726d6d21435222b172f14c1b33bf757c8dd9c81e
- parent
- b34e354dc61b63e93dd3aab6bd22ac1bbcfaae47
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | README.md | 4 | +- |
| M | apps/ | 1 | + |
| M | deno.json | 1 | + |
| M | docs/ | 25 | +++--- |
| M | docs/ | 2 | + |
| A | docs/ | 54 | +++++++++++++ |
| M | package/ | 14 | +++ |
| M | package/ | 2 | + |
| M | package/ | 7 | +- |
| M | package/ | 9 | ++- |
| A | package/ | 125 | ++++++++++++++++++++++++++++++ |
| A | package/ | 62 | +++++++++++++++ |
| M | package/ | 4 | +- |
| M | package/ | 77 | ++++++++++++++++++ |
| M | package/ | 31 | +++++++ |
| M | tools/ | 1 | + |
| M | tools/ | 89 | +++++++++++++++++++++- |
@@ -135,8 +135,8 @@ permission APIs.
135135 136136 OS-facing capabilities stay out of the starter until the product opts in. The137137 [installed-app recipe catalog](docs/recipes/README.md) contains tested, isolated entrypoints for Web138-Share, installed-window launch handling, validated file-import previews, and custom-protocol item139-links. Each keeps manifest changes opt-in and documents validation, offline behavior, and fallbacks.138+Share, launch handling, file-import previews, custom-protocol links, and presentation-only companion139+discovery. Each keeps manifest changes opt-in and documents validation, behavior, and fallbacks.140140 141141 ## Testing local-first behavior142142 @@ -15,6 +15,7 @@
1515 "@nzip/lofi/recipes/file-handler": "../../package/recipes/file-handler.ts",1616 "@nzip/lofi/recipes/launch-handler": "../../package/recipes/launch-handler.ts",1717 "@nzip/lofi/recipes/protocol-handler": "../../package/recipes/protocol-handler.ts",18+ "@nzip/lofi/recipes/related-app-discovery": "../../package/recipes/related-app-discovery.ts",1819 "@nzip/lofi/recipes/web-share": "../../package/recipes/web-share.ts",1920 "@nzip/lofi/test": "../../package/commands/run_tests.ts",2021 "@nzip/lofi/testing": "../../package/testing/mod.ts",@@ -17,6 +17,7 @@
1717 "./recipes/file-handler": "./package/recipes/file-handler.ts",1818 "./recipes/launch-handler": "./package/recipes/launch-handler.ts",1919 "./recipes/protocol-handler": "./package/recipes/protocol-handler.ts",20+ "./recipes/related-app-discovery": "./package/recipes/related-app-discovery.ts",2021 "./recipes/web-share": "./package/recipes/web-share.ts",2122 "./testing": "./package/testing/mod.ts",2223 "./test": "./package/commands/run_tests.ts"@@ -62,18 +62,19 @@ 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/file-handler` | Optional validated installed-app file import drafts |72-| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73-| `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |74-| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |75-| `@nzip/lofi/testing` | Local-first Playwright helpers |76-| `@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/file-handler` | Optional validated installed-app file import drafts |72+| `@nzip/lofi/recipes/launch-handler` | Optional safe installed-window launch routing |73+| `@nzip/lofi/recipes/protocol-handler` | Optional allow-listed custom-protocol item links |74+| `@nzip/lofi/recipes/related-app-discovery` | Optional presentation-only companion discovery |75+| `@nzip/lofi/recipes/web-share` | Optional inbound/outbound text and link sharing |76+| `@nzip/lofi/testing` | Local-first Playwright helpers |77+| `@nzip/lofi/{create,dev,...}` | Generator and generated-project command entrypoints |7778 7879 All of these imports resolve through the single version pinned in `deno.json`. Upgrade that pin to7980 receive framework fixes; do not copy package files into `src/` or import unpublished internal paths.@@ -22,6 +22,8 @@ flowchart LR
2222 transient drafts before the user confirms persistence.2323 - [Custom-protocol item links](protocol-handler.md) — parse one product-owned `web+` scheme into2424 allow-listed collaborative-list identifiers without arbitrary redirects.25+- [Related-application discovery](related-app-discovery.md) — hide redundant companion onboarding26+ from exact verified-app matches without changing authentication or PWA installability.2527 2628 ## Contract for every recipe2729 @@ -0,0 +1,54 @@
1+# Related-application discovery2+3+Use this recipe only when the product already owns a native or installed-web companion and has4+completed that platform's bidirectional verification. The5+[`getInstalledRelatedApps()` API](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps)6+is experimental, limited to top-level secure contexts in supporting browsers, and intended here only7+to avoid redundant presentation such as a companion-app onboarding banner.8+9+## Manifest and verification10+11+The starter omits both members. Add the verified listing while leaving native preference false:12+13+```json14+{15+ "prefer_related_applications": false,16+ "related_applications": [17+ {18+ "platform": "play",19+ "id": "com.example.companion",20+ "url": "https://play.google.com/store/apps/details?id=com.example.companion"21+ }22+ ]23+}24+```25+26+Do not invent identifiers. Android requires Digital Asset Links; Windows, cross-scope PWAs, and27+other platforms have their own verification mechanisms. Manifest declaration alone is not proof of28+ownership. Keep `prefer_related_applications` false or omitted so Chromium retains PWA29+installability.30+31+## Presentation-only discovery32+33+```ts34+import { discoverRelatedApplications } from "@nzip/lofi/recipes/related-app-discovery";35+36+const discovery = await discoverRelatedApplications({37+ allow: [{38+ platform: "play",39+ id: "com.example.companion",40+ url: "https://play.google.com/store/apps/details?id=com.example.companion",41+ }],42+});43+44+const showCompanionOnboarding = discovery.status !== "installed";45+```46+47+Only exact allow-list matches are returned. Browser-supplied versions, unknown apps, and extra48+fields are discarded. Unsupported, empty, malformed, embedded-context, and rejected calls all retain49+the normal PWA experience. Never use discovery for authentication, authorization, account linking,50+feature entitlements, or proof that the current user controls the companion app.51+52+Test unsupported and empty results, exact and mismatched platform IDs/URLs, API rejection, top-level53+versus embedded contexts, privacy-safe UI, PWA installability, platform verification updates, and54+removing the manifest listing.@@ -65,6 +65,7 @@ const recipeFiles = [
6565 "file-handler.ts",6666 "launch-handler.ts",6767 "protocol-handler.ts",68+ "related-app-discovery.ts",6869 "web-share.ts",6970 ] as const;7071 @@ -97,6 +98,13 @@ function renderConfig(projectRoot: string): string {
9798 "recipes",9899 "protocol-handler.ts",99100 );101+ const relatedAppDiscoveryRecipe = join(102+ projectRoot,103+ ".lofi",104+ "package",105+ "recipes",106+ "related-app-discovery.ts",107+ );100108 const webShareRecipe = join(projectRoot, ".lofi", "package", "recipes", "web-share.ts");101109 return `import preact from "@astrojs/preact";102110 import { defineConfig } from "astro/config";@@ -145,6 +153,9 @@ export default defineConfig({
145153 } },146154 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/protocol-handler$/, replacement: ${147155 JSON.stringify(protocolHandlerRecipe)156+ } },157+ { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/related-app-discovery$/, replacement: ${158+ JSON.stringify(relatedAppDiscoveryRecipe)148159 } },149160 { find: /^jsr:@nzip\\/lofi@[^/]+\\/recipes\\/web-share$/, replacement: ${150161 JSON.stringify(webShareRecipe)@@ -164,6 +175,9 @@ export default defineConfig({
164175 } },165176 { find: "@nzip/lofi/recipes/protocol-handler", replacement: ${166177 JSON.stringify(protocolHandlerRecipe)178+ } },179+ { find: "@nzip/lofi/recipes/related-app-discovery", replacement: ${180+ JSON.stringify(relatedAppDiscoveryRecipe)167181 } },168182 { find: "@nzip/lofi/recipes/file-handler", replacement: ${169183 JSON.stringify(fileHandlerRecipe)@@ -17,6 +17,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
1717 ".lofi/package/runtime/mod.ts",1818 ".lofi/package/recipes/file-handler.ts",1919 ".lofi/package/recipes/protocol-handler.ts",20+ ".lofi/package/recipes/related-app-discovery.ts",2021 ".lofi/package/recipes/web-share.ts",2122 "^jsr:@nzip\\/lofi@[^/]+",2223 "^npm:preact@[^/]+",@@ -38,6 +39,7 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
3839 await Deno.stat(`${root}/.lofi/package/recipes/file-handler.ts`);3940 await Deno.stat(`${root}/.lofi/package/recipes/launch-handler.ts`);4041 await Deno.stat(`${root}/.lofi/package/recipes/protocol-handler.ts`);42+ await Deno.stat(`${root}/.lofi/package/recipes/related-app-discovery.ts`);4143 await Deno.stat(`${root}/.lofi/package/recipes/web-share.ts`);4244 } finally {4345 await Deno.remove(root, { recursive: true });@@ -84,7 +84,8 @@ OS-facing capabilities remain absent until the product opts in. Start with the
8484 [installed-app recipe catalog](https://github.com/FelineStateMachine/lofi/blob/main/docs/recipes/README.md)8585 for tested manifest patches, feature detection, input validation, offline behavior, and normal-web8686 fallbacks. Isolated entrypoints cover Web Share, installed-window launch handling, and validated87-file-import previews, and allow-listed custom protocol links without adding any to the starter.87+file-import previews, custom protocol links, and presentation-only companion discovery without adding88+any to the starter.8889 8990 Public tasks: \`dev\`, \`doctor\`, \`test\`, \`build\`, and \`preview\`. Sync/backup and schema tasks:9091 \`jazz:provision\`, \`schema:validate\`, \`schema:deploy\`, \`migrations:create\`, and \`migrations:push\`.@@ -141,6 +142,10 @@ const lofiImportTargets: Record<string, { subpath: string; localPath: string }>
141142 subpath: "recipes/protocol-handler",142143 localPath: "recipes/protocol-handler.ts",143144 },145+ "@nzip/lofi/recipes/related-app-discovery": {146+ subpath: "recipes/related-app-discovery",147+ localPath: "recipes/related-app-discovery.ts",148+ },144149 "@nzip/lofi/recipes/web-share": {145150 subpath: "recipes/web-share",146151 localPath: "recipes/web-share.ts",@@ -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 === 15,110- `expected one package prefix, access, two integrations, six commands, four recipes, and testing, received ${lofiSpecifiers.length}`,109+ lofiSpecifiers.length === 16,110+ `expected one package prefix, access, two integrations, six commands, five recipes, and testing, received ${lofiSpecifiers.length}`,111111 );112112 assert(113113 lofiSpecifiers.every((specifier) => specifier.startsWith("jsr:@nzip/lofi@0.3.3/")),@@ -154,6 +154,7 @@ Deno.test("package manifest and generated version stay coupled", () => {
154154 "./recipes/file-handler",155155 "./recipes/launch-handler",156156 "./recipes/protocol-handler",157+ "./recipes/related-app-discovery",157158 "./recipes/web-share",158159 "./test",159160 "./testing",@@ -174,6 +175,10 @@ Deno.test("package manifest and generated version stay coupled", () => {
174175 packageManifest.exports["./recipes/protocol-handler"],175176 "./package/recipes/protocol-handler.ts",176177 );178+ assertEquals(179+ packageManifest.exports["./recipes/related-app-discovery"],180+ "./package/recipes/related-app-discovery.ts",181+ );177182 assertEquals(178183 packageManifest.exports["./recipes/web-share"],179184 "./package/recipes/web-share.ts",@@ -0,0 +1,125 @@
1+/**2+ * Opt-in presentation-only discovery for verified related applications.3+ *4+ * @module5+ */6+7+/** A product-owned related application declaration. */8+export type VerifiedRelatedApplication = {9+ readonly platform: string;10+ readonly id?: string;11+ readonly url?: string;12+};13+14+/** Minimal browser discovery client used by the recipe. */15+export type RelatedApplicationClient = {16+ getInstalledRelatedApps(): Promise<readonly unknown[]>;17+};18+19+/** Presentation-only discovery result. */20+export type RelatedApplicationDiscovery =21+ | { status: "unsupported" | "none" | "failed"; installed: readonly [] }22+ | { status: "installed"; installed: readonly VerifiedRelatedApplication[] };23+24+/** Options for matching browser results against product-owned declarations. */25+export type DiscoverRelatedApplicationsOptions = {26+ /** Declarations copied from a verified `related_applications` manifest entry. */27+ allow: readonly VerifiedRelatedApplication[];28+ /** Override the browser API for tests. */29+ client?: RelatedApplicationClient;30+};31+32+const platforms = new Set([33+ "amazon",34+ "chrome_web_store",35+ "chromeos_play",36+ "f-droid",37+ "play",38+ "webapp",39+ "windows",40+]);41+42+function nonEmptyBounded(value: unknown): value is string {43+ return typeof value === "string" && value.trim() === value && value.length > 0 &&44+ value.length <= 512 && ![...value].some((character) => {45+ const code = character.codePointAt(0)!;46+ return code <= 0x1f || code === 0x7f;47+ });48+}49+50+function normalizeDeclaration(value: VerifiedRelatedApplication): VerifiedRelatedApplication {51+ if (!platforms.has(value.platform) || !nonEmptyBounded(value.id) && !nonEmptyBounded(value.url)) {52+ throw new TypeError("related application needs a supported platform and an id or HTTPS URL");53+ }54+ let url: string | undefined;55+ if (value.url !== undefined) {56+ if (!nonEmptyBounded(value.url)) throw new TypeError("related application URL is invalid");57+ const parsed = new URL(value.url);58+ if (parsed.protocol !== "https:" || parsed.username || parsed.password) {59+ throw new TypeError("related application URL must be credential-free HTTPS");60+ }61+ url = parsed.href;62+ }63+ if (value.id !== undefined && !nonEmptyBounded(value.id)) {64+ throw new TypeError("related application ID is invalid");65+ }66+ return { platform: value.platform, id: value.id, url };67+}68+69+function browserClient(): RelatedApplicationClient | undefined {70+ if (typeof navigator === "undefined") return undefined;71+ const candidate = navigator as Navigator & {72+ getInstalledRelatedApps?: RelatedApplicationClient["getInstalledRelatedApps"];73+ };74+ return typeof candidate.getInstalledRelatedApps === "function"75+ ? { getInstalledRelatedApps: candidate.getInstalledRelatedApps.bind(candidate) }76+ : undefined;77+}78+79+function isObject(value: unknown): value is Record<string, unknown> {80+ return value !== null && typeof value === "object" && !Array.isArray(value);81+}82+83+/**84+ * Discover installed companions and return only exact allow-list matches.85+ *86+ * Use the result to adjust presentation such as redundant onboarding. It is87+ * intentionally unsuitable for authentication or authorization: unknown fields88+ * and versions are discarded, and errors degrade to the normal PWA experience.89+ */90+export async function discoverRelatedApplications(91+ options: DiscoverRelatedApplicationsOptions,92+): Promise<RelatedApplicationDiscovery> {93+ const allow = options.allow.map(normalizeDeclaration);94+ const keys = allow.map((entry) => JSON.stringify(entry));95+ if (new Set(keys).size !== keys.length) {96+ throw new TypeError("related application declarations must not repeat");97+ }98+ const client = options.client ?? browserClient();99+ if (!client) return { status: "unsupported", installed: [] };100+ let values: readonly unknown[];101+ try {102+ values = await client.getInstalledRelatedApps();103+ } catch {104+ return { status: "failed", installed: [] };105+ }106+ if (!Array.isArray(values)) return { status: "failed", installed: [] };107+ const installed = allow.filter((declaration) =>108+ values.some((raw) => {109+ if (!isObject(raw) || raw.platform !== declaration.platform) return false;110+ if (declaration.id !== undefined && raw.id !== declaration.id) return false;111+ if (declaration.url !== undefined) {112+ if (typeof raw.url !== "string") return false;113+ try {114+ if (new URL(raw.url).href !== declaration.url) return false;115+ } catch {116+ return false;117+ }118+ }119+ return true;120+ })121+ );122+ return installed.length > 0123+ ? { status: "installed", installed }124+ : { status: "none", installed: [] };125+}@@ -0,0 +1,62 @@
1+import { discoverRelatedApplications } from "./related-app-discovery.ts";2+3+function assert(condition: unknown, message: string): asserts condition {4+ if (!condition) throw new Error(message);5+}6+7+const allow = [{8+ platform: "play",9+ id: "com.example.companion",10+ url: "https://play.google.com/store/apps/details?id=com.example.companion",11+}] as const;12+13+Deno.test("related app discovery returns exact product-owned matches for presentation", async () => {14+ const result = await discoverRelatedApplications({15+ allow,16+ client: {17+ getInstalledRelatedApps: () =>18+ Promise.resolve([{19+ platform: "play",20+ id: "com.example.companion",21+ url: "https://play.google.com/store/apps/details?id=com.example.companion",22+ version: "private-version",23+ }, { platform: "play", id: "attacker.app" }]),24+ },25+ });26+ assert(result.status === "installed", `match failed: ${JSON.stringify(result)}`);27+ assert(result.installed.length === 1, "unknown app was exposed");28+ assert(!("version" in result.installed[0]), "browser version escaped the presentation boundary");29+});30+31+Deno.test("related app discovery makes unsupported, empty, and failure normal states", async () => {32+ const unsupported = await discoverRelatedApplications({ allow });33+ assert(unsupported.status === "unsupported", "missing API was not unsupported");34+ const none = await discoverRelatedApplications({35+ allow,36+ client: { getInstalledRelatedApps: () => Promise.resolve([]) },37+ });38+ assert(none.status === "none", "empty result was not normal");39+ const failed = await discoverRelatedApplications({40+ allow,41+ client: { getInstalledRelatedApps: () => Promise.reject(new Error("private")) },42+ });43+ assert(failed.status === "failed", "API failure did not degrade safely");44+});45+46+Deno.test("related app discovery rejects invented declarations", async () => {47+ for (48+ const declaration of [49+ { platform: "invented", id: "app" },50+ { platform: "play" },51+ { platform: "play", id: "app", url: "http://store.example/app" },52+ ]53+ ) {54+ let rejected = false;55+ try {56+ await discoverRelatedApplications({ allow: [declaration] });57+ } catch (error) {58+ rejected = error instanceof TypeError;59+ }60+ assert(rejected, `invented declaration passed: ${JSON.stringify(declaration)}`);61+ }62+});@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "bd627e90ccb70c01ad0420aee863cccc9b254fbaa9dfe57971eae9e9e6ef67d2",4+ "deno.json": "c83e98279e71d5f540093e0845509de3758cad82cb72712b0e2950fe12967cf1",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",@@ -11,7 +11,7 @@
1111 "public/manifest.webmanifest": "c76d1d487e86fca4ce4f8c71a5c3b22a15a2d0e3370583ae0fb40aafd4513560",1212 "public/screenshot-narrow.png": "8d11e3779e2e7437ef9e44d07bd382d161570846663338b3ad3f1cdb065b018f",1313 "public/screenshot-wide.png": "812786db28f6803d12adc8bd3af5fffefa8e4e20851b61de8a256fcc538c114e",14- "README.md": "e9fbef6e2f5d29f7ad5aa51dc8d76123599e76fb804d7b8abf640716e89afc55",14+ "README.md": "c12eb7b97332ed138d3a777bbb72194066213b25db014cf853279361be5e9224",1515 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1616 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1717 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",@@ -23,6 +23,15 @@ const launchClientModes = new Set([
2323 "navigate-existing",2424 "navigate-new",2525 ]);26+const relatedApplicationPlatforms = new Set([27+ "amazon",28+ "chrome_web_store",29+ "chromeos_play",30+ "f-droid",31+ "play",32+ "webapp",33+ "windows",34+]);2635 const imageTypes = new Map([2736 ["image/jpeg", new Set([".jpeg", ".jpg"])],2837 ["image/png", new Set([".png"])],@@ -292,6 +301,74 @@ async function parseAndValidateManifest(
292301 ),293302 );294303 }304+ if (manifest.prefer_related_applications !== undefined) {305+ if (typeof manifest.prefer_related_applications !== "boolean") {306+ issues.push(307+ issue(`${manifestFile}: prefer_related_applications must be boolean`, remediation),308+ );309+ } else if (manifest.prefer_related_applications) {310+ issues.push(311+ issue(312+ `${manifestFile}: prefer_related_applications must stay false to preserve PWA installability`,313+ remediation,314+ ),315+ );316+ }317+ }318+319+ if (manifest.related_applications !== undefined) {320+ if (321+ !Array.isArray(manifest.related_applications) || manifest.related_applications.length === 0322+ ) {323+ issues.push(324+ issue(`${manifestFile}: related_applications must be a non-empty array`, remediation),325+ );326+ } else {327+ const declarations = new Set<string>();328+ for (const [index, raw] of manifest.related_applications.entries()) {329+ const label = `${manifestFile}: related_applications[${index}]`;330+ if (!isObject(raw)) {331+ issues.push(issue(`${label} must be an object`, remediation));332+ continue;333+ }334+ const unknown = Object.keys(raw).filter((member) =>335+ !["platform", "id", "url"].includes(member)336+ );337+ if (unknown.length > 0) {338+ issues.push(issue(`${label} contains unsupported members`, remediation));339+ }340+ if (!nonEmptyString(raw.platform) || !relatedApplicationPlatforms.has(raw.platform)) {341+ issues.push(issue(`${label}.platform is not supported by this recipe`, remediation));342+ }343+ const hasId = nonEmptyString(raw.id) && raw.id.length <= 512;344+ const hasUrl = nonEmptyString(raw.url) && raw.url.length <= 512;345+ if (!hasId && !hasUrl) {346+ issues.push(issue(`${label} needs a bounded id or HTTPS url`, remediation));347+ }348+ let normalizedUrl = "";349+ if (raw.url !== undefined) {350+ if (!hasUrl) {351+ issues.push(issue(`${label}.url must be a bounded non-empty string`, remediation));352+ } else {353+ try {354+ const url = new URL(raw.url as string);355+ if (url.protocol !== "https:" || url.username || url.password) {356+ issues.push(issue(`${label}.url must be credential-free HTTPS`, remediation));357+ } else normalizedUrl = url.href;358+ } catch {359+ issues.push(issue(`${label}.url is not a valid URL`, remediation));360+ }361+ }362+ }363+ if (raw.id !== undefined && !hasId) {364+ issues.push(issue(`${label}.id must be a bounded non-empty string`, remediation));365+ }366+ const key = JSON.stringify([raw.platform, hasId ? raw.id : "", normalizedUrl]);367+ if (declarations.has(key)) issues.push(issue(`${label} must not repeat`, remediation));368+ declarations.add(key);369+ }370+ }371+ }295372 for (const member of ["background_color", "theme_color"] as const) {296373 if (!isLikelyCssColor(manifest[member])) {297374 issues.push(issue(`${manifestFile}: ${member} must be a CSS color`, remediation));@@ -268,6 +268,37 @@ Deno.test("source PWA validation checks opt-in protocol handlers", async () => {
268268 }269269 });270270 271+Deno.test("source PWA validation checks opt-in related applications without preferring them", async () => {272+ const { root, project } = await makeProject();273+ try {274+ const manifest = await readManifest(project);275+ manifest.prefer_related_applications = true;276+ manifest.related_applications = [{277+ platform: "invented",278+ url: "http://user:secret@store.invalid/app",279+ extra: true,280+ }];281+ await writeManifest(project, manifest);282+ const issues = await sourcePwaIssues(project);283+ assert(hasIssue(issues, "preserve PWA installability"), "native preference was accepted");284+ assert(hasIssue(issues, "platform is not supported"), "invented platform was accepted");285+ assert(hasIssue(issues, "credential-free HTTPS"), "unsafe related URL was accepted");286+ assert(hasIssue(issues, "contains unsupported members"), "unknown related member passed");287+288+ manifest.prefer_related_applications = false;289+ manifest.related_applications = [{290+ platform: "play",291+ id: "com.example.companion",292+ url: "https://play.google.com/store/apps/details?id=com.example.companion",293+ }];294+ await writeManifest(project, manifest);295+ const valid = await sourcePwaIssues(project);296+ assert(valid.length === 0, `valid related app was rejected: ${JSON.stringify(valid)}`);297+ } finally {298+ await Deno.remove(root, { recursive: true });299+ }300+});301+271302 Deno.test("source PWA validation permits replacement branding and optional members", async () => {272303 const { root, project } = await makeProject();273304 try {@@ -12,6 +12,7 @@ const entrypoints = [
1212 "package/recipes/file-handler.ts",1313 "package/recipes/launch-handler.ts",1414 "package/recipes/protocol-handler.ts",15+ "package/recipes/related-app-discovery.ts",1516 "package/recipes/web-share.ts",1617 "package/testing/mod.ts",1718 "package/commands/run_tests.ts",@@ -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 === 16, `expected 16 lofi imports, received ${lofiImports.length}`);395+ assert(lofiImports.length === 17, `expected 17 lofi imports, received ${lofiImports.length}`);396396 const expected = source === "registry"397397 ? `jsr:@nzip/lofi@${version}`398398 : pathToFileURL(join(repositoryRoot, "package")).href;@@ -914,6 +914,41 @@ export default function ProtocolItem() {
914914 );915915 }916916 917+async function installRelatedAppDiscoveryRecipe(projectRoot: string) {918+ const manifestPath = join(projectRoot, "public", "manifest.webmanifest");919+ const manifest = JSON.parse(await Deno.readTextFile(manifestPath));920+ manifest.prefer_related_applications = false;921+ manifest.related_applications = [{922+ platform: "play",923+ id: "com.example.companion",924+ url: "https://play.google.com/store/apps/details?id=com.example.companion",925+ }];926+ await Deno.writeTextFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);927+ await Deno.writeTextFile(928+ join(projectRoot, "src", "pages", "companion.astro"),929+ `---930+import CompanionDiscovery from "../islands/CompanionDiscovery.tsx";931+import Shell from "../layouts/Shell.astro";932+import "../styles/global.css";933+---934+<Shell title="Companion app"><CompanionDiscovery client:load /></Shell>935+`,936+ );937+ await Deno.writeTextFile(938+ join(projectRoot, "src", "islands", "CompanionDiscovery.tsx"),939+ `import { useEffect, useState } from "preact/hooks";940+import { discoverRelatedApplications, type RelatedApplicationDiscovery } from "@nzip/lofi/recipes/related-app-discovery";941+const allow = [{ platform: "play", id: "com.example.companion", url: "https://play.google.com/store/apps/details?id=com.example.companion" }] as const;942+export default function CompanionDiscovery() {943+ const [result, setResult] = useState<RelatedApplicationDiscovery>();944+ useEffect(() => { void discoverRelatedApplications({ allow }).then(setResult); }, []);945+ const installed = result?.status === "installed";946+ return <section class="island"><h1>Companion app</h1><output data-related-status>{result?.status ?? "checking"}</output>{!installed && <p data-related-onboarding>Get the companion app</p>}{installed && <p data-related-installed>Companion already installed</p>}<p data-related-auth>Sign-in and permissions are unchanged.</p></section>;947+}948+`,949+ );950+}951+917952 async function main() {918953 const { source, version } = parseArgs(Deno.args);919954 await Deno.remove(artifactsRoot, { recursive: true }).catch((error) => {@@ -1164,6 +1199,10 @@ async function main() {
11641199 "install protocol-handler recipe",11651200 () => installProtocolHandlerRecipe(projectRoot!),11661201 );1202+ await stage(1203+ "install related-app-discovery recipe",1204+ () => installRelatedAppDiscoveryRecipe(projectRoot!),1205+ );11671206 11681207 await stage(11691208 "build",@@ -1545,6 +1584,54 @@ async function main() {
15451584 description: "task app after protocol-handler exercise",15461585 });15471586 });1587+ await stage("related-app-discovery recipe browser", async () => {1588+ const unsupportedPage = await context!.newPage();1589+ try {1590+ await unsupportedPage.addInitScript(() => {1591+ Object.defineProperty(navigator, "getInstalledRelatedApps", {1592+ configurable: true,1593+ value: undefined,1594+ });1595+ });1596+ await unsupportedPage.goto(`${origin}companion/`, { waitUntil: "domcontentloaded" });1597+ await unsupportedPage.locator("[data-related-onboarding]").waitFor({ state: "visible" });1598+ await waitForReady(1599+ unsupportedPage,1600+ () => document.querySelector("[data-related-status]")?.textContent === "unsupported",1601+ undefined,1602+ { description: "unsupported related-app discovery" },1603+ );1604+ } finally {1605+ await unsupportedPage.close();1606+ }1607+ await page!.addInitScript(() => {1608+ Object.defineProperty(navigator, "getInstalledRelatedApps", {1609+ configurable: true,1610+ value: () =>1611+ Promise.resolve([{1612+ platform: "play",1613+ id: "com.example.companion",1614+ url: "https://play.google.com/store/apps/details?id=com.example.companion",1615+ version: "private-version",1616+ }]),1617+ });1618+ });1619+ await page!.goto(`${origin}companion/`, { waitUntil: "domcontentloaded" });1620+ await page!.locator("[data-related-installed]").waitFor({ state: "visible" });1621+ assert(1622+ await page!.locator("[data-related-auth]").textContent() ===1623+ "Sign-in and permissions are unchanged.",1624+ "discovery presentation implied an auth or authorization change",1625+ );1626+ assert(1627+ !(await page!.locator("body").innerText()).includes("private-version"),1628+ "browser-supplied companion version leaked into presentation",1629+ );1630+ await page!.goto(origin, { waitUntil: "domcontentloaded" });1631+ await waitForReady(page!, taskAppReady, undefined, {1632+ description: "task app after related-app discovery exercise",1633+ });1634+ });15481635 await stage("waiting worker update browser", async () => {15491636 const workerPath = join(projectRoot!, "dist", "sw.js");15501637 const worker = await Deno.readTextFile(workerPath);