clone

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

feat/184-task-notice-migration
lofi / package / recipes / related-app-discovery.ts
related-app-discovery.ts 5.4K
1/**2 * Opt-in presentation-only discovery for verified related applications.3 *4 * @module5 */67const platformList = [8 "amazon",9 "chrome_web_store",10 "chromeos_play",11 "f-droid",12 "play",13 "webapp",14 "windows",15] as const;1617/** Store platform identifier accepted in a `related_applications` declaration. */18export type RelatedApplicationPlatform = (typeof platformList)[number];1920/** A product-owned related application declaration. */21export type VerifiedRelatedApplication = {22 readonly platform: RelatedApplicationPlatform;23 readonly id?: string;24 readonly url?: string;25};2627/** Minimal browser discovery client used by the recipe. */28export type RelatedApplicationClient = {29 getInstalledRelatedApps(): Promise<readonly unknown[]>;30};3132/**33 * Presentation-only discovery outcome. `installed` is the only status that34 * carries matches; `unsupported`, `none`, and `failed` are equally normal35 * states that keep the ordinary PWA experience.36 */37export type RelatedApplicationDiscoveryStatus = "unsupported" | "none" | "failed" | "installed";3839/** Presentation-only discovery result. */40export type RelatedApplicationDiscovery =41 | {42 status: Exclude<RelatedApplicationDiscoveryStatus, "installed">;43 installed: readonly [];44 }45 | { status: "installed"; installed: readonly VerifiedRelatedApplication[] };4647/** Options for matching browser results against product-owned declarations. */48export type DiscoverRelatedApplicationsOptions = {49 /** Declarations copied from a verified `related_applications` manifest entry. */50 allow: readonly VerifiedRelatedApplication[];51 /** Override the browser API for tests. */52 client?: RelatedApplicationClient;53};5455/**56 * The one shared `related_applications` grammar: manifest validation imports57 * these so the validator can never accept a declaration this recipe rejects.58 */59export const relatedApplicationPlatforms: ReadonlySet<string> = new Set(platformList);6061/** Bounded, trimmed, control-character-free manifest string field. */62export function isBoundedManifestString(value: unknown): value is string {63 return typeof value === "string" && value.trim() === value && value.length > 0 &&64 value.length <= 512 && ![...value].some((character) => {65 const code = character.codePointAt(0)!;66 return code <= 0x1f || code === 0x7f;67 });68}6970const platforms = relatedApplicationPlatforms;71const nonEmptyBounded = isBoundedManifestString;7273function normalizeDeclaration(value: VerifiedRelatedApplication): VerifiedRelatedApplication {74 if (!platforms.has(value.platform) || !nonEmptyBounded(value.id) && !nonEmptyBounded(value.url)) {75 throw new TypeError("related application needs a supported platform and an id or HTTPS URL");76 }77 let url: string | undefined;78 if (value.url !== undefined) {79 if (!nonEmptyBounded(value.url)) throw new TypeError("related application URL is invalid");80 const parsed = new URL(value.url);81 if (parsed.protocol !== "https:" || parsed.username || parsed.password) {82 throw new TypeError("related application URL must be credential-free HTTPS");83 }84 url = parsed.href;85 }86 if (value.id !== undefined && !nonEmptyBounded(value.id)) {87 throw new TypeError("related application ID is invalid");88 }89 return { platform: value.platform, id: value.id, url };90}9192function browserClient(): RelatedApplicationClient | undefined {93 if (typeof navigator === "undefined") return undefined;94 const candidate = navigator as Navigator & {95 getInstalledRelatedApps?: RelatedApplicationClient["getInstalledRelatedApps"];96 };97 return typeof candidate.getInstalledRelatedApps === "function"98 ? { getInstalledRelatedApps: candidate.getInstalledRelatedApps.bind(candidate) }99 : undefined;100}101102function isObject(value: unknown): value is Record<string, unknown> {103 return value !== null && typeof value === "object" && !Array.isArray(value);104}105106/**107 * Discover installed companions and return only exact allow-list matches.108 *109 * Use the result to adjust presentation such as redundant onboarding. It is110 * intentionally unsuitable for authentication or authorization: unknown fields111 * and versions are discarded, and errors degrade to the normal PWA experience.112 */113export async function discoverRelatedApplications(114 options: DiscoverRelatedApplicationsOptions,115): Promise<RelatedApplicationDiscovery> {116 const allow = options.allow.map(normalizeDeclaration);117 const keys = allow.map((entry) => JSON.stringify(entry));118 if (new Set(keys).size !== keys.length) {119 throw new TypeError("related application declarations must not repeat");120 }121 const client = options.client ?? browserClient();122 if (!client) return { status: "unsupported", installed: [] };123 let values: readonly unknown[];124 try {125 values = await client.getInstalledRelatedApps();126 } catch {127 return { status: "failed", installed: [] };128 }129 if (!Array.isArray(values)) return { status: "failed", installed: [] };130 const installed = allow.filter((declaration) =>131 values.some((raw) => {132 if (!isObject(raw) || raw.platform !== declaration.platform) return false;133 if (declaration.id !== undefined && raw.id !== declaration.id) return false;134 if (declaration.url !== undefined) {135 if (typeof raw.url !== "string") return false;136 try {137 if (new URL(raw.url).href !== declaration.url) return false;138 } catch {139 return false;140 }141 }142 return true;143 })144 );145 return installed.length > 0146 ? { status: "installed", installed }147 : { status: "none", installed: [] };148}