@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.
Files changed (285) hide show
  1. package/GETTING_STARTED.md +128 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +6 -0
  4. package/README.md +37 -0
  5. package/api/quo-api.md +196 -0
  6. package/api/route.ts +227 -0
  7. package/beings/GLOSSARY.md +118 -0
  8. package/beings/WORLDS.md +567 -0
  9. package/beings/avatar.ts +101 -0
  10. package/beings/carry.ts +159 -0
  11. package/beings/clock.ts +552 -0
  12. package/beings/courier.ts +107 -0
  13. package/beings/desk.ts +128 -0
  14. package/beings/doorbell.ts +129 -0
  15. package/beings/envoy.ts +218 -0
  16. package/beings/index.ts +16 -0
  17. package/beings/join.ts +51 -0
  18. package/beings/link.ts +104 -0
  19. package/beings/look.ts +139 -0
  20. package/beings/porter.ts +43 -0
  21. package/beings/presence.ts +39 -0
  22. package/beings/quo-app-bells.md +280 -0
  23. package/beings/quo-dock.md +1942 -0
  24. package/beings/setup.ts +45 -0
  25. package/beings/side.ts +60 -0
  26. package/beings/user.ts +459 -0
  27. package/beings/way.ts +65 -0
  28. package/cli/bundles.ts +20 -0
  29. package/cli/client.ts +73 -0
  30. package/cli/daemon.ts +581 -0
  31. package/cli/estate/Caddyfile +33 -0
  32. package/cli/estate/quo.service +35 -0
  33. package/cli/estate/systems.quo.plist +44 -0
  34. package/cli/estate.ts +48 -0
  35. package/cli/folder.ts +27 -0
  36. package/cli/oauth.ts +32 -0
  37. package/cli/pilot.ts +38 -0
  38. package/cli/quo-dock.md +1942 -0
  39. package/cli/quo.ts +203 -0
  40. package/cli/stand.ts +160 -0
  41. package/dist/api/route.d.ts +21 -0
  42. package/dist/api/route.js +193 -0
  43. package/dist/beings/avatar.d.ts +32 -0
  44. package/dist/beings/avatar.js +100 -0
  45. package/dist/beings/carry.d.ts +12 -0
  46. package/dist/beings/carry.js +174 -0
  47. package/dist/beings/clock.d.ts +161 -0
  48. package/dist/beings/clock.js +528 -0
  49. package/dist/beings/courier.d.ts +50 -0
  50. package/dist/beings/courier.js +96 -0
  51. package/dist/beings/desk.d.ts +53 -0
  52. package/dist/beings/desk.js +135 -0
  53. package/dist/beings/doorbell.d.ts +78 -0
  54. package/dist/beings/doorbell.js +133 -0
  55. package/dist/beings/envoy.d.ts +54 -0
  56. package/dist/beings/envoy.js +211 -0
  57. package/dist/beings/index.d.ts +14 -0
  58. package/dist/beings/index.js +16 -0
  59. package/dist/beings/join.d.ts +15 -0
  60. package/dist/beings/join.js +42 -0
  61. package/dist/beings/link.d.ts +14 -0
  62. package/dist/beings/link.js +91 -0
  63. package/dist/beings/look.d.ts +30 -0
  64. package/dist/beings/look.js +104 -0
  65. package/dist/beings/porter.d.ts +14 -0
  66. package/dist/beings/porter.js +41 -0
  67. package/dist/beings/presence.d.ts +8 -0
  68. package/dist/beings/presence.js +21 -0
  69. package/dist/beings/setup.d.ts +3 -0
  70. package/dist/beings/setup.js +39 -0
  71. package/dist/beings/side.d.ts +32 -0
  72. package/dist/beings/side.js +37 -0
  73. package/dist/beings/user.d.ts +315 -0
  74. package/dist/beings/user.js +462 -0
  75. package/dist/beings/way.d.ts +19 -0
  76. package/dist/beings/way.js +28 -0
  77. package/dist/cli/bundles.d.ts +5 -0
  78. package/dist/cli/bundles.js +17 -0
  79. package/dist/cli/client.d.ts +15 -0
  80. package/dist/cli/client.js +58 -0
  81. package/dist/cli/daemon.d.ts +50 -0
  82. package/dist/cli/daemon.js +584 -0
  83. package/dist/cli/estate/Caddyfile +33 -0
  84. package/dist/cli/estate/quo.service +35 -0
  85. package/dist/cli/estate/systems.quo.plist +44 -0
  86. package/dist/cli/estate.d.ts +5 -0
  87. package/dist/cli/estate.js +50 -0
  88. package/dist/cli/folder.d.ts +2 -0
  89. package/dist/cli/folder.js +25 -0
  90. package/dist/cli/oauth.d.ts +2 -0
  91. package/dist/cli/oauth.js +32 -0
  92. package/dist/cli/pilot.d.ts +5 -0
  93. package/dist/cli/pilot.js +22 -0
  94. package/dist/cli/quo.d.ts +2 -0
  95. package/dist/cli/quo.js +201 -0
  96. package/dist/cli/stand.d.ts +37 -0
  97. package/dist/cli/stand.js +110 -0
  98. package/dist/dock/agents.d.ts +102 -0
  99. package/dist/dock/agents.js +105 -0
  100. package/dist/dock/apns.d.ts +27 -0
  101. package/dist/dock/apns.js +98 -0
  102. package/dist/dock/console.d.ts +6 -0
  103. package/dist/dock/console.js +20 -0
  104. package/dist/dock/dialers.d.ts +56 -0
  105. package/dist/dock/dialers.js +75 -0
  106. package/dist/dock/faculty.d.ts +24 -0
  107. package/dist/dock/faculty.js +142 -0
  108. package/dist/dock/fcm.d.ts +13 -0
  109. package/dist/dock/fcm.js +77 -0
  110. package/dist/dock/grants.d.ts +41 -0
  111. package/dist/dock/grants.js +99 -0
  112. package/dist/dock/http2.d.ts +4 -0
  113. package/dist/dock/http2.js +68 -0
  114. package/dist/dock/index.d.ts +41 -0
  115. package/dist/dock/index.js +113 -0
  116. package/dist/dock/local.d.ts +13 -0
  117. package/dist/dock/local.js +44 -0
  118. package/dist/dock/location.d.ts +19 -0
  119. package/dist/dock/location.js +11 -0
  120. package/dist/dock/notifications.d.ts +38 -0
  121. package/dist/dock/notifications.js +60 -0
  122. package/dist/dock/push.d.ts +79 -0
  123. package/dist/dock/push.js +132 -0
  124. package/dist/dock/service.d.ts +4 -0
  125. package/dist/dock/service.js +107 -0
  126. package/dist/dock/timer.d.ts +52 -0
  127. package/dist/dock/timer.js +149 -0
  128. package/dist/dock/webpush.d.ts +12 -0
  129. package/dist/dock/webpush.js +144 -0
  130. package/dist/harbor/browser.d.ts +15 -0
  131. package/dist/harbor/browser.js +82 -0
  132. package/dist/harbor/capacitor.d.ts +28 -0
  133. package/dist/harbor/capacitor.js +218 -0
  134. package/dist/harbor/disk.d.ts +27 -0
  135. package/dist/harbor/disk.js +249 -0
  136. package/dist/harbor/edge/alarm.d.ts +10 -0
  137. package/dist/harbor/edge/alarm.js +38 -0
  138. package/dist/harbor/edge/edge.d.ts +54 -0
  139. package/dist/harbor/edge/edge.js +370 -0
  140. package/dist/harbor/edge/exercise.d.ts +7 -0
  141. package/dist/harbor/edge/exercise.js +232 -0
  142. package/dist/harbor/edge/given.d.ts +26 -0
  143. package/dist/harbor/edge/given.js +42 -0
  144. package/dist/harbor/edge/platform.d.ts +39 -0
  145. package/dist/harbor/edge/storage.d.ts +30 -0
  146. package/dist/harbor/edge/storage.js +180 -0
  147. package/dist/harbor/edge/worker.d.ts +17 -0
  148. package/dist/harbor/edge/worker.js +67 -0
  149. package/dist/harbor/files.d.ts +15 -0
  150. package/dist/harbor/files.js +220 -0
  151. package/dist/harbor/http.d.ts +19 -0
  152. package/dist/harbor/http.js +177 -0
  153. package/dist/harbor/idb.d.ts +15 -0
  154. package/dist/harbor/idb.js +0 -0
  155. package/dist/harbor/quo.d.ts +10 -0
  156. package/dist/harbor/quo.js +42 -0
  157. package/dist/harbor/resolve.d.ts +16 -0
  158. package/dist/harbor/resolve.js +17 -0
  159. package/dist/harbor/seal.d.ts +3 -0
  160. package/dist/harbor/seal.js +25 -0
  161. package/dist/harbor/sealed.d.ts +35 -0
  162. package/dist/harbor/sealed.js +162 -0
  163. package/dist/harbor/tauri.d.ts +16 -0
  164. package/dist/harbor/tauri.js +59 -0
  165. package/dist/harbor/ward.d.ts +9 -0
  166. package/dist/harbor/ward.js +15 -0
  167. package/dist/human/app.d.ts +32 -0
  168. package/dist/human/app.js +527 -0
  169. package/dist/human/apps.d.ts +10 -0
  170. package/dist/human/apps.js +29 -0
  171. package/dist/human/dom.d.ts +2 -0
  172. package/dist/human/dom.js +15 -0
  173. package/dist/human/door.d.ts +19 -0
  174. package/dist/human/door.js +92 -0
  175. package/dist/human/guest.d.ts +3 -0
  176. package/dist/human/guest.js +33 -0
  177. package/dist/human/html.d.ts +55 -0
  178. package/dist/human/html.js +451 -0
  179. package/dist/human/local.d.ts +7 -0
  180. package/dist/human/local.js +10 -0
  181. package/dist/human/mark.d.ts +8 -0
  182. package/dist/human/mark.js +47 -0
  183. package/dist/human/move.d.ts +17 -0
  184. package/dist/human/move.js +90 -0
  185. package/dist/human/screen.d.ts +19 -0
  186. package/dist/human/screen.js +119 -0
  187. package/dist/human/style.d.ts +2 -0
  188. package/dist/human/style.js +60 -0
  189. package/dist/human/tab.bundle.txt +4775 -0
  190. package/dist/human/tab.d.ts +15 -0
  191. package/dist/human/tab.js +409 -0
  192. package/dist/human/tree.d.ts +50 -0
  193. package/dist/human/tree.js +157 -0
  194. package/dist/human/wake.d.ts +34 -0
  195. package/dist/human/wake.js +179 -0
  196. package/dist/human/web.d.ts +33 -0
  197. package/dist/human/web.js +184 -0
  198. package/dist/human/worker.bundle.txt +3868 -0
  199. package/dist/human/worker.d.ts +1 -0
  200. package/dist/human/worker.js +81 -0
  201. package/dist/human/worlds.d.ts +24 -0
  202. package/dist/human/worlds.js +99 -0
  203. package/dist/mcp/agent.d.ts +10 -0
  204. package/dist/mcp/agent.js +78 -0
  205. package/dist/mcp/allow.d.ts +20 -0
  206. package/dist/mcp/allow.js +88 -0
  207. package/dist/mcp/http.d.ts +27 -0
  208. package/dist/mcp/http.js +92 -0
  209. package/dist/mcp/oauth.d.ts +91 -0
  210. package/dist/mcp/oauth.js +241 -0
  211. package/dist/mcp/route.d.ts +15 -0
  212. package/dist/mcp/route.js +42 -0
  213. package/dist/mcp/runner.d.ts +45 -0
  214. package/dist/mcp/runner.js +99 -0
  215. package/dist/mcp/server.d.ts +11 -0
  216. package/dist/mcp/server.js +102 -0
  217. package/dock/agents.ts +121 -0
  218. package/dock/apns.ts +138 -0
  219. package/dock/console.ts +23 -0
  220. package/dock/dialers.ts +85 -0
  221. package/dock/faculty.ts +148 -0
  222. package/dock/fcm.ts +103 -0
  223. package/dock/grants.ts +111 -0
  224. package/dock/http2.ts +70 -0
  225. package/dock/index.ts +133 -0
  226. package/dock/local.ts +48 -0
  227. package/dock/location.ts +22 -0
  228. package/dock/notifications.ts +67 -0
  229. package/dock/push.ts +161 -0
  230. package/dock/quo-app-bells.md +280 -0
  231. package/dock/quo-dock.md +1942 -0
  232. package/dock/service.ts +100 -0
  233. package/dock/timer.ts +174 -0
  234. package/dock/webpush.ts +182 -0
  235. package/harbor/browser.ts +97 -0
  236. package/harbor/capacitor.ts +223 -0
  237. package/harbor/disk.ts +237 -0
  238. package/harbor/edge/alarm.ts +48 -0
  239. package/harbor/edge/edge.ts +407 -0
  240. package/harbor/edge/exercise.ts +241 -0
  241. package/harbor/edge/given.ts +106 -0
  242. package/harbor/edge/platform.d.ts +39 -0
  243. package/harbor/edge/storage.ts +179 -0
  244. package/harbor/edge/text.d.ts +15 -0
  245. package/harbor/edge/worker.ts +70 -0
  246. package/harbor/edge/wrangler.toml +30 -0
  247. package/harbor/files.ts +221 -0
  248. package/harbor/http.ts +188 -0
  249. package/harbor/idb.ts +0 -0
  250. package/harbor/quo-harbor.md +850 -0
  251. package/harbor/quo.ts +44 -0
  252. package/harbor/resolve.ts +31 -0
  253. package/harbor/seal.ts +26 -0
  254. package/harbor/sealed.ts +183 -0
  255. package/harbor/tauri.ts +79 -0
  256. package/harbor/ward.ts +28 -0
  257. package/human/app.ts +580 -0
  258. package/human/apps.ts +47 -0
  259. package/human/dom.ts +21 -0
  260. package/human/door.ts +113 -0
  261. package/human/guest.ts +34 -0
  262. package/human/html.ts +490 -0
  263. package/human/local.ts +30 -0
  264. package/human/mark.ts +53 -0
  265. package/human/move.ts +122 -0
  266. package/human/quo-human.md +1042 -0
  267. package/human/screen.ts +136 -0
  268. package/human/style.ts +63 -0
  269. package/human/tab.bundle.txt +4775 -0
  270. package/human/tab.ts +446 -0
  271. package/human/tree.ts +217 -0
  272. package/human/wake.ts +191 -0
  273. package/human/web.ts +259 -0
  274. package/human/worker.bundle.txt +3868 -0
  275. package/human/worker.ts +124 -0
  276. package/human/worlds.ts +127 -0
  277. package/mcp/agent.ts +94 -0
  278. package/mcp/allow.ts +131 -0
  279. package/mcp/http.ts +92 -0
  280. package/mcp/oauth.ts +270 -0
  281. package/mcp/quo-mcp.md +343 -0
  282. package/mcp/route.ts +45 -0
  283. package/mcp/runner.ts +136 -0
  284. package/mcp/server.ts +101 -0
  285. 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
+ }