@azphalt/web-handoff 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Az
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The web→host install handoff from `spec/web-handoff.md`, for any web storefront.
3
+ *
4
+ * The spec requires the same ladder of every conforming storefront — build the link, attempt it,
5
+ * detect that nothing claimed it, fall back to the `.azp` download and the host directory — and the
6
+ * middle step is a **heuristic**, because no browser API answers "is this scheme registered". A
7
+ * heuristic reimplemented per storefront is a heuristic that behaves differently per storefront, so
8
+ * it lives here once instead.
9
+ *
10
+ * The Compose storefront's `models/InstallHandoff.kt` + `network/WasmHandoff.kt` are the Kotlin
11
+ * counterpart, deliberately making the same decisions in the same order. Two storefronts that
12
+ * disagree about what pressing Install does would be two products wearing one name.
13
+ */
14
+ /**
15
+ * The minimum a package must look like for this module to act on it.
16
+ *
17
+ * Structural rather than an import of `@azphalt/azdk`'s `PackageSummary` on purpose: a storefront may
18
+ * carry its own catalogue type (the React storefront does), and forcing every consumer onto one
19
+ * nominal type would make this package harder to adopt than reimplementing it — which is the outcome
20
+ * it exists to prevent. Anything with these fields satisfies it.
21
+ */
22
+ export interface HandoffPackage {
23
+ id: string;
24
+ name: string;
25
+ version: string;
26
+ /** Reverse-DNS host ids this package is scoped to. Empty/absent = global. */
27
+ targetApps?: string[];
28
+ /** The reduced `app` block from a `kind:"app"` summary (`spec/repository-api.md` § The `app` summary). */
29
+ app?: {
30
+ roles?: string[];
31
+ hostId?: string;
32
+ platforms?: {
33
+ android?: {
34
+ install?: string;
35
+ };
36
+ pwa?: {
37
+ startUrl?: string;
38
+ };
39
+ };
40
+ };
41
+ }
42
+ /**
43
+ * The link a storefront emits for a package.
44
+ *
45
+ * Host-agnostic by construction (`web-handoff.md` § Why a custom scheme): no host is named, so any
46
+ * conforming host may claim it and the OS disambiguates when several do. A pack uses this same shape —
47
+ * it is a package with an id, and the host resolves its members.
48
+ */
49
+ export declare function installLink(id: string, version?: string, repo?: string): string;
50
+ /** A host worth offering to someone who has none installed. */
51
+ export interface HostOption {
52
+ name: string;
53
+ hostId: string;
54
+ installUrl: string;
55
+ }
56
+ /**
57
+ * The hosts from `catalog` that could run `pkg`, for the "get a host" half of the fallback.
58
+ *
59
+ * An app-scoped package (`targetApps` non-empty) offers only the hosts it names — pitching a
60
+ * Guillotine-only pack at a paint app wastes the user's time in the one place they are already stuck.
61
+ * A listing with no install URL is dropped rather than shown as a dead entry.
62
+ */
63
+ export declare function hostsFor(pkg: HandoffPackage, catalog: HandoffPackage[]): HostOption[];
64
+ /**
65
+ * The conforming download URL — the `.azp` half of the fallback, which works with no link support at
66
+ * all because a host registered for the media type opens the downloaded file directly.
67
+ *
68
+ * Relative on purpose: a conforming storefront serving this bundle from its own domain should hand
69
+ * out its own bytes, not the flagship registry's.
70
+ */
71
+ export declare function downloadUrl(id: string, version: string): string;
72
+ /**
73
+ * Fire the link and report whether anything took it.
74
+ *
75
+ * `true` means a host claimed it and the user has left. `false` means nothing did, and the caller
76
+ * should show the fallback (`web-handoff.md` § When no host is installed).
77
+ *
78
+ * The attempt is made unconditionally — there is no "is a host likely on this platform" check first,
79
+ * because any such check is a list of platforms someone thought hosts existed on, and it fails closed
80
+ * against exactly the hosts it has not heard of yet.
81
+ *
82
+ * Departure is recorded by listeners rather than sampled at the end: a user can leave for a host and
83
+ * return well inside the wait window, and by then `visibilityState` is "visible" again. Sampling
84
+ * would read that as "nothing claimed it" and show the fallback to somebody who just installed.
85
+ */
86
+ export declare function attemptHandoff(link: string): Promise<boolean>;
package/dist/index.js ADDED
@@ -0,0 +1,115 @@
1
+ /** How long to wait before concluding nothing claimed the link. See `attemptHandoff`. */
2
+ const HANDOFF_WAIT_MS = 1_400;
3
+ /**
4
+ * The link a storefront emits for a package.
5
+ *
6
+ * Host-agnostic by construction (`web-handoff.md` § Why a custom scheme): no host is named, so any
7
+ * conforming host may claim it and the OS disambiguates when several do. A pack uses this same shape —
8
+ * it is a package with an id, and the host resolves its members.
9
+ */
10
+ export function installLink(id, version, repo) {
11
+ const params = new URLSearchParams();
12
+ params.set("id", id);
13
+ // Always sent when known: the storefront is displaying the version, so making the host re-resolve
14
+ // `latest` would be a round-trip to learn what we already had.
15
+ if (version?.trim())
16
+ params.set("version", version);
17
+ if (repo?.trim())
18
+ params.set("repo", repo);
19
+ // URLSearchParams form-encodes, which writes a space as '+'. A strict RFC 3986 parser reads '+' as
20
+ // a literal plus, so the id would arrive at the host altered.
21
+ return `azphalt://install?${params.toString().replace(/\+/g, "%20")}`;
22
+ }
23
+ /**
24
+ * The hosts from `catalog` that could run `pkg`, for the "get a host" half of the fallback.
25
+ *
26
+ * An app-scoped package (`targetApps` non-empty) offers only the hosts it names — pitching a
27
+ * Guillotine-only pack at a paint app wastes the user's time in the one place they are already stuck.
28
+ * A listing with no install URL is dropped rather than shown as a dead entry.
29
+ */
30
+ export function hostsFor(pkg, catalog) {
31
+ const wanted = new Set(pkg.targetApps ?? []);
32
+ const out = [];
33
+ const seen = new Set();
34
+ for (const candidate of catalog) {
35
+ const app = candidate.app;
36
+ if (!app)
37
+ continue;
38
+ // Absent `roles` means `["companion"]`; the API resolves that before serving, but a storefront
39
+ // pointed at an older deployment should not start offering companions as hosts.
40
+ if (!(app.roles ?? ["companion"]).includes("host"))
41
+ continue;
42
+ if (!app.hostId)
43
+ continue;
44
+ if (wanted.size > 0 && !wanted.has(app.hostId))
45
+ continue;
46
+ const installUrl = app.platforms?.android?.install ?? app.platforms?.pwa?.startUrl;
47
+ if (!installUrl)
48
+ continue;
49
+ if (seen.has(app.hostId))
50
+ continue;
51
+ seen.add(app.hostId);
52
+ out.push({ name: candidate.name, hostId: app.hostId, installUrl });
53
+ }
54
+ return out;
55
+ }
56
+ /**
57
+ * The conforming download URL — the `.azp` half of the fallback, which works with no link support at
58
+ * all because a host registered for the media type opens the downloaded file directly.
59
+ *
60
+ * Relative on purpose: a conforming storefront serving this bundle from its own domain should hand
61
+ * out its own bytes, not the flagship registry's.
62
+ */
63
+ export function downloadUrl(id, version) {
64
+ return `/packages/${encodeURIComponent(id)}/versions/${encodeURIComponent(version)}/download`;
65
+ }
66
+ /**
67
+ * Fire the link and report whether anything took it.
68
+ *
69
+ * `true` means a host claimed it and the user has left. `false` means nothing did, and the caller
70
+ * should show the fallback (`web-handoff.md` § When no host is installed).
71
+ *
72
+ * The attempt is made unconditionally — there is no "is a host likely on this platform" check first,
73
+ * because any such check is a list of platforms someone thought hosts existed on, and it fails closed
74
+ * against exactly the hosts it has not heard of yet.
75
+ *
76
+ * Departure is recorded by listeners rather than sampled at the end: a user can leave for a host and
77
+ * return well inside the wait window, and by then `visibilityState` is "visible" again. Sampling
78
+ * would read that as "nothing claimed it" and show the fallback to somebody who just installed.
79
+ */
80
+ export function attemptHandoff(link) {
81
+ return new Promise((resolve) => {
82
+ let left = false;
83
+ const mark = () => {
84
+ left = true;
85
+ };
86
+ const onVisibility = () => {
87
+ if (document.visibilityState !== "visible")
88
+ left = true;
89
+ };
90
+ window.addEventListener("pagehide", mark);
91
+ window.addEventListener("blur", mark);
92
+ document.addEventListener("visibilitychange", onVisibility);
93
+ const cleanup = () => {
94
+ window.removeEventListener("pagehide", mark);
95
+ window.removeEventListener("blur", mark);
96
+ document.removeEventListener("visibilitychange", onVisibility);
97
+ };
98
+ try {
99
+ // `location.href` rather than an iframe: modern browsers treat a top-level navigation to an
100
+ // unhandled scheme as a no-op, which is the behaviour this whole dance is built around. An
101
+ // iframe would be blocked outright in several of them.
102
+ window.location.href = link;
103
+ }
104
+ catch {
105
+ // A navigation the browser refuses is "no host", not an error to surface.
106
+ cleanup();
107
+ resolve(false);
108
+ return;
109
+ }
110
+ window.setTimeout(() => {
111
+ cleanup();
112
+ resolve(left);
113
+ }, HANDOFF_WAIT_MS);
114
+ });
115
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@azphalt/web-handoff",
3
+ "version": "0.2.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "The web→host install handoff from spec/web-handoff.md — build the azphalt://install link, attempt it, and fall back when no host claims it.",
8
+ "license": "MIT",
9
+ "author": "Az (https://hereliesaz.com)",
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "sideEffects": false,
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "test": "vitest run",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit",
27
+ "clean": "rm -rf dist"
28
+ }
29
+ }