@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 +21 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +115 -0
- package/package.json +29 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|