@nervur-org/dock 0.3.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/GETTING_STARTED.md +128 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +37 -0
- package/api/quo-api.md +196 -0
- package/api/route.ts +227 -0
- package/beings/GLOSSARY.md +118 -0
- package/beings/WORLDS.md +567 -0
- package/beings/avatar.ts +101 -0
- package/beings/carry.ts +159 -0
- package/beings/clock.ts +552 -0
- package/beings/courier.ts +107 -0
- package/beings/desk.ts +128 -0
- package/beings/doorbell.ts +129 -0
- package/beings/envoy.ts +218 -0
- package/beings/index.ts +16 -0
- package/beings/join.ts +51 -0
- package/beings/link.ts +104 -0
- package/beings/look.ts +139 -0
- package/beings/porter.ts +43 -0
- package/beings/presence.ts +39 -0
- package/beings/quo-app-bells.md +280 -0
- package/beings/quo-dock.md +1942 -0
- package/beings/setup.ts +45 -0
- package/beings/side.ts +60 -0
- package/beings/user.ts +459 -0
- package/beings/way.ts +65 -0
- package/cli/bundles.ts +20 -0
- package/cli/client.ts +73 -0
- package/cli/daemon.ts +581 -0
- package/cli/estate/Caddyfile +33 -0
- package/cli/estate/quo.service +35 -0
- package/cli/estate/systems.quo.plist +44 -0
- package/cli/estate.ts +48 -0
- package/cli/folder.ts +27 -0
- package/cli/oauth.ts +32 -0
- package/cli/pilot.ts +38 -0
- package/cli/quo-dock.md +1942 -0
- package/cli/quo.ts +203 -0
- package/cli/stand.ts +160 -0
- package/dist/api/route.d.ts +21 -0
- package/dist/api/route.js +193 -0
- package/dist/beings/avatar.d.ts +32 -0
- package/dist/beings/avatar.js +100 -0
- package/dist/beings/carry.d.ts +12 -0
- package/dist/beings/carry.js +174 -0
- package/dist/beings/clock.d.ts +161 -0
- package/dist/beings/clock.js +528 -0
- package/dist/beings/courier.d.ts +50 -0
- package/dist/beings/courier.js +96 -0
- package/dist/beings/desk.d.ts +53 -0
- package/dist/beings/desk.js +135 -0
- package/dist/beings/doorbell.d.ts +78 -0
- package/dist/beings/doorbell.js +133 -0
- package/dist/beings/envoy.d.ts +54 -0
- package/dist/beings/envoy.js +211 -0
- package/dist/beings/index.d.ts +14 -0
- package/dist/beings/index.js +16 -0
- package/dist/beings/join.d.ts +15 -0
- package/dist/beings/join.js +42 -0
- package/dist/beings/link.d.ts +14 -0
- package/dist/beings/link.js +91 -0
- package/dist/beings/look.d.ts +30 -0
- package/dist/beings/look.js +104 -0
- package/dist/beings/porter.d.ts +14 -0
- package/dist/beings/porter.js +41 -0
- package/dist/beings/presence.d.ts +8 -0
- package/dist/beings/presence.js +21 -0
- package/dist/beings/setup.d.ts +3 -0
- package/dist/beings/setup.js +39 -0
- package/dist/beings/side.d.ts +32 -0
- package/dist/beings/side.js +37 -0
- package/dist/beings/user.d.ts +315 -0
- package/dist/beings/user.js +462 -0
- package/dist/beings/way.d.ts +19 -0
- package/dist/beings/way.js +28 -0
- package/dist/cli/bundles.d.ts +5 -0
- package/dist/cli/bundles.js +17 -0
- package/dist/cli/client.d.ts +15 -0
- package/dist/cli/client.js +58 -0
- package/dist/cli/daemon.d.ts +50 -0
- package/dist/cli/daemon.js +584 -0
- package/dist/cli/estate/Caddyfile +33 -0
- package/dist/cli/estate/quo.service +35 -0
- package/dist/cli/estate/systems.quo.plist +44 -0
- package/dist/cli/estate.d.ts +5 -0
- package/dist/cli/estate.js +50 -0
- package/dist/cli/folder.d.ts +2 -0
- package/dist/cli/folder.js +25 -0
- package/dist/cli/oauth.d.ts +2 -0
- package/dist/cli/oauth.js +32 -0
- package/dist/cli/pilot.d.ts +5 -0
- package/dist/cli/pilot.js +22 -0
- package/dist/cli/quo.d.ts +2 -0
- package/dist/cli/quo.js +201 -0
- package/dist/cli/stand.d.ts +37 -0
- package/dist/cli/stand.js +110 -0
- package/dist/dock/agents.d.ts +102 -0
- package/dist/dock/agents.js +105 -0
- package/dist/dock/apns.d.ts +27 -0
- package/dist/dock/apns.js +98 -0
- package/dist/dock/console.d.ts +6 -0
- package/dist/dock/console.js +20 -0
- package/dist/dock/dialers.d.ts +56 -0
- package/dist/dock/dialers.js +75 -0
- package/dist/dock/faculty.d.ts +24 -0
- package/dist/dock/faculty.js +142 -0
- package/dist/dock/fcm.d.ts +13 -0
- package/dist/dock/fcm.js +77 -0
- package/dist/dock/grants.d.ts +41 -0
- package/dist/dock/grants.js +99 -0
- package/dist/dock/http2.d.ts +4 -0
- package/dist/dock/http2.js +68 -0
- package/dist/dock/index.d.ts +41 -0
- package/dist/dock/index.js +113 -0
- package/dist/dock/local.d.ts +13 -0
- package/dist/dock/local.js +44 -0
- package/dist/dock/location.d.ts +19 -0
- package/dist/dock/location.js +11 -0
- package/dist/dock/notifications.d.ts +38 -0
- package/dist/dock/notifications.js +60 -0
- package/dist/dock/push.d.ts +79 -0
- package/dist/dock/push.js +132 -0
- package/dist/dock/service.d.ts +4 -0
- package/dist/dock/service.js +107 -0
- package/dist/dock/timer.d.ts +52 -0
- package/dist/dock/timer.js +149 -0
- package/dist/dock/webpush.d.ts +12 -0
- package/dist/dock/webpush.js +144 -0
- package/dist/harbor/browser.d.ts +15 -0
- package/dist/harbor/browser.js +82 -0
- package/dist/harbor/capacitor.d.ts +28 -0
- package/dist/harbor/capacitor.js +218 -0
- package/dist/harbor/disk.d.ts +27 -0
- package/dist/harbor/disk.js +249 -0
- package/dist/harbor/edge/alarm.d.ts +10 -0
- package/dist/harbor/edge/alarm.js +38 -0
- package/dist/harbor/edge/edge.d.ts +54 -0
- package/dist/harbor/edge/edge.js +370 -0
- package/dist/harbor/edge/exercise.d.ts +7 -0
- package/dist/harbor/edge/exercise.js +232 -0
- package/dist/harbor/edge/given.d.ts +26 -0
- package/dist/harbor/edge/given.js +42 -0
- package/dist/harbor/edge/platform.d.ts +39 -0
- package/dist/harbor/edge/storage.d.ts +30 -0
- package/dist/harbor/edge/storage.js +180 -0
- package/dist/harbor/edge/worker.d.ts +17 -0
- package/dist/harbor/edge/worker.js +67 -0
- package/dist/harbor/files.d.ts +15 -0
- package/dist/harbor/files.js +220 -0
- package/dist/harbor/http.d.ts +19 -0
- package/dist/harbor/http.js +177 -0
- package/dist/harbor/idb.d.ts +15 -0
- package/dist/harbor/idb.js +0 -0
- package/dist/harbor/quo.d.ts +10 -0
- package/dist/harbor/quo.js +42 -0
- package/dist/harbor/resolve.d.ts +16 -0
- package/dist/harbor/resolve.js +17 -0
- package/dist/harbor/seal.d.ts +3 -0
- package/dist/harbor/seal.js +25 -0
- package/dist/harbor/sealed.d.ts +35 -0
- package/dist/harbor/sealed.js +162 -0
- package/dist/harbor/tauri.d.ts +16 -0
- package/dist/harbor/tauri.js +59 -0
- package/dist/harbor/ward.d.ts +9 -0
- package/dist/harbor/ward.js +15 -0
- package/dist/human/app.d.ts +32 -0
- package/dist/human/app.js +527 -0
- package/dist/human/apps.d.ts +10 -0
- package/dist/human/apps.js +29 -0
- package/dist/human/dom.d.ts +2 -0
- package/dist/human/dom.js +15 -0
- package/dist/human/door.d.ts +19 -0
- package/dist/human/door.js +92 -0
- package/dist/human/guest.d.ts +3 -0
- package/dist/human/guest.js +33 -0
- package/dist/human/html.d.ts +55 -0
- package/dist/human/html.js +451 -0
- package/dist/human/local.d.ts +7 -0
- package/dist/human/local.js +10 -0
- package/dist/human/mark.d.ts +8 -0
- package/dist/human/mark.js +47 -0
- package/dist/human/move.d.ts +17 -0
- package/dist/human/move.js +90 -0
- package/dist/human/screen.d.ts +19 -0
- package/dist/human/screen.js +119 -0
- package/dist/human/style.d.ts +2 -0
- package/dist/human/style.js +60 -0
- package/dist/human/tab.bundle.txt +4775 -0
- package/dist/human/tab.d.ts +15 -0
- package/dist/human/tab.js +409 -0
- package/dist/human/tree.d.ts +50 -0
- package/dist/human/tree.js +157 -0
- package/dist/human/wake.d.ts +34 -0
- package/dist/human/wake.js +179 -0
- package/dist/human/web.d.ts +33 -0
- package/dist/human/web.js +184 -0
- package/dist/human/worker.bundle.txt +3868 -0
- package/dist/human/worker.d.ts +1 -0
- package/dist/human/worker.js +81 -0
- package/dist/human/worlds.d.ts +24 -0
- package/dist/human/worlds.js +99 -0
- package/dist/mcp/agent.d.ts +10 -0
- package/dist/mcp/agent.js +78 -0
- package/dist/mcp/allow.d.ts +20 -0
- package/dist/mcp/allow.js +88 -0
- package/dist/mcp/http.d.ts +27 -0
- package/dist/mcp/http.js +92 -0
- package/dist/mcp/oauth.d.ts +91 -0
- package/dist/mcp/oauth.js +241 -0
- package/dist/mcp/route.d.ts +15 -0
- package/dist/mcp/route.js +42 -0
- package/dist/mcp/runner.d.ts +45 -0
- package/dist/mcp/runner.js +99 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +102 -0
- package/dock/agents.ts +121 -0
- package/dock/apns.ts +138 -0
- package/dock/console.ts +23 -0
- package/dock/dialers.ts +85 -0
- package/dock/faculty.ts +148 -0
- package/dock/fcm.ts +103 -0
- package/dock/grants.ts +111 -0
- package/dock/http2.ts +70 -0
- package/dock/index.ts +133 -0
- package/dock/local.ts +48 -0
- package/dock/location.ts +22 -0
- package/dock/notifications.ts +67 -0
- package/dock/push.ts +161 -0
- package/dock/quo-app-bells.md +280 -0
- package/dock/quo-dock.md +1942 -0
- package/dock/service.ts +100 -0
- package/dock/timer.ts +174 -0
- package/dock/webpush.ts +182 -0
- package/harbor/browser.ts +97 -0
- package/harbor/capacitor.ts +223 -0
- package/harbor/disk.ts +237 -0
- package/harbor/edge/alarm.ts +48 -0
- package/harbor/edge/edge.ts +407 -0
- package/harbor/edge/exercise.ts +241 -0
- package/harbor/edge/given.ts +106 -0
- package/harbor/edge/platform.d.ts +39 -0
- package/harbor/edge/storage.ts +179 -0
- package/harbor/edge/text.d.ts +15 -0
- package/harbor/edge/worker.ts +70 -0
- package/harbor/edge/wrangler.toml +30 -0
- package/harbor/files.ts +221 -0
- package/harbor/http.ts +188 -0
- package/harbor/idb.ts +0 -0
- package/harbor/quo-harbor.md +850 -0
- package/harbor/quo.ts +44 -0
- package/harbor/resolve.ts +31 -0
- package/harbor/seal.ts +26 -0
- package/harbor/sealed.ts +183 -0
- package/harbor/tauri.ts +79 -0
- package/harbor/ward.ts +28 -0
- package/human/app.ts +580 -0
- package/human/apps.ts +47 -0
- package/human/dom.ts +21 -0
- package/human/door.ts +113 -0
- package/human/guest.ts +34 -0
- package/human/html.ts +490 -0
- package/human/local.ts +30 -0
- package/human/mark.ts +53 -0
- package/human/move.ts +122 -0
- package/human/quo-human.md +1042 -0
- package/human/screen.ts +136 -0
- package/human/style.ts +63 -0
- package/human/tab.bundle.txt +4775 -0
- package/human/tab.ts +446 -0
- package/human/tree.ts +217 -0
- package/human/wake.ts +191 -0
- package/human/web.ts +259 -0
- package/human/worker.bundle.txt +3868 -0
- package/human/worker.ts +124 -0
- package/human/worlds.ts +127 -0
- package/mcp/agent.ts +94 -0
- package/mcp/allow.ts +131 -0
- package/mcp/http.ts +92 -0
- package/mcp/oauth.ts +270 -0
- package/mcp/quo-mcp.md +343 -0
- package/mcp/route.ts +45 -0
- package/mcp/runner.ts +136 -0
- package/mcp/server.ts +101 -0
- package/package.json +121 -0
package/dock/service.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The bell service, from both ends: the ground a world rings through when
|
|
3
|
+
// it does not hold the platform's key, and the door that holds the key and
|
|
4
|
+
// rings.
|
|
5
|
+
//
|
|
6
|
+
// Why there is one at all. Apple and Google accept a push only when it is
|
|
7
|
+
// signed with the key of the team that published the app, so a world's
|
|
8
|
+
// droplet cannot ring the Quo app however much its human wants it to: the
|
|
9
|
+
// key belongs to whoever publishes the app and to nobody else. So a world's
|
|
10
|
+
// world hands the ring on, a token and a sealed banner, to a service the
|
|
11
|
+
// publisher runs. It is the one server the app has. An estate that ships
|
|
12
|
+
// its own app under its own name holds its own keys and runs its own, or
|
|
13
|
+
// puts these two rings straight into its push faculty and runs none.
|
|
14
|
+
//
|
|
15
|
+
// What it sees is a token, a ring, and ciphertext. Not a world, not an
|
|
16
|
+
// occupant, not a pk, and not a word of what was pushed: the banner is
|
|
17
|
+
// sealed to the device's banner key by the world that rang, and this
|
|
18
|
+
// service holds no key that opens it and never will. It keeps nothing
|
|
19
|
+
// either: no log of who rang, no list of tokens, no state at all, which is
|
|
20
|
+
// the only honest way to run a server every world's traffic passes through.
|
|
21
|
+
//
|
|
22
|
+
// Who may ring it is the token itself, and that is deliberate. A device
|
|
23
|
+
// token exists only because a device installed this app and handed it to a
|
|
24
|
+
// world it joined; it is per app and per device; a ring wakes a phone and
|
|
25
|
+
// says nothing to it; and the platforms throttle a background push on their
|
|
26
|
+
// own. An account here would be a registry of worlds, which is the one
|
|
27
|
+
// thing the app is built not to have. The day this is abused it wants a
|
|
28
|
+
// rate limit at the edge, which is the terrain's and not a word of Quo.
|
|
29
|
+
import { bellOf, type Ring, type Token } from './push.ts';
|
|
30
|
+
|
|
31
|
+
// The ring a world holds for a store's kind: the token names its bell and
|
|
32
|
+
// the ring goes there, so a phone running an app this world never shipped
|
|
33
|
+
// is rung through that app's own service; a token naming none goes to the
|
|
34
|
+
// service the world was told of, and a world told of none rings nothing.
|
|
35
|
+
export function routed(fallback?: string): Ring {
|
|
36
|
+
return {
|
|
37
|
+
async ring(token: Token, sealed?: string) {
|
|
38
|
+
const at = bellOf(token) ?? fallback;
|
|
39
|
+
if (!at) throw new Error(`no bell service rings a ${token.kind} from this box`);
|
|
40
|
+
return handedTo(at).ring(token, sealed);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The ring a world holds: hand the token to the service and take its word
|
|
46
|
+
// for it. A ring either left or threw, as every ring does.
|
|
47
|
+
export function handedTo(at: string): Ring {
|
|
48
|
+
return {
|
|
49
|
+
async ring(token: Token, sealed?: string) {
|
|
50
|
+
const res = await fetch(`${at.replace(/\/$/, '')}/ring`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
body: JSON.stringify(sealed === undefined ? { token } : { token, sealed }),
|
|
54
|
+
});
|
|
55
|
+
const said = (await res.text()).slice(0, 200);
|
|
56
|
+
if (!res.ok) throw new Error(`the bell service answered ${res.status} ${said}`);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The other end, as a plain request in and a response out, so that the
|
|
62
|
+
// terrain it runs on is the terrain's business and this is the whole of the
|
|
63
|
+
// service. `rings` is one per kind, exactly as the push faculty's map is: a
|
|
64
|
+
// service with no key for a kind answers that it has none, which is what a
|
|
65
|
+
// publisher who rings no Android phones says to one. `apps` is which apps
|
|
66
|
+
// this service holds keys for, and a token naming another is refused,
|
|
67
|
+
// since a key signs for one app and a ring for another would be a lie the
|
|
68
|
+
// platform catches later and nobody reads.
|
|
69
|
+
export function ringing(rings: Record<string, Ring>, apps?: readonly string[]): (request: Request) => Promise<Response> {
|
|
70
|
+
const answer = (status: number, body: Record<string, unknown>) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
|
|
71
|
+
return async (request: Request) => {
|
|
72
|
+
if (new URL(request.url).pathname !== '/ring') return answer(404, { error: 'the bell service has one door, /ring' });
|
|
73
|
+
if (request.method !== 'POST') return answer(405, { error: 'a ring is a POST' });
|
|
74
|
+
let token: unknown;
|
|
75
|
+
let sealed: unknown;
|
|
76
|
+
try {
|
|
77
|
+
const body = (await request.json()) as { token?: unknown; sealed?: unknown };
|
|
78
|
+
token = body.token;
|
|
79
|
+
sealed = body.sealed;
|
|
80
|
+
} catch {
|
|
81
|
+
return answer(400, { error: 'a ring is one JSON object naming a token' });
|
|
82
|
+
}
|
|
83
|
+
if (sealed !== undefined && typeof sealed !== 'string') return answer(400, { error: 'a seal is text' });
|
|
84
|
+
const kind = (token as { kind?: unknown } | null)?.kind;
|
|
85
|
+
if (typeof kind !== 'string') return answer(400, { error: 'a token names its kind' });
|
|
86
|
+
const g = rings[kind];
|
|
87
|
+
if (!g) return answer(400, { error: `this service holds no key to ring a ${kind}` });
|
|
88
|
+
const app = (token as { app?: unknown }).app;
|
|
89
|
+
if (apps && (typeof app !== 'string' || !apps.includes(app))) return answer(400, { error: `this service holds no key for ${typeof app === 'string' ? app : 'an app the token does not name'}` });
|
|
90
|
+
try {
|
|
91
|
+
await g.ring(token as Token, sealed as string | undefined);
|
|
92
|
+
return answer(200, { rang: kind });
|
|
93
|
+
} catch (e) {
|
|
94
|
+
// The platform's own refusal, passed back as it came. A world reads
|
|
95
|
+
// it in the error object its Bell answers with, which is where a
|
|
96
|
+
// human eventually reads it too.
|
|
97
|
+
return answer(502, { error: e instanceof Error ? e.message : String(e) });
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dock/timer.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box's clock, and the one being on a device that reads one. A being who
|
|
3
|
+
// could read a clock could not be moved, since the moment she read would be
|
|
4
|
+
// her box's and no other, so the moment reaches every other being the one way
|
|
5
|
+
// anything reaches a being: as an ask at her door, from this faculty, as the
|
|
6
|
+
// occupant she named when she asked to be woken.
|
|
7
|
+
//
|
|
8
|
+
// She arms for the next due moment and for nothing else. Every watcher
|
|
9
|
+
// answers a round with when she is next due, this arms for the earliest of
|
|
10
|
+
// them, and a box whose watchers all say null arms nothing and sleeps until
|
|
11
|
+
// somebody keeps something. So a box with no schedules never ticks, and a
|
|
12
|
+
// home on the edge with a sweep at three in the morning wakes once a night.
|
|
13
|
+
//
|
|
14
|
+
// A round is armed for one moment at a time and never overlaps the one
|
|
15
|
+
// before, and the moment is floored to the minute with half a second after
|
|
16
|
+
// it, since a watcher floors the moment she is handed and a round that fired
|
|
17
|
+
// a hair early would floor to the minute before.
|
|
18
|
+
//
|
|
19
|
+
// One method knows a terrain, `arm`, and a subclass replaces it: a
|
|
20
|
+
// `setTimeout` here, the object's storage alarm on a worker. Nothing else in
|
|
21
|
+
// her reads a platform.
|
|
22
|
+
import type { JsonObject } from '@nervur-org/nervur';
|
|
23
|
+
import { Faculty, type Woke } from './faculty.ts';
|
|
24
|
+
|
|
25
|
+
const MINUTE = 60_000;
|
|
26
|
+
// A timeout further out than this is not trusted, so the arming is capped and
|
|
27
|
+
// made again when it goes off. It is the base class's own bound and no rule
|
|
28
|
+
// of the faculty: a terrain whose alarm holds a month arms for the month.
|
|
29
|
+
const DAY = 86_400_000;
|
|
30
|
+
|
|
31
|
+
export class Timer extends Faculty {
|
|
32
|
+
static override asks = {
|
|
33
|
+
watch: { description: 'wake me when I say I am next due', input: { type: 'object', properties: { invitation: { type: 'object' }, method: { type: 'string' }, args: { type: 'object' } }, required: ['invitation', 'method'] } },
|
|
34
|
+
due: { description: 'bring my next round forward to this moment', input: { type: 'object', properties: { at: { type: 'number' } }, required: ['at'] } },
|
|
35
|
+
now: { description: 'this box’s moment', input: { type: 'object' } },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
#timeout: ReturnType<typeof setTimeout> | undefined;
|
|
39
|
+
#stopped = false;
|
|
40
|
+
#armed: number | null = null;
|
|
41
|
+
#round: Promise<Woke[]> | null = null;
|
|
42
|
+
|
|
43
|
+
// The moment she is armed for, or null while she is asleep. For the
|
|
44
|
+
// device's own code and for a suite; it crosses no door.
|
|
45
|
+
get armed(): number | null {
|
|
46
|
+
return this.#armed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A watcher says she is due sooner than the box knew. It only ever brings
|
|
50
|
+
// the round forward: what fires is decided at the tick, in the watcher's
|
|
51
|
+
// own cells, so an arming that moved later on somebody's word would be a
|
|
52
|
+
// schedule somebody else held silenced. The door is the whole gate here:
|
|
53
|
+
// a standing at a faculty comes from `lend` alone, and this adds no second
|
|
54
|
+
// one.
|
|
55
|
+
due(args: JsonObject): JsonObject {
|
|
56
|
+
const at = args.at;
|
|
57
|
+
if (typeof at !== 'number' || !Number.isFinite(at)) return { error: 'a due carries the moment: { at }' };
|
|
58
|
+
const when = moment(at);
|
|
59
|
+
if (this.#armed === null || when < this.#armed) this.#arm(when);
|
|
60
|
+
return { armed: this.#armed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Whoever was just filed is ticked at once, and what she answers is what
|
|
64
|
+
// the box arms for. One move covers a box booting, a home moving in and a
|
|
65
|
+
// watcher born long after the box was up: she is never waiting for a round
|
|
66
|
+
// nobody scheduled.
|
|
67
|
+
protected override async welcomed(id: string): Promise<void> {
|
|
68
|
+
await this.round(Date.now(), id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The arming went off. A moment still well ahead is the day's cap this
|
|
72
|
+
// class put on its own timeout, so it is armed again rather than fired: a
|
|
73
|
+
// terrain calls this and never has to know how far out the arming was.
|
|
74
|
+
async rang(): Promise<Woke[]> {
|
|
75
|
+
if (this.#stopped) return [];
|
|
76
|
+
if (this.#armed !== null && this.#armed - Date.now() > 1_000) {
|
|
77
|
+
this.arm(this.#armed);
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return this.fire(Date.now());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The moment, read here and handed on. A device's own code may call it at a
|
|
84
|
+
// moment it names, which is how a suite proves a wake without waiting for
|
|
85
|
+
// one and how an operator makes a round happen now.
|
|
86
|
+
fire(now: number): Promise<Woke[]> {
|
|
87
|
+
return this.round(now);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One round, and never two: a second while one is in flight is handed the
|
|
91
|
+
// one already running. What every watcher answered decides the next
|
|
92
|
+
// arming, and a `due` that landed while the round ran is not lost.
|
|
93
|
+
protected round(now: number, only?: string): Promise<Woke[]> {
|
|
94
|
+
if (this.#round) return this.#round;
|
|
95
|
+
const mine = this.#run(now, only);
|
|
96
|
+
this.#round = mine;
|
|
97
|
+
return mine.finally(() => {
|
|
98
|
+
if (this.#round === mine) this.#round = null;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async #run(now: number, only?: string): Promise<Woke[]> {
|
|
103
|
+
if (only === undefined) this.#arm(null); // the arming is spent, whether it went off or the device called this by hand
|
|
104
|
+
const woke = await this.woke((w) => ({ ...w.args, now }), only);
|
|
105
|
+
if (woke === null) {
|
|
106
|
+
this.stop(); // her ward took her out; there is nobody left to wake
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
// A moment already gone is no arming. Every watcher works out when she is
|
|
110
|
+
// next due from the moment she was handed, so a round fired by hand at a
|
|
111
|
+
// moment that is not this box's answers one that is not this box's
|
|
112
|
+
// either, and a box that armed for it would run a round for a minute long
|
|
113
|
+
// past. A round at the box's own moment always names one still ahead.
|
|
114
|
+
const named = soonest(woke);
|
|
115
|
+
const said = named !== null && named > Date.now() ? named : null;
|
|
116
|
+
const held = this.#armed;
|
|
117
|
+
const at = said === null ? held : held === null ? moment(said) : Math.min(held, moment(said));
|
|
118
|
+
if (at !== null && at !== this.#armed) this.#arm(at);
|
|
119
|
+
else if (at === null && this.#armed !== null) this.#arm(null);
|
|
120
|
+
return woke;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#arm(when: number | null): void {
|
|
124
|
+
if (this.#stopped) return;
|
|
125
|
+
this.#armed = when;
|
|
126
|
+
this.arm(when);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The one thing a terrain says about itself: be woken at this moment, or at
|
|
130
|
+
// no moment at all. A subclass replaces this and nothing else of her.
|
|
131
|
+
//
|
|
132
|
+
// Here it is a timeout, capped at a day and armed again when the cap goes
|
|
133
|
+
// off. The box stays up for its work and never for this, so the handle is
|
|
134
|
+
// unreferenced.
|
|
135
|
+
protected arm(when: number | null): void {
|
|
136
|
+
clearTimeout(this.#timeout);
|
|
137
|
+
this.#timeout = undefined;
|
|
138
|
+
if (when === null) return;
|
|
139
|
+
this.#timeout = setTimeout(() => void this.rang(), Math.min(Math.max(when - Date.now(), 0), DAY));
|
|
140
|
+
this.#timeout.unref?.();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A being her ward took out reaches nobody, so nothing is left to wake. The
|
|
144
|
+
// device calls this when it puts the box down.
|
|
145
|
+
stop(): void {
|
|
146
|
+
this.#stopped = true;
|
|
147
|
+
this.#armed = null;
|
|
148
|
+
this.arm(null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
now(): JsonObject {
|
|
152
|
+
return { now: Date.now() };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Half a second past the minute a moment falls in, and never before the
|
|
157
|
+
// moment itself: a watcher floors what she is handed, so a round that fired a
|
|
158
|
+
// hair early would floor to the minute before and fire nothing.
|
|
159
|
+
const moment = (at: number): number => Math.max(at, Math.floor(at / MINUTE) * MINUTE + 500);
|
|
160
|
+
|
|
161
|
+
// What one round learned. Every watcher says when she is next due, and the
|
|
162
|
+
// box arms for the earliest of them. A watcher who named no moment has
|
|
163
|
+
// nothing to be woken for, and so has one who answered something that is no
|
|
164
|
+
// tally at all.
|
|
165
|
+
function soonest(woke: Woke[]): number | null {
|
|
166
|
+
let at: number | null = null;
|
|
167
|
+
for (const w of woke) {
|
|
168
|
+
if (w.out === null || typeof w.out !== 'object') continue;
|
|
169
|
+
const next = (w.out as { next?: unknown }).next;
|
|
170
|
+
if (typeof next !== 'number' || !Number.isFinite(next)) continue;
|
|
171
|
+
if (at === null || next < at) at = next;
|
|
172
|
+
}
|
|
173
|
+
return at;
|
|
174
|
+
}
|
package/dock/webpush.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Web Push as a bell ground: the one platform whose keys we mint ourselves.
|
|
3
|
+
// A browser hands a page an endpoint at whichever push service that browser
|
|
4
|
+
// keeps, and anyone holding the endpoint may ring it; what makes the ring
|
|
5
|
+
// this world's is VAPID, RFC 8292, a P-256 keypair the world owns and a
|
|
6
|
+
// signed token naming the push service it is speaking to. No account, no
|
|
7
|
+
// registration and no platform key: `mintVapid` is where a world's keypair
|
|
8
|
+
// comes from, and the public half is what a page subscribes with.
|
|
9
|
+
//
|
|
10
|
+
// The ring carries the line itself, and no second seal. RFC 8291 encrypts
|
|
11
|
+
// a Web Push body to keys only that browser holds, the `p256dh` and the
|
|
12
|
+
// `auth` of the subscription it made, so the one party a banner key would
|
|
13
|
+
// hide the line from on this path is the push service, which that
|
|
14
|
+
// encryption already excludes. A second seal would buy nothing and would
|
|
15
|
+
// make every tab stand a dock ward to keep a banner key in. So the world
|
|
16
|
+
// encrypts here, the browser decrypts, and the service worker shows what it
|
|
17
|
+
// was handed rather than a line it guessed at.
|
|
18
|
+
//
|
|
19
|
+
// A ring with no line is still a ring: the body is left off and the device
|
|
20
|
+
// wakes, dials and drains what it missed over Quo, which is what a world
|
|
21
|
+
// with nothing to show sends.
|
|
22
|
+
//
|
|
23
|
+
// This is one ground of the three the bell has. APNs and FCM are the
|
|
24
|
+
// stores' own and want a platform key; this one stands where nothing is in
|
|
25
|
+
// the way, a keypair the world mints itself.
|
|
26
|
+
import type { Line, Ring, Token } from './push.ts';
|
|
27
|
+
|
|
28
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
29
|
+
const ES256 = { name: 'ECDSA', namedCurve: 'P-256' } as const;
|
|
30
|
+
|
|
31
|
+
const b64url = (bytes: Uint8Array): string => {
|
|
32
|
+
let binary = '';
|
|
33
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
34
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
35
|
+
};
|
|
36
|
+
const unb64url = (v: string): Uint8Array => {
|
|
37
|
+
const binary = atob(v.replaceAll('-', '+').replaceAll('_', '/'));
|
|
38
|
+
const out = new Uint8Array(binary.length);
|
|
39
|
+
for (let at = 0; at < binary.length; at += 1) out[at] = binary.charCodeAt(at);
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
const utf8 = (v: string) => new TextEncoder().encode(v);
|
|
43
|
+
|
|
44
|
+
// What a world holds: the private key as a JWK, and the address a push
|
|
45
|
+
// service may complain to, which VAPID wants as `mailto:` or `https:`.
|
|
46
|
+
export type Vapid = { key: JsonWebKey; subject: string };
|
|
47
|
+
|
|
48
|
+
// The key a page subscribes with, `applicationServerKey`: the public half
|
|
49
|
+
// as the 65 uncompressed bytes, base64url. It is public by definition and
|
|
50
|
+
// travels in the world's config beside its routes.
|
|
51
|
+
export function publicOf(key: JsonWebKey): string {
|
|
52
|
+
if (typeof key.x !== 'string' || typeof key.y !== 'string') throw new Error('a vapid key is a P-256 JWK');
|
|
53
|
+
const x = unb64url(key.x),
|
|
54
|
+
y = unb64url(key.y);
|
|
55
|
+
const raw = new Uint8Array(65);
|
|
56
|
+
raw[0] = 4;
|
|
57
|
+
raw.set(x, 1);
|
|
58
|
+
raw.set(y, 33);
|
|
59
|
+
return b64url(raw);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A world's own keypair, minted once and kept as its secret. The private
|
|
63
|
+
// half is the JWK; the public half is what a page subscribes with.
|
|
64
|
+
export async function mintVapid(): Promise<{ key: JsonWebKey; publicKey: string }> {
|
|
65
|
+
const pair = await subtle().generateKey(ES256, true, ['sign', 'verify']);
|
|
66
|
+
const key = await subtle().exportKey('jwk', pair.privateKey);
|
|
67
|
+
return { key, publicKey: publicOf(key) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The signed token one push service is handed, good for twelve hours. The
|
|
71
|
+
// audience is that service's origin and nothing finer: a token minted for
|
|
72
|
+
// one service says nothing to another, and it names no endpoint, so it
|
|
73
|
+
// cannot be read back into which device was rung.
|
|
74
|
+
async function vapidToken(v: Vapid, endpoint: string): Promise<string> {
|
|
75
|
+
const key = await subtle().importKey('jwk', v.key, ES256, false, ['sign']);
|
|
76
|
+
const head = b64url(utf8(JSON.stringify({ typ: 'JWT', alg: 'ES256' })));
|
|
77
|
+
const claims = b64url(utf8(JSON.stringify({ aud: new URL(endpoint).origin, exp: Math.floor(Date.now() / 1000) + 12 * 3600, sub: v.subject })));
|
|
78
|
+
const signed = await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, key, utf8(`${head}.${claims}`));
|
|
79
|
+
return `${head}.${claims}.${b64url(new Uint8Array(signed))}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// What a push service will carry, RFC 8291's own ceiling, and what the
|
|
83
|
+
// record costs before a byte of the line: the salt, the record size, the
|
|
84
|
+
// length of the key, the key itself, the padding delimiter and the GCM tag.
|
|
85
|
+
const RECORD = 4096;
|
|
86
|
+
const OVERHEAD = 16 + 4 + 1 + 65 + 1 + 16;
|
|
87
|
+
|
|
88
|
+
const hmac = async (key: Uint8Array, data: Uint8Array): Promise<Uint8Array> => {
|
|
89
|
+
const k = await subtle().importKey('raw', key as BufferSource, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
90
|
+
return new Uint8Array(await subtle().sign('HMAC', k, data as BufferSource));
|
|
91
|
+
};
|
|
92
|
+
const joined = (...parts: Uint8Array[]): Uint8Array => {
|
|
93
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
94
|
+
let at = 0;
|
|
95
|
+
for (const p of parts) {
|
|
96
|
+
out.set(p, at);
|
|
97
|
+
at += p.length;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// The keys a subscription hands a world, which RFC 8291 encrypts to: the
|
|
103
|
+
// browser's own P-256 public point and the sixteen bytes of authentication
|
|
104
|
+
// secret it minted with it. A token without them is a subscription made
|
|
105
|
+
// before this or one a browser gave no keys for, and it is rung with no
|
|
106
|
+
// body rather than refused.
|
|
107
|
+
function keysOf(token: Token): { ua: Uint8Array; auth: Uint8Array } | null {
|
|
108
|
+
if (typeof token.p256dh !== 'string' || typeof token.auth !== 'string') return null;
|
|
109
|
+
try {
|
|
110
|
+
const ua = unb64url(token.p256dh),
|
|
111
|
+
auth = unb64url(token.auth);
|
|
112
|
+
return ua.length === 65 && ua[0] === 4 && auth.length === 16 ? { ua, auth } : null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One record, `aes128gcm`, RFC 8188 with the key derivation of RFC 8291.
|
|
119
|
+
// The world mints a keypair for this ring alone, agrees with the browser's,
|
|
120
|
+
// and salts each record freshly, so two rings to one device share no key
|
|
121
|
+
// and no nonce.
|
|
122
|
+
export async function encrypt(line: Line, ua: Uint8Array, auth: Uint8Array): Promise<Uint8Array> {
|
|
123
|
+
const pair = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
124
|
+
const as = new Uint8Array(await subtle().exportKey('raw', pair.publicKey));
|
|
125
|
+
const theirs = await subtle().importKey('raw', ua as BufferSource, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
126
|
+
const shared = new Uint8Array(await subtle().deriveBits({ name: 'ECDH', public: theirs }, pair.privateKey, 256));
|
|
127
|
+
|
|
128
|
+
// The input keying material is the agreement, stretched under the
|
|
129
|
+
// subscription's auth secret and bound to both public keys, so a record
|
|
130
|
+
// cannot be replayed at a device it was not encrypted for.
|
|
131
|
+
const ikm = await hmac(await hmac(auth, shared), joined(utf8('WebPush: info\0'), ua, as, Uint8Array.of(1)));
|
|
132
|
+
const salt = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
133
|
+
const prk = await hmac(salt, ikm);
|
|
134
|
+
const cek = (await hmac(prk, joined(utf8('Content-Encoding: aes128gcm\0'), Uint8Array.of(1)))).slice(0, 16);
|
|
135
|
+
const nonce = (await hmac(prk, joined(utf8('Content-Encoding: nonce\0'), Uint8Array.of(1)))).slice(0, 12);
|
|
136
|
+
|
|
137
|
+
// One record, so the padding delimiter is 2 and there is nothing after
|
|
138
|
+
// it. A line too long for what a push service carries is cut at the body,
|
|
139
|
+
// which is where a lock screen cuts it anyway.
|
|
140
|
+
let text = utf8(JSON.stringify(line));
|
|
141
|
+
if (text.length > RECORD - OVERHEAD) {
|
|
142
|
+
const room = RECORD - OVERHEAD - utf8(JSON.stringify({ ...line, body: '' })).length;
|
|
143
|
+
text = utf8(JSON.stringify({ ...line, body: new TextDecoder().decode(utf8(line.body).slice(0, Math.max(0, room))) }));
|
|
144
|
+
}
|
|
145
|
+
const key = await subtle().importKey('raw', cek as BufferSource, 'AES-GCM', false, ['encrypt']);
|
|
146
|
+
const sealed = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv: nonce as BufferSource }, key, joined(text, Uint8Array.of(2)) as BufferSource));
|
|
147
|
+
|
|
148
|
+
const head = new Uint8Array(21);
|
|
149
|
+
head.set(salt, 0);
|
|
150
|
+
new DataView(head.buffer).setUint32(16, RECORD);
|
|
151
|
+
head[20] = as.length;
|
|
152
|
+
return joined(head, as, sealed);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The ground. A `web` token is an endpoint and, when the browser gave them,
|
|
156
|
+
// the two keys of its own subscription, which is what a line is encrypted
|
|
157
|
+
// to. A token with no keys is rung with no body.
|
|
158
|
+
export function webPush(v: Vapid): Ring {
|
|
159
|
+
const publicKey = publicOf(v.key);
|
|
160
|
+
return {
|
|
161
|
+
async ring(token: Token, _sealed?: string, line?: Line) {
|
|
162
|
+
const endpoint = token.endpoint;
|
|
163
|
+
if (typeof endpoint !== 'string' || !/^https?:\/\//.test(endpoint)) throw new Error('a web token is an endpoint');
|
|
164
|
+
const keys = line ? keysOf(token) : null;
|
|
165
|
+
const body = keys && line ? await encrypt(line, keys.ua, keys.auth) : undefined;
|
|
166
|
+
const res = await fetch(endpoint, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: {
|
|
169
|
+
authorization: `vapid t=${await vapidToken(v, endpoint)}, k=${publicKey}`,
|
|
170
|
+
ttl: '60',
|
|
171
|
+
...(body ? { 'content-encoding': 'aes128gcm', 'content-type': 'application/octet-stream' } : {}),
|
|
172
|
+
},
|
|
173
|
+
...(body ? { body: body as BodyInit } : {}),
|
|
174
|
+
});
|
|
175
|
+
// 404 and 410 are the push service saying this endpoint is gone. The
|
|
176
|
+
// device sends a fresh one when it next comes up, so nothing here
|
|
177
|
+
// forgets a token: a world that dropped one would stop ringing a
|
|
178
|
+
// device that is merely off.
|
|
179
|
+
if (!res.ok) throw new Error(`the push service answered ${res.status}`);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The browser harbor: the core over a store, in a webview. A dialer and
|
|
3
|
+
// never a listener: it holds one socket per world it is connected to, and
|
|
4
|
+
// every ask to a pk it does not hold goes down a socket to the listener,
|
|
5
|
+
// which is the rendezvous. In a tab the store is IndexedDB under a name,
|
|
6
|
+
// and the lease is a web lock on that name, held for the life of the tab:
|
|
7
|
+
// a second tab on one origin meets the lock and is a screen, not a harbor.
|
|
8
|
+
// In an app the store is handed in, and the process that holds it is the
|
|
9
|
+
// lease: an app has one harbor and nothing else can open its files. The
|
|
10
|
+
// classes are the estate's beings, handed in with the bundle, plus whatever
|
|
11
|
+
// a page hands in beside them.
|
|
12
|
+
import type { BeingClass } from '@nervur-org/nervur';
|
|
13
|
+
import { User, Desk, Avatar, Presence, Courier } from '../beings/index.ts';
|
|
14
|
+
import { Harbor, dial, type Dialer, type Lend, type Store } from '@nervur-org/nervur/harbor';
|
|
15
|
+
import { FACULTY_CLASSES, settled, stand } from '../dock/index.ts';
|
|
16
|
+
import { Idb } from './idb.ts';
|
|
17
|
+
|
|
18
|
+
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar, Presence, Courier, ...FACULTY_CLASSES };
|
|
19
|
+
|
|
20
|
+
export class BrowserHarbor extends Harbor {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly dialers: Dialer[] = [];
|
|
23
|
+
readonly #locked: boolean;
|
|
24
|
+
#release: (() => void) | undefined;
|
|
25
|
+
|
|
26
|
+
// `lend` is what this box lends the beings of the wards it hosts: a name
|
|
27
|
+
// to an invitation on a being of its own ward. `stand` builds one; a bare
|
|
28
|
+
// tab hands none and every name answers null.
|
|
29
|
+
constructor(store: string | Store = 'quo', classes: Record<string, BeingClass> = {}, lend?: Lend) {
|
|
30
|
+
const s = typeof store === 'string' ? new Idb(store) : store;
|
|
31
|
+
super(s, async () => ({ ...BUILT_IN, ...classes }), lend);
|
|
32
|
+
this.name = typeof store === 'string' ? store : 'quo';
|
|
33
|
+
this.#locked = typeof store === 'string';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// What this box lends, by name. A bare tab says nothing, stands no ward of
|
|
37
|
+
// its own and lends nothing.
|
|
38
|
+
lent: Record<string, string> | undefined;
|
|
39
|
+
|
|
40
|
+
// What this box lends is said before it boots, and standing the ward is
|
|
41
|
+
// part of booting: a being born in any other ward lends from her first
|
|
42
|
+
// line, and a ground that could not reach it yet would leave her holding
|
|
43
|
+
// nothing until her next birth.
|
|
44
|
+
async stand(lent: Record<string, string>): Promise<void> {
|
|
45
|
+
this.lent = lent;
|
|
46
|
+
if (this.wards.size > 0) this.lend = await stand(this, lent);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Take the lock and boot every ward kept here. A lock already held is
|
|
50
|
+
// another tab's harbor over this seed, and this one refuses to boot.
|
|
51
|
+
override async boot(): Promise<void> {
|
|
52
|
+
if (this.#locked) await this.#lease();
|
|
53
|
+
if (this.lent) this.lend = await stand(this, this.lent);
|
|
54
|
+
await super.boot();
|
|
55
|
+
// Every being who takes what this box lends does it at her birth, and
|
|
56
|
+
// birth is synchronous while taking a standing is not. A box is up when
|
|
57
|
+
// they have landed, not before, here as on a daemon: a page painted
|
|
58
|
+
// ahead of them shows a device that can do nothing.
|
|
59
|
+
await settled();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Hold one socket to a world's quo. route.
|
|
63
|
+
dial(url: string): Dialer {
|
|
64
|
+
const d = dial(this, url);
|
|
65
|
+
this.dialers.push(d);
|
|
66
|
+
return d;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The device is back: a phone out of a pocket, an app to the foreground.
|
|
70
|
+
// Every socket is told, and one that died silently is dialed again.
|
|
71
|
+
wake(): void {
|
|
72
|
+
for (const d of this.dialers) d.wake();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async close(): Promise<void> {
|
|
76
|
+
for (const d of this.dialers.splice(0)) d.close();
|
|
77
|
+
(this.store as { close?(): void }).close?.();
|
|
78
|
+
this.#release?.();
|
|
79
|
+
this.#release = undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async #lease(): Promise<void> {
|
|
83
|
+
const locks = (globalThis.navigator as Navigator | undefined)?.locks;
|
|
84
|
+
if (!locks) return; // no web locks on this terrain: nothing vouches, as in the memory harbor
|
|
85
|
+
const granted = await new Promise<boolean>((ok) => {
|
|
86
|
+
void locks.request(`quo:${this.name}`, { ifAvailable: true }, (lock) => {
|
|
87
|
+
if (!lock) {
|
|
88
|
+
ok(false);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
ok(true);
|
|
92
|
+
return new Promise<void>((release) => (this.#release = release)); // held until close
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
if (!granted) throw new Error(`harbor ${this.name} is held by another tab`);
|
|
96
|
+
}
|
|
97
|
+
}
|