@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/human/app.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The app's shell: one device, many worlds, one screen. Where the tab is a
|
|
3
|
+
// page on a world's own origin and learns that world from the page it was
|
|
4
|
+
// served, the app is one origin of its own and every world is somewhere
|
|
5
|
+
// else, so it learns each by one fetch of its address and keeps the list of
|
|
6
|
+
// them itself. Nothing of a world's code ever runs here: an app is a
|
|
7
|
+
// published bundle on somebody's phone, and a world that could put code in
|
|
8
|
+
// it would be a world that owns the phone.
|
|
9
|
+
//
|
|
10
|
+
// One harbor, whichever the terrain has, and inside it one local ward per
|
|
11
|
+
// world joined, `worlds.ts`, the ward named by the far world's pk. The
|
|
12
|
+
// screen shows one world's relation at a time and the switcher moves
|
|
13
|
+
// between them. Everything under the switcher is `screen.ts` and a
|
|
14
|
+
// surface, the same as the tab: the app adds no way of showing a being.
|
|
15
|
+
import type { Harbor, Hosted } from '@nervur-org/nervur/harbor';
|
|
16
|
+
import type { Blueprint, Invitation, JsonObject } from '@nervur-org/nervur';
|
|
17
|
+
import { screenSide } from './screen.ts';
|
|
18
|
+
import { domSurface } from './dom.ts';
|
|
19
|
+
import { guest } from './guest.ts';
|
|
20
|
+
import { door, held as heldPage } from './door.ts';
|
|
21
|
+
import { USER } from '../beings/avatar.ts';
|
|
22
|
+
import { DOCK, CONSOLE, NOTIFICATIONS, settled } from '../dock/index.ts';
|
|
23
|
+
import { fresh, joined, localName, ordered, relations, world, type Joined, type Relation, type World } from './worlds.ts';
|
|
24
|
+
import { giveWorld, takeWorld, type Giving } from './move.ts';
|
|
25
|
+
import { invitationOf, reachOf } from '../beings/link.ts';
|
|
26
|
+
import { doorbell, drained, told, notice as noticeOf, type Bell, type Held } from './wake.ts';
|
|
27
|
+
import type { Token } from '../dock/index.ts';
|
|
28
|
+
import type { Config } from './tab.ts';
|
|
29
|
+
|
|
30
|
+
// What one world's address answers when an app asks it for JSON, and the
|
|
31
|
+
// only thing an app ever reads from a world besides its wards' own bytes.
|
|
32
|
+
export type Learn = (at: string) => Promise<Config>;
|
|
33
|
+
|
|
34
|
+
// The fetch, as every terrain but a test does it: the world's address, its
|
|
35
|
+
// own origin asking, and JSON. A world answers this to an app and to
|
|
36
|
+
// nobody else, `web.ts`.
|
|
37
|
+
export const learn: Learn = async (at) => {
|
|
38
|
+
const r = await fetch(at, { headers: { accept: 'application/json' } });
|
|
39
|
+
if (!r.ok) throw new Error(`${at} answered ${r.status}`);
|
|
40
|
+
return (await r.json()) as Config;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// A harbor the app can reach a world through: the browser's and the
|
|
44
|
+
// phone's both dial, since a device behind a network nobody can address
|
|
45
|
+
// reaches a world by the socket it opened and never the other way.
|
|
46
|
+
export type Dialing = Harbor & { dial(url: string): unknown };
|
|
47
|
+
|
|
48
|
+
// How this device is woken: one platform function handed to the shell, the
|
|
49
|
+
// token being the box's and no being's. The shell knows there is an ear and
|
|
50
|
+
// never what kind of ear it is.
|
|
51
|
+
//
|
|
52
|
+
// The ear answers the token this device is reachable at, or null, which is
|
|
53
|
+
// a device with no doorbell: a terrain with no push service behind it, a
|
|
54
|
+
// human who was asked and said no, or a human who has not been asked. Each
|
|
55
|
+
// of those works in every other way. It is asked again on every foreground,
|
|
56
|
+
// because a platform may hand a device a new token at any time and a stale
|
|
57
|
+
// one rings an empty house.
|
|
58
|
+
//
|
|
59
|
+
// What the human reads when the device was woken while nobody was looking
|
|
60
|
+
// is composed here from objects that crossed sealed, never from the ring,
|
|
61
|
+
// and shown by asking the dock ward's notifications being through the
|
|
62
|
+
// harbor's own pointer: the same act on every terrain, and no second body.
|
|
63
|
+
export type Ear = () => Promise<Token | null>;
|
|
64
|
+
|
|
65
|
+
export type AppOptions = {
|
|
66
|
+
harbor: Dialing;
|
|
67
|
+
kept: Joined;
|
|
68
|
+
root: HTMLElement;
|
|
69
|
+
learn?: Learn;
|
|
70
|
+
// A link the platform handed the app, opened before anything else: the
|
|
71
|
+
// world it names is joined, whether or not it was known.
|
|
72
|
+
opened?: { at: string; invitation?: Invitation };
|
|
73
|
+
ear?: Ear;
|
|
74
|
+
// Where this device's bell stands, as the build knows it. A shell handed
|
|
75
|
+
// none holds no doorbell and hands a world nothing, which is what a world
|
|
76
|
+
// with no bell behind it looks like.
|
|
77
|
+
bell?: Bell;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type Running = {
|
|
81
|
+
// Show one world by its pk, as the switcher does.
|
|
82
|
+
show(pk: string): Promise<void>;
|
|
83
|
+
// Take a link now, as the platform hands one to a running app.
|
|
84
|
+
open(at: string, invitation?: Invitation): Promise<void>;
|
|
85
|
+
// The device is back: the terrain says so when the app comes to the
|
|
86
|
+
// foreground or a ring woke it. What it missed comes over here.
|
|
87
|
+
woken(): Promise<JsonObject[]>;
|
|
88
|
+
worlds(): World[];
|
|
89
|
+
close(): Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, text = '', attrs: Record<string, string> = {}): HTMLElementTagNameMap[K] => {
|
|
93
|
+
const e = document.createElement(tag);
|
|
94
|
+
if (text) e.textContent = text;
|
|
95
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
|
|
96
|
+
return e;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export async function app(o: AppOptions): Promise<Running> {
|
|
100
|
+
const look = o.learn ?? learn;
|
|
101
|
+
const nav = el('nav', '', { class: 'worlds nv-strip' });
|
|
102
|
+
const status = el('p', 'booting', { class: 'state nv-t-quiet' });
|
|
103
|
+
const screen = el('div');
|
|
104
|
+
o.root.append(nav, status, screen);
|
|
105
|
+
const say = (s: string) => (status.textContent = s);
|
|
106
|
+
|
|
107
|
+
// A device is one harbor, and this browser may already hold it in
|
|
108
|
+
// another tab. That tab is the device; this one is the held page, as a
|
|
109
|
+
// world's tab is, and it offers nothing that takes the lock.
|
|
110
|
+
try {
|
|
111
|
+
await o.harbor.boot();
|
|
112
|
+
} catch {
|
|
113
|
+
status.remove();
|
|
114
|
+
screen.innerHTML = heldPage({ host: location.host });
|
|
115
|
+
const none = async () => {};
|
|
116
|
+
return { show: none, open: none, woken: async () => [], worlds: () => [], close: none };
|
|
117
|
+
}
|
|
118
|
+
// Every world this device holds is dialed, not only the one on screen: a
|
|
119
|
+
// world reaches a device by the socket the device opened, and a device
|
|
120
|
+
// that dialed only what it is looking at could be woken by nothing else.
|
|
121
|
+
const dialed = new Set<string>();
|
|
122
|
+
const dial = (quo: string) => {
|
|
123
|
+
if (dialed.has(quo)) return;
|
|
124
|
+
dialed.add(quo);
|
|
125
|
+
o.harbor.dial(quo);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
let list = o.kept.read();
|
|
129
|
+
let showing: string | null = null;
|
|
130
|
+
let side: { close(): Promise<void> } | null = null;
|
|
131
|
+
|
|
132
|
+
// How this device is woken, asked of the terrain and never decided here.
|
|
133
|
+
let heard: Token | null = null;
|
|
134
|
+
const listen = async (): Promise<Token | null> => {
|
|
135
|
+
if (!o.ear) return null;
|
|
136
|
+
try {
|
|
137
|
+
heard = await o.ear();
|
|
138
|
+
} catch {
|
|
139
|
+
heard = null; // a device whose ear will not answer is a device with no doorbell, and everything else stands
|
|
140
|
+
}
|
|
141
|
+
return heard;
|
|
142
|
+
};
|
|
143
|
+
// This device's doorbell, at the bell the build named. It is taken once
|
|
144
|
+
// and kept, and the platform's token goes to it and to nowhere else.
|
|
145
|
+
let bellAt: Held | null = null;
|
|
146
|
+
|
|
147
|
+
// Telling one world how to wake this device, which is `wake.ts`'s and not
|
|
148
|
+
// the shell's: the shell holds the doorbell and the ear, and says where
|
|
149
|
+
// the ward is saved.
|
|
150
|
+
const tell = async (ward: Hosted, rel: Relation) => {
|
|
151
|
+
await told(rel.avatar, bellAt, heard);
|
|
152
|
+
await ward.save(); // her keys rotated on those asks
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// What the switcher switches between is the human's versions of
|
|
156
|
+
// themselves, one world each and a ward each: an employee at one world, a
|
|
157
|
+
// consultant at a second, family at a third, and never one of them
|
|
158
|
+
// knowing about another. So a device with one version has no switcher at
|
|
159
|
+
// all, since a control over one thing can only do nothing. It is taken
|
|
160
|
+
// out of the page rather than hidden, because the stylesheet lays it out
|
|
161
|
+
// by class and a class beats the hidden attribute.
|
|
162
|
+
//
|
|
163
|
+
// Its last entry is never a world: it is this device, the box's own ward
|
|
164
|
+
// shown through the Console, where a human sees what the box lends and
|
|
165
|
+
// leaves a world. A box that lends nothing has no such page.
|
|
166
|
+
const switcher = () => {
|
|
167
|
+
nav.replaceChildren();
|
|
168
|
+
const device = hasDevice();
|
|
169
|
+
if (list.length + (device ? 1 : 0) < 2) {
|
|
170
|
+
nav.remove();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!nav.isConnected) o.root.insertBefore(nav, o.root.firstChild);
|
|
174
|
+
for (const w of ordered(list)) {
|
|
175
|
+
const b = el('button', w.name || w.pk.slice(0, 8), { type: 'button', class: w.home ? 'home nv-chip' : 'nv-chip' });
|
|
176
|
+
if (w.pk === showing) b.setAttribute('aria-current', 'true');
|
|
177
|
+
b.onclick = () => void show(w.pk).catch((e: unknown) => say(String(e)));
|
|
178
|
+
nav.append(b);
|
|
179
|
+
}
|
|
180
|
+
if (device) {
|
|
181
|
+
const b = el('button', 'this device', { type: 'button', class: 'device nv-chip' });
|
|
182
|
+
if (showing === DEVICE) b.setAttribute('aria-current', 'true');
|
|
183
|
+
b.onclick = () => void showDevice().catch((e: unknown) => say(String(e)));
|
|
184
|
+
nav.append(b);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const remember = (w: World) => {
|
|
189
|
+
list = joined(list, w);
|
|
190
|
+
o.kept.write(list);
|
|
191
|
+
switcher();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// One world on screen: its ward, whoever was in it last, and her page.
|
|
195
|
+
// A world whose address will not answer is still a world: the ward and
|
|
196
|
+
// the standings are here, and the switcher keeps it.
|
|
197
|
+
const show = async (pk: string) => {
|
|
198
|
+
const w = list.find((x) => x.pk === pk);
|
|
199
|
+
if (!w) return void say('no such world');
|
|
200
|
+
showing = pk;
|
|
201
|
+
await side?.close();
|
|
202
|
+
side = null;
|
|
203
|
+
screen.replaceChildren();
|
|
204
|
+
held.remove();
|
|
205
|
+
if (!status.isConnected) o.root.insertBefore(status, screen);
|
|
206
|
+
say(`reaching ${w.name || w.at}`);
|
|
207
|
+
switcher();
|
|
208
|
+
|
|
209
|
+
let cfg: Config | null = null;
|
|
210
|
+
try {
|
|
211
|
+
cfg = await look(w.at);
|
|
212
|
+
dial(cfg.quo);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
say(`${w.name || w.at} did not answer: ${e instanceof Error ? e.message : String(e)}`);
|
|
215
|
+
}
|
|
216
|
+
const ward = await world(o.harbor, pk);
|
|
217
|
+
const named = cfg ? Object.entries(cfg.wards).find(([, v]) => v.pk === pk) : undefined;
|
|
218
|
+
if (cfg && named) remember({ pk, at: w.at, name: named[0] });
|
|
219
|
+
|
|
220
|
+
const rels = relations(ward);
|
|
221
|
+
const back = rels[rels.length - 1];
|
|
222
|
+
if (back) {
|
|
223
|
+
const had = await back.avatar.tools();
|
|
224
|
+
await ward.save(); // her keys rotated on that ask, whatever it answered
|
|
225
|
+
if ('asks' in had) return inside(ward, back, `in, at ${w.name || named?.[0] || w.at}`, had);
|
|
226
|
+
say(`not in (${had.error})`);
|
|
227
|
+
}
|
|
228
|
+
await atDoor(ward, pk, w, named?.[1]?.public === true);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const inside = async (ward: Awaited<ReturnType<typeof world>>, rel: Relation, notice: string, bp: Blueprint) => {
|
|
232
|
+
// Whether this world is a home is the user being's word, in her notes,
|
|
233
|
+
// and the switcher puts home first
|
|
234
|
+
const w = list.find((x) => x.pk === showing);
|
|
235
|
+
if (w) remember({ ...w, home: isHome(bp) });
|
|
236
|
+
await side?.close();
|
|
237
|
+
// A device has no surface of its own, and nothing of it is handed to a
|
|
238
|
+
// world: what this box can do is beings of its own ward, and the being
|
|
239
|
+
// of this world who lives on this box holds standings at them. What a
|
|
240
|
+
// world reaches is her, on the standing it already holds.
|
|
241
|
+
const note = notice;
|
|
242
|
+
status.remove();
|
|
243
|
+
screen.replaceChildren();
|
|
244
|
+
// The world's page address travels with the side, since that is what an
|
|
245
|
+
// invitation she answers becomes a link on: a being knows no routes and
|
|
246
|
+
// the side is where one is composed, in an app exactly as in a tab.
|
|
247
|
+
side = await screenSide(rel.avatar, domSurface(screen), {
|
|
248
|
+
after: () => ward.save(),
|
|
249
|
+
notice: note,
|
|
250
|
+
// A world that ended the relation is over, so the list of worlds is
|
|
251
|
+
// put up with its way out in reach. Nothing is dropped: one answer at
|
|
252
|
+
// one moment cannot delete a ward, and the far world is never asked.
|
|
253
|
+
ended: async () => {
|
|
254
|
+
leaving();
|
|
255
|
+
screen.after(held);
|
|
256
|
+
},
|
|
257
|
+
...(w ? { at: w.at } : {}),
|
|
258
|
+
});
|
|
259
|
+
// This world learns how to wake this device, and hands over whatever it
|
|
260
|
+
// pushed while nobody could be reached. Both are ordinary asks on this
|
|
261
|
+
// relation, sealed like every other, and each object lands on the avatar
|
|
262
|
+
// exactly where a push would have landed.
|
|
263
|
+
await tell(ward, rel);
|
|
264
|
+
if (showing && showing !== DEVICE) await drained(o.harbor, [showing]);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// At the door of a world this device has no standing in: the public
|
|
268
|
+
// being's page when there is one, and a form whose answer is an
|
|
269
|
+
// invitation is the way in; the door page when there is nobody, since
|
|
270
|
+
// then a link is the only way and there is nothing to type.
|
|
271
|
+
const atDoor = async (ward: Awaited<ReturnType<typeof world>>, pk: string, w: World, isPublic: boolean) => {
|
|
272
|
+
if (!isPublic) {
|
|
273
|
+
status.remove();
|
|
274
|
+
screen.innerHTML = door({ world: w.name || undefined, host: hostOf(w.at) });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
say(`at the door of ${w.name || hostOf(w.at)}`);
|
|
278
|
+
const rel = await fresh(ward);
|
|
279
|
+
const gate = guest(rel.avatar, pk);
|
|
280
|
+
const s = await screenSide(gate, domSurface(screen), {
|
|
281
|
+
after: () => ward.save(),
|
|
282
|
+
notice: `a guest at ${w.name || hostOf(w.at)}: what she shows strangers`,
|
|
283
|
+
at: w.at,
|
|
284
|
+
admit: async (inv) => {
|
|
285
|
+
await s.close();
|
|
286
|
+
await admit(ward, inv, `in, as a guest of ${w.name || hostOf(w.at)}`);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
side = s;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// The box's own ward, as its harbor stood it: the Console carrying every
|
|
293
|
+
// faculty this box lends. A box that lends nothing stands none, and there
|
|
294
|
+
// is no page of its own to show.
|
|
295
|
+
const deviceWard = async (): Promise<Hosted | null> => {
|
|
296
|
+
const ward = o.harbor.wards.get(DOCK);
|
|
297
|
+
if (!ward || !ward.being(CONSOLE)) return null;
|
|
298
|
+
return ward;
|
|
299
|
+
};
|
|
300
|
+
const hasDevice = (): boolean => o.harbor.wards.get(DOCK)?.being(CONSOLE) !== undefined;
|
|
301
|
+
|
|
302
|
+
// The device shown by itself: the last entry of the switcher, and the
|
|
303
|
+
// whole screen of a device that has joined no world. A fresh install has
|
|
304
|
+
// no world's page to carry her forms, and a first screen that says only
|
|
305
|
+
// "open a link someone sent you" is an app with permissions in its
|
|
306
|
+
// manifest and nothing that ever asks for them, which is a store
|
|
307
|
+
// reviewer's first question and a human's second.
|
|
308
|
+
//
|
|
309
|
+
// She is shown by the ordinary screen and by no other way: one avatar in
|
|
310
|
+
// the box's own ward holding her standing under the name a world's user
|
|
311
|
+
// being would stand under, so the side sees what it always sees. The root
|
|
312
|
+
// places it, because the root is the box.
|
|
313
|
+
const deviceScreen = async (): Promise<{ ward: Hosted; rel: Relation } | null> => {
|
|
314
|
+
const ward = await deviceWard();
|
|
315
|
+
if (!ward) return null;
|
|
316
|
+
const had = relations(ward)[0];
|
|
317
|
+
if (had) return { ward, rel: had };
|
|
318
|
+
const rel = await fresh(ward);
|
|
319
|
+
const inv = await ward.ask('invite', { being: CONSOLE, id: rel.key });
|
|
320
|
+
if (!inv || typeof inv !== 'object') throw new Error('this device is already held under that name');
|
|
321
|
+
const took = (await ward.ask('knock', { being: rel.key, id: USER, invitation: inv as unknown as JsonObject })) as { taken?: string; error?: string };
|
|
322
|
+
if (took.taken !== USER) throw new Error(took.error ?? 'the shell could not take this device');
|
|
323
|
+
await ward.save();
|
|
324
|
+
return { ward, rel };
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// The device's page: what the box lends, as forms that ask the faculty
|
|
328
|
+
// and show its answer, a refusal included; the worlds this device is in,
|
|
329
|
+
// each with a way out; and the one sentence about custody a human needs.
|
|
330
|
+
const showDevice = async () => {
|
|
331
|
+
let found: { ward: Hosted; rel: Relation } | null = null;
|
|
332
|
+
try {
|
|
333
|
+
found = await deviceScreen();
|
|
334
|
+
} catch (e) {
|
|
335
|
+
return void say(`this device would not answer: ${e instanceof Error ? e.message : String(e)}`);
|
|
336
|
+
}
|
|
337
|
+
if (!found) return void say('this device lends nothing');
|
|
338
|
+
const { ward, rel } = found;
|
|
339
|
+
showing = DEVICE;
|
|
340
|
+
await side?.close();
|
|
341
|
+
side = null;
|
|
342
|
+
screen.replaceChildren();
|
|
343
|
+
if (!status.isConnected) o.root.insertBefore(status, screen);
|
|
344
|
+
say('');
|
|
345
|
+
switcher();
|
|
346
|
+
side = await screenSide(rel.avatar, domSurface(screen), { after: () => ward.save(), notice: CUSTODY });
|
|
347
|
+
leaving();
|
|
348
|
+
screen.after(held);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// The worlds this device is in, each with a way out and a way to another
|
|
352
|
+
// device. Leaving is the local ward dropped whole, seed and every
|
|
353
|
+
// standing in it, and the world forgotten from the list: the far world
|
|
354
|
+
// is never asked and never told, as WORLDS says of a lost phone, and it
|
|
355
|
+
// sees one device gone. Moving is the Courier: this device offers, the
|
|
356
|
+
// human carries the offer to the other device, and this copy goes the
|
|
357
|
+
// moment the other one says it landed.
|
|
358
|
+
const held = el('section', '', { class: 'held' });
|
|
359
|
+
const offer = el('p', '', { class: 'invitation' });
|
|
360
|
+
let giving: Giving | null = null;
|
|
361
|
+
const stopGiving = async () => {
|
|
362
|
+
const g = giving;
|
|
363
|
+
giving = null;
|
|
364
|
+
offer.textContent = '';
|
|
365
|
+
await g?.close();
|
|
366
|
+
leaving();
|
|
367
|
+
};
|
|
368
|
+
const give = async (w: World) => {
|
|
369
|
+
if (giving) return stopGiving();
|
|
370
|
+
const cfg = await look(w.at);
|
|
371
|
+
dial(cfg.quo);
|
|
372
|
+
giving = await giveWorld(o.harbor, w, cfg.quo);
|
|
373
|
+
offer.textContent = giving.offer;
|
|
374
|
+
leaving();
|
|
375
|
+
void giving.moved.then(async () => {
|
|
376
|
+
// it is on the other device now, and this one is out of the world
|
|
377
|
+
giving = null;
|
|
378
|
+
offer.textContent = '';
|
|
379
|
+
list = list.filter((x) => x.pk !== w.pk);
|
|
380
|
+
o.kept.write(list);
|
|
381
|
+
leaving();
|
|
382
|
+
switcher();
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
const leaving = () => {
|
|
386
|
+
held.replaceChildren();
|
|
387
|
+
if (list.length === 0) return;
|
|
388
|
+
held.append(el('p', 'the worlds this device is in', { class: 'quiet' }));
|
|
389
|
+
for (const w of list) {
|
|
390
|
+
const name = w.name || w.pk.slice(0, 8);
|
|
391
|
+
const b = el('button', `leave ${name}`, { type: 'button', 'data-leave': w.pk, class: 'nv-button', 'data-variant': 'danger' });
|
|
392
|
+
b.onclick = () => void leave(w.pk).catch((e: unknown) => say(String(e)));
|
|
393
|
+
const m = el('button', giving ? 'stop offering it' : `move ${name} to another device`, { type: 'button', 'data-give': w.pk, class: 'nv-button', 'data-variant': 'quiet' });
|
|
394
|
+
m.onclick = () => void give(w).catch((e: unknown) => say(String(e)));
|
|
395
|
+
held.append(b, m);
|
|
396
|
+
}
|
|
397
|
+
held.append(offer);
|
|
398
|
+
};
|
|
399
|
+
const leave = async (pk: string) => {
|
|
400
|
+
const name = localName(pk);
|
|
401
|
+
if (o.harbor.wards.has(name)) await o.harbor.drop(name);
|
|
402
|
+
list = list.filter((w) => w.pk !== pk);
|
|
403
|
+
o.kept.write(list);
|
|
404
|
+
leaving();
|
|
405
|
+
switcher();
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const cold = async () => {
|
|
409
|
+
await showDevice();
|
|
410
|
+
say('no worlds yet: open a link someone sent you');
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const admit = async (ward: Awaited<ReturnType<typeof world>>, inv: Invitation, notice: string) => {
|
|
414
|
+
say('knocking');
|
|
415
|
+
const rel = await fresh(ward);
|
|
416
|
+
let got = await rel.avatar.join(inv);
|
|
417
|
+
await ward.save(); // the knock went through her own door, which the harbor never sees
|
|
418
|
+
if (!('asks' in got)) return void say(`not in: ${got.error}`);
|
|
419
|
+
// What came back is the far being as she was during the knock. If
|
|
420
|
+
// anything on this device was still landing then, she read a device that
|
|
421
|
+
// can do nothing and carries that, and the empty ask is the only way
|
|
422
|
+
// anyone learns a shape moved: nothing pushes. So the page asks once
|
|
423
|
+
// more, with this box quiet, and paints what is there rather than what
|
|
424
|
+
// was there a moment ago. A page paints once, so asking once more here
|
|
425
|
+
// is the difference between a device with its faculties on screen and a
|
|
426
|
+
// device that looks empty until the person navigates away and back.
|
|
427
|
+
await settled();
|
|
428
|
+
const now = await rel.avatar.tools();
|
|
429
|
+
if ('asks' in now) got = now;
|
|
430
|
+
await inside(ward, rel, notice, got);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// A link, whether the app was opened by it or handed it while running.
|
|
434
|
+
// The invitation names its ward by pk, so the address in the link is
|
|
435
|
+
// where to reach that world and never which world it is.
|
|
436
|
+
const open = async (at: string, invitation?: Invitation) => {
|
|
437
|
+
let cfg: Config | null = null;
|
|
438
|
+
try {
|
|
439
|
+
cfg = await look(at);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
return void say(`${at} did not answer: ${e instanceof Error ? e.message : String(e)}`);
|
|
442
|
+
}
|
|
443
|
+
dial(cfg.quo);
|
|
444
|
+
const pk = invitation?.ward ?? (cfg.ward ? cfg.wards[cfg.ward]?.pk : undefined);
|
|
445
|
+
if (!pk) return void say('that link names no world');
|
|
446
|
+
const name = Object.entries(cfg.wards).find(([, v]) => v.pk === pk)?.[0] ?? '';
|
|
447
|
+
remember({ pk, at, name });
|
|
448
|
+
if (!invitation) return show(pk);
|
|
449
|
+
showing = pk;
|
|
450
|
+
switcher();
|
|
451
|
+
if (!status.isConnected) o.root.insertBefore(status, screen);
|
|
452
|
+
screen.replaceChildren();
|
|
453
|
+
await admit(await world(o.harbor, pk), invitation, 'in, by the link');
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Taking a world from another device. The device that holds the world
|
|
457
|
+
// shows an offer, an invitation next to the rendezvous to reach it
|
|
458
|
+
// through; the human carries it here, by eye or by camera, and the ward
|
|
459
|
+
// arrives whole: the avatars, the standings, everything won there, with
|
|
460
|
+
// the far world never asked and never told. This device dials the note
|
|
461
|
+
// first, because a device reaches a pk only through a listener it holds
|
|
462
|
+
// a socket to, and one that has joined no world dials nothing at all.
|
|
463
|
+
const taking = el('form', '', { class: 'move' });
|
|
464
|
+
const field = el('input', '', { name: 'offer', placeholder: 'the offer the other device shows', autocomplete: 'off', spellcheck: 'false' });
|
|
465
|
+
const said = el('p', '', { class: 'quiet' });
|
|
466
|
+
taking.append(el('label', 'take a world from another device'), field, el('button', 'take it', { type: 'submit', class: 'nv-button', 'data-variant': 'quiet' }), said);
|
|
467
|
+
o.root.append(taking);
|
|
468
|
+
taking.onsubmit = (e) => {
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
const inv = invitationOf(field.value);
|
|
471
|
+
if (!inv) return void (said.textContent = 'that is not an offer');
|
|
472
|
+
const note = reachOf(field.value);
|
|
473
|
+
if (note) dial(note);
|
|
474
|
+
said.textContent = 'taking it…';
|
|
475
|
+
void (async () => {
|
|
476
|
+
const out = await takeWorld(o.harbor, inv);
|
|
477
|
+
if ('error' in out) return void (said.textContent = out.error);
|
|
478
|
+
said.textContent = '';
|
|
479
|
+
field.value = '';
|
|
480
|
+
remember(out.world);
|
|
481
|
+
await show(out.world.pk);
|
|
482
|
+
})().catch((why: unknown) => (said.textContent = String(why)));
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// The device is back. A ring carries nothing, so waking is three moves and
|
|
486
|
+
// the harbor already made the first: it told its dialers, since a socket
|
|
487
|
+
// that died while the device slept died silently. What is left is to say
|
|
488
|
+
// the token again, because a platform may have handed out a new one, and
|
|
489
|
+
// to ask every world what it missed.
|
|
490
|
+
//
|
|
491
|
+
// Every world and not only the one on screen: a device is woken for
|
|
492
|
+
// whichever world had something to say, and one that drained only what it
|
|
493
|
+
// was looking at would leave the rest waiting for a second wake that may
|
|
494
|
+
// never come.
|
|
495
|
+
const woken = async (): Promise<JsonObject[]> => {
|
|
496
|
+
await listen();
|
|
497
|
+
// The token goes to this device's doorbell and to nobody else, which is
|
|
498
|
+
// the whole of a rotation: one write, and no world is told. A world
|
|
499
|
+
// that has no doorbell yet, because it was down when this device joined
|
|
500
|
+
// it, is given one here.
|
|
501
|
+
if (heard) await bellAt?.keep(heard);
|
|
502
|
+
for (const w of list) {
|
|
503
|
+
const ward = o.harbor.wards.get(localName(w.pk));
|
|
504
|
+
if (!ward) continue;
|
|
505
|
+
for (const rel of relations(ward)) await tell(ward, rel);
|
|
506
|
+
}
|
|
507
|
+
const got = await drained(o.harbor, list.map((w) => w.pk));
|
|
508
|
+
// What the human reads, composed here from what crossed sealed. Only
|
|
509
|
+
// when nobody was looking: a page that is open already shows every one
|
|
510
|
+
// of these where the push would have landed, and a banner over it would
|
|
511
|
+
// be the same news twice.
|
|
512
|
+
if (got.length && globalThis.document?.visibilityState !== 'visible') await shown(noticeOf(got));
|
|
513
|
+
return got;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// The line the human reads, shown by the box's own notifications being.
|
|
517
|
+
// A box that lends none shows nothing, and a refusal is hers to answer;
|
|
518
|
+
// the shell asks and does not read the answer, since the news is already
|
|
519
|
+
// on the page where the push landed.
|
|
520
|
+
const shown = async (line: { title: string; body: string }) => {
|
|
521
|
+
const dock = o.harbor.wards.get(DOCK);
|
|
522
|
+
if (!dock) return;
|
|
523
|
+
await dock.ask('ask', { being: NOTIFICATIONS, method: 'notify', args: line }).catch(() => {});
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// The device ward before anything is dialed: a harbor announces the wards
|
|
527
|
+
// it hosts when it opens a socket, and a ward made after that is a pk the
|
|
528
|
+
// far world cannot knock. A device whose ward will not stand is a device
|
|
529
|
+
// with no grounds and a shell that works in every other way, so nothing
|
|
530
|
+
// here stops the app; the cold screen is where that is said out loud.
|
|
531
|
+
try {
|
|
532
|
+
await deviceWard();
|
|
533
|
+
} catch {
|
|
534
|
+
/* said by the cold screen, which is the one place a device speaks for itself */
|
|
535
|
+
}
|
|
536
|
+
await listen();
|
|
537
|
+
// The bell is dialed like a world, because a device reaches a pk only
|
|
538
|
+
// through a listener it holds a socket to; then the doorbell, taken the
|
|
539
|
+
// first time this device ever ran and kept from then on, and the
|
|
540
|
+
// platform's token said to it.
|
|
541
|
+
if (o.bell) {
|
|
542
|
+
dial(o.bell.at);
|
|
543
|
+
bellAt = await doorbell(o.harbor, o.bell);
|
|
544
|
+
if (heard) await bellAt?.keep(heard);
|
|
545
|
+
}
|
|
546
|
+
switcher();
|
|
547
|
+
if (o.opened) await open(o.opened.at, o.opened.invitation);
|
|
548
|
+
else if (list.length) await show(list[list.length - 1].pk);
|
|
549
|
+
else await cold();
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
show,
|
|
553
|
+
open,
|
|
554
|
+
woken,
|
|
555
|
+
worlds: () => list,
|
|
556
|
+
close: async () => {
|
|
557
|
+
await side?.close();
|
|
558
|
+
side = null;
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// What the switcher shows when the device's own page is up: not a pk, and
|
|
564
|
+
// never a world.
|
|
565
|
+
const DEVICE = 'device';
|
|
566
|
+
|
|
567
|
+
// A world is a home when the being reached there says so: the user being
|
|
568
|
+
// writes `home` in her notes, and no other being does.
|
|
569
|
+
const isHome = (bp: Blueprint): boolean => bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) && (bp.notes as { home?: unknown }).home === true;
|
|
570
|
+
|
|
571
|
+
// The one sentence about custody a human needs, on the device's page.
|
|
572
|
+
const CUSTODY = 'this device: what it lends, and the worlds it is in. A reinstall starts fresh, so a world that holds nothing of you elsewhere must invite you again.';
|
|
573
|
+
|
|
574
|
+
const hostOf = (at: string) => {
|
|
575
|
+
try {
|
|
576
|
+
return new URL(at).host;
|
|
577
|
+
} catch {
|
|
578
|
+
return at;
|
|
579
|
+
}
|
|
580
|
+
};
|
package/human/apps.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// What a hostname says where the two platforms look before they let an app
|
|
3
|
+
// claim its links. The claim is a file, the platform fetches it over TLS,
|
|
4
|
+
// and a hostname that names no app is a hostname whose links open a
|
|
5
|
+
// browser.
|
|
6
|
+
//
|
|
7
|
+
// Two hostnames say it and neither may say it differently. A world's web
|
|
8
|
+
// route says it for the estate that ships an app of its own, and the Quo
|
|
9
|
+
// app's own domain says it for the app the stores carry, which serves no
|
|
10
|
+
// world and has no route. So the words are here, once, and both fetch them
|
|
11
|
+
// from the same place: Apple's format and Google's are the platforms' and
|
|
12
|
+
// change on their schedule, not ours, and a second copy would be a second
|
|
13
|
+
// thing to remember on the day one of them moves.
|
|
14
|
+
export type Apps = {
|
|
15
|
+
// `<team>.<bundle id>`, as Apple's file calls an app
|
|
16
|
+
apple: string[];
|
|
17
|
+
// the package and the signing certificates Android verifies it by
|
|
18
|
+
android: { package: string; fingerprints: string[] }[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The two paths, which are the platforms' and not ours.
|
|
22
|
+
export const APPLE = '/.well-known/apple-app-site-association';
|
|
23
|
+
export const ANDROID = '/.well-known/assetlinks.json';
|
|
24
|
+
|
|
25
|
+
// The claim `path` should answer with, or `null` when this hostname claims
|
|
26
|
+
// no app there: no apps at all, none of that platform, or a path that is
|
|
27
|
+
// neither file. `null` is a 404 and not an empty file, because an empty
|
|
28
|
+
// claim and no claim mean the same thing to a phone and only one of them
|
|
29
|
+
// is honest about a hostname that ships no app.
|
|
30
|
+
//
|
|
31
|
+
// `notApp` is the paths on this hostname that are the browser's even when
|
|
32
|
+
// the app is installed. A world's route has one, the credential exchange,
|
|
33
|
+
// which belongs in the browser that started it; the app's own domain has
|
|
34
|
+
// none, since it has no pages at all.
|
|
35
|
+
export function association(apps: Apps | null | undefined, path: string, notApp: readonly string[] = []): string | null {
|
|
36
|
+
if (!apps) return null;
|
|
37
|
+
if (path === ANDROID) {
|
|
38
|
+
if (!apps.android.length) return null;
|
|
39
|
+
return JSON.stringify(apps.android.map((a) => ({ relation: ['delegate_permission/common.handle_all_urls'], target: { namespace: 'android_app', package_name: a.package, sha256_cert_fingerprints: a.fingerprints } })));
|
|
40
|
+
}
|
|
41
|
+
if (path === APPLE) {
|
|
42
|
+
if (!apps.apple.length) return null;
|
|
43
|
+
const components = [...notApp.map((p) => ({ '/': p, exclude: true })), { '/': '/*' }];
|
|
44
|
+
return JSON.stringify({ applinks: { details: [{ appIDs: apps.apple, components }] } });
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
package/human/dom.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The surface on a document: a page is the root's HTML, and a submit
|
|
3
|
+
// anywhere under it is the form's fields as strings, handed to the side
|
|
4
|
+
// under the ask the form names. The one file under `human/` that touches an
|
|
5
|
+
// element, and it knows nothing of what the page says.
|
|
6
|
+
import type { Surface } from './screen.ts';
|
|
7
|
+
import type { Raw } from './html.ts';
|
|
8
|
+
|
|
9
|
+
export function domSurface(root: HTMLElement): Surface {
|
|
10
|
+
const s: Surface = { show: (html) => (root.innerHTML = html), onSubmit: undefined };
|
|
11
|
+
root.addEventListener('submit', (ev) => {
|
|
12
|
+
const f = ev.target as HTMLFormElement | null;
|
|
13
|
+
const ask = f?.dataset.ask;
|
|
14
|
+
if (!f || ask === undefined) return;
|
|
15
|
+
ev.preventDefault();
|
|
16
|
+
const raw: Raw = {};
|
|
17
|
+
for (const [k, v] of new FormData(f)) raw[k] = typeof v === 'string' ? v : '';
|
|
18
|
+
s.onSubmit?.(ask, raw);
|
|
19
|
+
});
|
|
20
|
+
return s;
|
|
21
|
+
}
|