1/**2 * Opt-in presentation-only discovery for verified related applications.3 *4 * @module5 */67/** A product-owned related application declaration. */8export type VerifiedRelatedApplication = {9 readonly platform: string;10 readonly id?: string;11 readonly url?: string;12};1314/** Minimal browser discovery client used by the recipe. */15export type RelatedApplicationClient = {16 getInstalledRelatedApps(): Promise<readonly unknown[]>;17};1819/** Presentation-only discovery result. */20export type RelatedApplicationDiscovery =21 | { status: "unsupported" | "none" | "failed"; installed: readonly [] }22 | { status: "installed"; installed: readonly VerifiedRelatedApplication[] };2324/** Options for matching browser results against product-owned declarations. */25export 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};3132/**33 * The one shared `related_applications` grammar: manifest validation imports34 * these so the validator can never accept a declaration this recipe rejects.35 */36export const relatedApplicationPlatforms: ReadonlySet<string> = new Set([37 "amazon",38 "chrome_web_store",39 "chromeos_play",40 "f-droid",41 "play",42 "webapp",43 "windows",44]);4546/** Bounded, trimmed, control-character-free manifest string field. */47export function isBoundedManifestString(value: unknown): value is string {48 return typeof value === "string" && value.trim() === value && value.length > 0 &&49 value.length <= 512 && ![...value].some((character) => {50 const code = character.codePointAt(0)!;51 return code <= 0x1f || code === 0x7f;52 });53}5455const platforms = relatedApplicationPlatforms;56const nonEmptyBounded = isBoundedManifestString;5758function normalizeDeclaration(value: VerifiedRelatedApplication): VerifiedRelatedApplication {59 if (!platforms.has(value.platform) || !nonEmptyBounded(value.id) && !nonEmptyBounded(value.url)) {60 throw new TypeError("related application needs a supported platform and an id or HTTPS URL");61 }62 let url: string | undefined;63 if (value.url !== undefined) {64 if (!nonEmptyBounded(value.url)) throw new TypeError("related application URL is invalid");65 const parsed = new URL(value.url);66 if (parsed.protocol !== "https:" || parsed.username || parsed.password) {67 throw new TypeError("related application URL must be credential-free HTTPS");68 }69 url = parsed.href;70 }71 if (value.id !== undefined && !nonEmptyBounded(value.id)) {72 throw new TypeError("related application ID is invalid");73 }74 return { platform: value.platform, id: value.id, url };75}7677function browserClient(): RelatedApplicationClient | undefined {78 if (typeof navigator === "undefined") return undefined;79 const candidate = navigator as Navigator & {80 getInstalledRelatedApps?: RelatedApplicationClient["getInstalledRelatedApps"];81 };82 return typeof candidate.getInstalledRelatedApps === "function"83 ? { getInstalledRelatedApps: candidate.getInstalledRelatedApps.bind(candidate) }84 : undefined;85}8687function isObject(value: unknown): value is Record<string, unknown> {88 return value !== null && typeof value === "object" && !Array.isArray(value);89}9091/**92 * Discover installed companions and return only exact allow-list matches.93 *94 * Use the result to adjust presentation such as redundant onboarding. It is95 * intentionally unsuitable for authentication or authorization: unknown fields96 * and versions are discarded, and errors degrade to the normal PWA experience.97 */98export async function discoverRelatedApplications(99 options: DiscoverRelatedApplicationsOptions,100): Promise<RelatedApplicationDiscovery> {101 const allow = options.allow.map(normalizeDeclaration);102 const keys = allow.map((entry) => JSON.stringify(entry));103 if (new Set(keys).size !== keys.length) {104 throw new TypeError("related application declarations must not repeat");105 }106 const client = options.client ?? browserClient();107 if (!client) return { status: "unsupported", installed: [] };108 let values: readonly unknown[];109 try {110 values = await client.getInstalledRelatedApps();111 } catch {112 return { status: "failed", installed: [] };113 }114 if (!Array.isArray(values)) return { status: "failed", installed: [] };115 const installed = allow.filter((declaration) =>116 values.some((raw) => {117 if (!isObject(raw) || raw.platform !== declaration.platform) return false;118 if (declaration.id !== undefined && raw.id !== declaration.id) return false;119 if (declaration.url !== undefined) {120 if (typeof raw.url !== "string") return false;121 try {122 if (new URL(raw.url).href !== declaration.url) return false;123 } catch {124 return false;125 }126 }127 return true;128 })129 );130 return installed.length > 0131 ? { status: "installed", installed }132 : { status: "none", installed: [] };133}