@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
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The phone: the sealed store over Capacitor's Filesystem and a secure
|
|
3
|
+
// storage plugin, the browser harbor over that store, and the phone's
|
|
4
|
+
// faculties as its plugins give them. The files sit in the folder iCloud
|
|
5
|
+
// does not copy on iOS and in the app's own data folder on Android, whose
|
|
6
|
+
// manifest says no backup; the key sits in the Keychain or the Keystore,
|
|
7
|
+
// this device only. The custody rule is the sealed store's, `sealed.ts`,
|
|
8
|
+
// and this file hands it the two seams and nothing more.
|
|
9
|
+
//
|
|
10
|
+
// The harbor on a phone is the browser harbor over that store: the app
|
|
11
|
+
// process is its lease, and the App plugin's foreground is its wake.
|
|
12
|
+
import { Capacitor } from '@capacitor/core';
|
|
13
|
+
import { App } from '@capacitor/app';
|
|
14
|
+
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
15
|
+
import { SecureStorage, KeychainAccess } from '@aparajita/capacitor-secure-storage';
|
|
16
|
+
import { Geolocation } from '@capacitor/geolocation';
|
|
17
|
+
import { LocalNotifications } from '@capacitor/local-notifications';
|
|
18
|
+
import { PushNotifications } from '@capacitor/push-notifications';
|
|
19
|
+
import type { BeingClass, JsonObject } from '@nervur-org/nervur';
|
|
20
|
+
import { LOCATION, NOTIFICATIONS, Location, Notifications, type Token, type Where } from '../dock/index.ts';
|
|
21
|
+
import { BrowserHarbor } from './browser.ts';
|
|
22
|
+
import { Sealed, type Files, type Secrets } from './sealed.ts';
|
|
23
|
+
|
|
24
|
+
// The phone's faculties, as its plugins give them: the two defaults extended,
|
|
25
|
+
// and handed to the harbor as its dock. The platform's permission is the
|
|
26
|
+
// faculty's: a prompt is slow work, since it waits on the human, so the first
|
|
27
|
+
// ask puts the prompt up and answers at once that the human is being asked,
|
|
28
|
+
// and a refusal is the error object every ask after answers with.
|
|
29
|
+
// The secure storage item the banner key's secret half is copied into. The
|
|
30
|
+
// account the keychain holds it under is this name behind the plugin's own
|
|
31
|
+
// prefix, `capacitor-storage_banner`, and the extension reads it there.
|
|
32
|
+
const BANNER = 'banner';
|
|
33
|
+
const asked = (what: string) => new Error(`the human is being asked to allow ${what} on this device; ask again`);
|
|
34
|
+
const refused = (what: string) => new Error(`${what} is not allowed on this device`);
|
|
35
|
+
|
|
36
|
+
export class PhoneLocation extends Location {
|
|
37
|
+
override async where(): Promise<Where | JsonObject> {
|
|
38
|
+
const { location } = await Geolocation.checkPermissions();
|
|
39
|
+
if (location === 'prompt' || location === 'prompt-with-rationale') {
|
|
40
|
+
void Geolocation.requestPermissions().catch(() => {});
|
|
41
|
+
throw asked('location');
|
|
42
|
+
}
|
|
43
|
+
if (location !== 'granted') throw refused('location');
|
|
44
|
+
const p = await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 20_000 });
|
|
45
|
+
return { lat: p.coords.latitude, lng: p.coords.longitude, accuracy: p.coords.accuracy, at: p.timestamp };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class PhoneNotifications extends Notifications {
|
|
50
|
+
override async notify(args: JsonObject): Promise<JsonObject> {
|
|
51
|
+
const title = typeof args.title === 'string' ? args.title : '';
|
|
52
|
+
const body = typeof args.body === 'string' ? args.body : '';
|
|
53
|
+
const { display } = await LocalNotifications.checkPermissions();
|
|
54
|
+
if (display === 'prompt' || display === 'prompt-with-rationale') {
|
|
55
|
+
void LocalNotifications.requestPermissions().catch(() => {});
|
|
56
|
+
throw asked('notifications');
|
|
57
|
+
}
|
|
58
|
+
if (display !== 'granted') throw refused('notifications');
|
|
59
|
+
// `isExactNotification` defaults to true, and on Android 12 and up a
|
|
60
|
+
// notification that wants an exact alarm without the permission for one
|
|
61
|
+
// opens the Alarms and reminders settings screen and never answers, so
|
|
62
|
+
// the ask goes late. This one is shown now and waits for no clock, and
|
|
63
|
+
// the permission it would otherwise want is the kind a store asks an
|
|
64
|
+
// alarm app to justify.
|
|
65
|
+
await LocalNotifications.schedule({ notifications: [{ id: Date.now() % 2147483647, title, body, isExactNotification: false }] });
|
|
66
|
+
return { shown: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Where a phone's notification process reads the banner key. The process
|
|
70
|
+
// that draws a banner here is not this one: an extension runs between the
|
|
71
|
+
// platform and the screen and cannot reach a ward at all, so the secret
|
|
72
|
+
// half goes into the keychain group the two share. The pair itself stays
|
|
73
|
+
// in the faculty's cells, which is the truth of it, and this is a copy of
|
|
74
|
+
// one half put somewhere a platform can find it.
|
|
75
|
+
//
|
|
76
|
+
// It goes through the same secure storage the store keeps its key in, and
|
|
77
|
+
// not through a plugin of the app's own. An item written there lands in
|
|
78
|
+
// the app's first keychain group, which is the group the extension is in,
|
|
79
|
+
// so the two meet with nothing written to make them meet. A plugin would
|
|
80
|
+
// have had to be registered before the page loads, which on this platform
|
|
81
|
+
// is a list a `cap sync` rewrites, so the app would have carried a hand
|
|
82
|
+
// edit that the next sync undoes.
|
|
83
|
+
//
|
|
84
|
+
// The platform's permission is asked for here, in the same act. A device
|
|
85
|
+
// that has just minted a banner key is a device about to be rung, and the
|
|
86
|
+
// ring that will carry that banner is an alert the platform draws, which
|
|
87
|
+
// it draws for nobody who has not allowed it. Registering for remote
|
|
88
|
+
// notifications asked for nothing, deliberately, so this is the one place
|
|
89
|
+
// on this terrain where the person is asked at all.
|
|
90
|
+
//
|
|
91
|
+
// A refusal leaves the key where it is and costs the device the line and
|
|
92
|
+
// not the wake: the seal is planted, the world is admitted, and what
|
|
93
|
+
// arrives is a push the human never sees until the app is opened. So the
|
|
94
|
+
// answer is not waited on and not read: the person may say yes later in
|
|
95
|
+
// Settings and everything already planted still works.
|
|
96
|
+
protected override async plant(pair: { secret: Uint8Array }): Promise<void> {
|
|
97
|
+
const hex = Array.from(pair.secret, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
98
|
+
await SecureStorage.set(BANNER, hex, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
|
|
99
|
+
const { receive } = await PushNotifications.checkPermissions().catch(() => ({ receive: 'denied' as const }));
|
|
100
|
+
if (receive === 'prompt' || receive === 'prompt-with-rationale') await PushNotifications.requestPermissions().catch(() => ({ receive: 'denied' as const }));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// What a phone lends: the two faculties above under the names every box
|
|
105
|
+
// lends them under, and nothing a phone has not. The harbor holds these
|
|
106
|
+
// class bodies for its own ward, and boots one being of each in it.
|
|
107
|
+
export const PHONE_FACULTIES: Record<string, BeingClass> = { Location: PhoneLocation, Notifications: PhoneNotifications };
|
|
108
|
+
export const phoneLent: Record<string, string> = { [LOCATION]: 'Location', [NOTIFICATIONS]: 'Notifications' };
|
|
109
|
+
|
|
110
|
+
// The phone's ear, as `human/app.ts` asks for one: the token this device is
|
|
111
|
+
// reachable at while it sleeps, or null.
|
|
112
|
+
//
|
|
113
|
+
// It asks the human for nothing, and it does not have to. Registering for
|
|
114
|
+
// remote notifications shows no prompt and wants no permission: what a
|
|
115
|
+
// permission gates is showing a human a banner, and a doorbell shows
|
|
116
|
+
// nobody anything. It carries no title, no body and no id, it wakes the
|
|
117
|
+
// app and nothing more, and what the human eventually reads is composed
|
|
118
|
+
// here afterwards out of objects that crossed sealed. So a device whose
|
|
119
|
+
// human has allowed nothing is still reachable, and misses only the line
|
|
120
|
+
// it would have read; the permission it wants for that line is the
|
|
121
|
+
// Notifications faculty's ask and not this one's.
|
|
122
|
+
//
|
|
123
|
+
// The token arrives as an event and not as an answer, so the registration
|
|
124
|
+
// is awaited once and kept: the platform hands the same token back on every
|
|
125
|
+
// later call, and a new one whenever it decides to, which is why the shell
|
|
126
|
+
// asks again on every foreground.
|
|
127
|
+
//
|
|
128
|
+
// `named` is what the build says of this shell, the way `sandbox` is: the
|
|
129
|
+
// bell service holding this app's keys and the app id, so a world rings
|
|
130
|
+
// this phone through the right service whoever shipped the app.
|
|
131
|
+
export function phoneEar(sandbox = false, within = 10_000, named: { bell?: string; app?: string } = {}): () => Promise<Token | null> {
|
|
132
|
+
let token: string | null = null;
|
|
133
|
+
return async () => {
|
|
134
|
+
if (!token) {
|
|
135
|
+
token = await new Promise<string | null>((answer) => {
|
|
136
|
+
const done = setTimeout(() => answer(null), within);
|
|
137
|
+
void PushNotifications.addListener('registration', ({ value }) => {
|
|
138
|
+
clearTimeout(done);
|
|
139
|
+
answer(value);
|
|
140
|
+
});
|
|
141
|
+
void PushNotifications.addListener('registrationError', () => {
|
|
142
|
+
clearTimeout(done);
|
|
143
|
+
answer(null);
|
|
144
|
+
});
|
|
145
|
+
void PushNotifications.register().catch(() => {
|
|
146
|
+
clearTimeout(done);
|
|
147
|
+
answer(null);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (!token) return null;
|
|
152
|
+
// Which of Apple's two push hosts minted it. Nothing in a device token
|
|
153
|
+
// says so, and a development token means nothing to the production
|
|
154
|
+
// host, so the build that knows what it is is the one that says.
|
|
155
|
+
const says = { ...(named.bell ? { bell: named.bell } : {}), ...(named.app ? { app: named.app } : {}) };
|
|
156
|
+
const apple: Token = { kind: 'apns', device: token, sandbox, ...says };
|
|
157
|
+
const google: Token = { kind: 'fcm', device: token, ...says };
|
|
158
|
+
return Capacitor.getPlatform() === 'ios' ? apple : google;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The app's one harbor, booted, lending the phone's faculties to its wards,
|
|
163
|
+
// told `wake` every time the app comes to the foreground, since a phone
|
|
164
|
+
// asleep loses its sockets silently.
|
|
165
|
+
export async function nativeHarbor(name = 'quo', classes: Record<string, BeingClass> = {}): Promise<BrowserHarbor> {
|
|
166
|
+
const h = new BrowserHarbor(await Native.open(name), { ...PHONE_FACULTIES, ...classes });
|
|
167
|
+
await h.stand(phoneLent);
|
|
168
|
+
await h.boot();
|
|
169
|
+
await App.addListener('appStateChange', ({ isActive }) => {
|
|
170
|
+
if (isActive) h.wake();
|
|
171
|
+
});
|
|
172
|
+
return h;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
176
|
+
// own files on Android, whose manifest says no backup.
|
|
177
|
+
export const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
178
|
+
|
|
179
|
+
// The two seams, as the plugins give them. Two things the plugins taught:
|
|
180
|
+
// the secure store keeps JSON, so a value is read with the call that
|
|
181
|
+
// parses; and mkdir refuses a folder that exists, recursive or not.
|
|
182
|
+
export const phoneFiles: Files = {
|
|
183
|
+
exists: async (path) => {
|
|
184
|
+
try {
|
|
185
|
+
await Filesystem.stat({ path, directory });
|
|
186
|
+
return true;
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
read: async (path) => (await Filesystem.readFile({ path, directory, encoding: Encoding.UTF8 })).data as string,
|
|
192
|
+
write: async (path, data) => {
|
|
193
|
+
await Filesystem.writeFile({ path, directory, data, encoding: Encoding.UTF8 });
|
|
194
|
+
},
|
|
195
|
+
remove: async (path) => {
|
|
196
|
+
await Filesystem.deleteFile({ path, directory });
|
|
197
|
+
},
|
|
198
|
+
mkdir: async (path) => {
|
|
199
|
+
await Filesystem.mkdir({ path, directory, recursive: true });
|
|
200
|
+
},
|
|
201
|
+
rmdir: async (path) => {
|
|
202
|
+
await Filesystem.rmdir({ path, directory, recursive: true });
|
|
203
|
+
},
|
|
204
|
+
list: async (path) => (await Filesystem.readdir({ path, directory })).files.map((f) => f.name),
|
|
205
|
+
};
|
|
206
|
+
export const phoneSecrets: Secrets = {
|
|
207
|
+
get: async (item) => {
|
|
208
|
+
const got = await SecureStorage.get(item, false, false);
|
|
209
|
+
return typeof got === 'string' ? got : undefined;
|
|
210
|
+
},
|
|
211
|
+
set: async (item, value) => {
|
|
212
|
+
await SecureStorage.set(item, value, false, false, KeychainAccess.afterFirstUnlockThisDeviceOnly);
|
|
213
|
+
},
|
|
214
|
+
remove: async (item) => {
|
|
215
|
+
await SecureStorage.remove(item);
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// The phone's store: the sealed store over the two seams above.
|
|
220
|
+
export const Native = {
|
|
221
|
+
open: (harbor: string): Promise<Sealed> => Sealed.open(harbor, phoneFiles, phoneSecrets),
|
|
222
|
+
wipe: (harbor: string): Promise<void> => Sealed.wipe(harbor, phoneFiles, phoneSecrets),
|
|
223
|
+
};
|
package/harbor/disk.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The node daemon's harbor: the core over a folder on disk. The store is
|
|
3
|
+
// `files.ts`, one folder per ward; the class bodies come from a module the
|
|
4
|
+
// ward record names, loaded from the folder; the lease is a pid file. The
|
|
5
|
+
// estate's own beings, the user being, the front desk and the avatar, are
|
|
6
|
+
// held by every harbor here: they are what every side needs, and a folder's
|
|
7
|
+
// class source adds to them.
|
|
8
|
+
//
|
|
9
|
+
// <dir>/
|
|
10
|
+
// lease pid of the one process that holds this harbor
|
|
11
|
+
// classes/index.ts the default class source, a module exporting classes
|
|
12
|
+
// tab/index.ts the classes for the tab, served bundled by the web route: human/web.ts
|
|
13
|
+
// design/ the face this world wears: tokens.css, fonts.css, fonts/, served by the web route
|
|
14
|
+
// wards/<name>/ seed, rows/, ward.json, or one sealed
|
|
15
|
+
// blob when the daemon holds QUO_SEED_KEY: see files.ts
|
|
16
|
+
// reach.json the directory's hints
|
|
17
|
+
import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { join, resolve, isAbsolute } from 'node:path';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
import { register } from 'node:module';
|
|
22
|
+
import process from 'node:process';
|
|
23
|
+
import type { BeingClass } from '@nervur-org/nervur';
|
|
24
|
+
import { User, Desk, Avatar, Presence, Clock, Courier } from '../beings/index.ts';
|
|
25
|
+
import { setup } from '../beings/setup.ts';
|
|
26
|
+
import { Harbor, DEFAULT_CODE, type Hosted, type Lend } from '@nervur-org/nervur/harbor';
|
|
27
|
+
import { FACULTY_CLASSES, settled, stand } from '../dock/index.ts';
|
|
28
|
+
import { Files } from './files.ts';
|
|
29
|
+
|
|
30
|
+
export type { Hosted, Bound } from '@nervur-org/nervur/harbor';
|
|
31
|
+
export type { WardRecord } from '@nervur-org/nervur/harbor';
|
|
32
|
+
export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar, Presence, Clock, Courier, ...FACULTY_CLASSES };
|
|
33
|
+
|
|
34
|
+
// The default ward, the one a caller means when naming none: the one marked,
|
|
35
|
+
// or the one ward hosted when there is exactly one, which is every harbor an
|
|
36
|
+
// adopter stands with one `quo init`. Several wards and none marked is
|
|
37
|
+
// refused by name, since agents and a door would otherwise hang off a
|
|
38
|
+
// guess. The dock ward is furniture, never a candidate.
|
|
39
|
+
export function defaultWard(marked: string[], hosted: string[]): string {
|
|
40
|
+
if (marked.length > 1) throw new Error(`this device marks ${marked.join(', ')} as default, and one at most may be`);
|
|
41
|
+
const one = marked[0];
|
|
42
|
+
if (one !== undefined) {
|
|
43
|
+
if (!hosted.includes(one)) throw new Error(`this device says its default ward is ${one}, and hosts no such ward`);
|
|
44
|
+
return one;
|
|
45
|
+
}
|
|
46
|
+
const [first, ...more] = hosted.filter((n) => n !== 'dock');
|
|
47
|
+
if (first !== undefined && more.length === 0) return first;
|
|
48
|
+
const names = first === undefined ? [] : [first, ...more];
|
|
49
|
+
throw new Error(names.length === 0 ? 'this device hosts no ward: run `quo init`' : `this device hosts ${names.join(', ')} and says which is the default: quo default <name>`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Folders held by a harbor in this process. The lease file names a pid, and
|
|
53
|
+
// a second harbor in the same pid would pass that check while still being a
|
|
54
|
+
// second ward with one pk.
|
|
55
|
+
const HELD = new Set<string>();
|
|
56
|
+
|
|
57
|
+
// A class file in the harbor folder resolves its packages from where the
|
|
58
|
+
// dock is installed, `resolve.ts`: the folder has no node_modules and is not
|
|
59
|
+
// meant to. Registered once per process, before the first class loads.
|
|
60
|
+
let hooked = false;
|
|
61
|
+
function hook(): void {
|
|
62
|
+
if (hooked) return;
|
|
63
|
+
hooked = true;
|
|
64
|
+
const self = import.meta.url as string | undefined;
|
|
65
|
+
// bundled into one script, as the desk's sidecar is, the dock has no URL and no folder of
|
|
66
|
+
// packages beside it to resolve to: a class file there imports nothing bare
|
|
67
|
+
if (!self) return;
|
|
68
|
+
register(new URL(self.endsWith('.ts') ? './resolve.ts' : './resolve.js', self), { parentURL: self, data: { parent: self } });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The class source is a module. Every export that is a class is a class the
|
|
72
|
+
// harbor holds, under its export name. The harbor never sees a body: it
|
|
73
|
+
// constructs when a ward names a class, and never chooses one.
|
|
74
|
+
async function loadClasses(path: string): Promise<Record<string, BeingClass>> {
|
|
75
|
+
if (!existsSync(path)) return {};
|
|
76
|
+
hook();
|
|
77
|
+
// Keyed by the file's own time, so a source that changed on disk is read
|
|
78
|
+
// again and a harbor boots on what it pins, not on what it once loaded.
|
|
79
|
+
const { mtimeMs } = await stat(path);
|
|
80
|
+
const mod = (await import(`${pathToFileURL(path).href}?v=${mtimeMs}`)) as Record<string, unknown>;
|
|
81
|
+
const out: Record<string, BeingClass> = {};
|
|
82
|
+
for (const [name, v] of Object.entries(mod)) if (typeof v === 'function' && 'prototype' in v) out[name] = v as BeingClass;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class DiskHarbor extends Harbor {
|
|
87
|
+
readonly dir: string;
|
|
88
|
+
// The default ward: the one a caller means when naming none. Not a kind of
|
|
89
|
+
// ward and nothing in Quo knows of it: a harbor is not a being and has no
|
|
90
|
+
// self to be, so it can only point at one of its wards. Its agents boot
|
|
91
|
+
// there, and its door answers for it when a caller names no ward. The one
|
|
92
|
+
// ward it hosts when there is one, the one marked otherwise, resolved by
|
|
93
|
+
// the daemon at boot; empty until then.
|
|
94
|
+
defaultWard = '';
|
|
95
|
+
|
|
96
|
+
// The mark is a file in the ward's own folder, `wards/<name>/default`, so
|
|
97
|
+
// it moves with the ward and nothing beside the wards says it. One at most:
|
|
98
|
+
// marking one takes the mark off every other.
|
|
99
|
+
static async setDefault(dir: string, name: string): Promise<void> {
|
|
100
|
+
const h = new DiskHarbor(dir);
|
|
101
|
+
const names = await h.store.list();
|
|
102
|
+
if (!names.includes(name)) throw new Error(`no ward ${name} in ${h.dir}`);
|
|
103
|
+
for (const n of names) await unlink(join(h.dir, 'wards', n, 'default')).catch(() => {});
|
|
104
|
+
await writeFile(join(h.dir, 'wards', name, 'default'), '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Whether a ward is shown at the web route: listed at its root and its
|
|
108
|
+
// public being's page painted for a stranger. Hidden, it is not listed
|
|
109
|
+
// and a stranger meets the plain door page, while a link still knocks
|
|
110
|
+
// with its invitation and the desk still answers a proof, so nothing of
|
|
111
|
+
// the way in changes. A mark in the ward's own folder, `wards/<name>/shown`,
|
|
112
|
+
// hidden until the root shows it.
|
|
113
|
+
static async setShown(dir: string, name: string, shown: boolean): Promise<void> {
|
|
114
|
+
const h = new DiskHarbor(dir);
|
|
115
|
+
if (!(await h.store.list()).includes(name)) throw new Error(`no ward ${name} in ${h.dir}`);
|
|
116
|
+
const p = join(h.dir, 'wards', name, 'shown');
|
|
117
|
+
if (shown) await writeFile(p, '');
|
|
118
|
+
else await unlink(p).catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static isShown(dir: string, name: string): boolean {
|
|
122
|
+
return existsSync(join(resolve(dir), 'wards', name, 'shown'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static async marked(dir: string): Promise<string[]> {
|
|
126
|
+
const h = new DiskHarbor(dir);
|
|
127
|
+
return (await h.store.list()).filter((n) => existsSync(join(h.dir, 'wards', n, 'default')));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The default ward read from the folder alone, for a command with no
|
|
131
|
+
// daemon in front of it and no `--ward` given.
|
|
132
|
+
static async defaultOf(dir: string): Promise<string> {
|
|
133
|
+
const h = new DiskHarbor(dir);
|
|
134
|
+
return defaultWard(await DiskHarbor.marked(dir), await h.store.list());
|
|
135
|
+
}
|
|
136
|
+
#held = false;
|
|
137
|
+
|
|
138
|
+
// `lend` is what this box lends the beings of every ward here: a name to
|
|
139
|
+
// an invitation on a being of its own ward, which `stand` puts up. A
|
|
140
|
+
// folder booted by a command lends nothing.
|
|
141
|
+
// `own` are this terrain's own class bodies, the faculty subclasses a box
|
|
142
|
+
// has: they stand in front of the built-ins, so a daemon that can ring
|
|
143
|
+
// hands its own `Push` and the dock ward boots that one.
|
|
144
|
+
constructor(dir: string, lend?: Lend, own: Record<string, BeingClass> = {}) {
|
|
145
|
+
const abs = resolve(dir);
|
|
146
|
+
// The key the environment gives, the edge's name for it: from the
|
|
147
|
+
// Keychain through the app that spawned this daemon, or nothing on a
|
|
148
|
+
// droplet, whose folder stays plain under its file modes.
|
|
149
|
+
super(new Files(abs, process.env.QUO_SEED_KEY), async (rec) => ({ ...BUILT_IN, ...own, ...(await loadClasses(isAbsolute(rec.code) ? rec.code : join(abs, rec.code))) }), lend);
|
|
150
|
+
this.dir = abs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// What this box lends, by name. Set before `boot` and read there; a box
|
|
154
|
+
// that says nothing stands no ward of its own and lends nothing.
|
|
155
|
+
lent: Record<string, string> | undefined;
|
|
156
|
+
|
|
157
|
+
// Create a harbor folder with one ward, and the root's setup in it: the
|
|
158
|
+
// front desk, public, and one user being, with the desk holding a standing
|
|
159
|
+
// to her placed by the root's knock. Boots the ward once, with no lease, to
|
|
160
|
+
// do that and to learn its pk, and writes the record. Refuses a folder that
|
|
161
|
+
// has one.
|
|
162
|
+
static async init(dir: string, name = 'main', user = 'me', cls = 'User', mark = false, shown = false): Promise<{ dir: string; name: string; pk: string; user: string }> {
|
|
163
|
+
const h = new DiskHarbor(dir);
|
|
164
|
+
if ((await h.store.list()).includes(name)) throw new Error(`ward ${name} already exists in ${h.dir}`);
|
|
165
|
+
await mkdir(join(h.dir, 'classes'), { recursive: true });
|
|
166
|
+
const classes = join(h.dir, DEFAULT_CODE);
|
|
167
|
+
if (!existsSync(classes)) await writeFile(classes, '// The classes this harbor holds beside the built-in ones. Export each one by name.\nexport {};\n');
|
|
168
|
+
const hosted = await h.create(name, user);
|
|
169
|
+
await setup(hosted, user, cls);
|
|
170
|
+
if (mark) await DiskHarbor.setDefault(h.dir, name);
|
|
171
|
+
if (shown) await DiskHarbor.setShown(h.dir, name, true);
|
|
172
|
+
return { dir: h.dir, name, pk: hosted.pk, user };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Take the lease and boot every ward in the folder. The lease is the
|
|
176
|
+
// harbor's vouch made a store property: two processes over one seed are two
|
|
177
|
+
// wards with one pk, so a held lease refuses the boot.
|
|
178
|
+
//
|
|
179
|
+
// The box's own ward goes up before every other, because a being born in
|
|
180
|
+
// any of them lends from her first line, and a ground that could not reach
|
|
181
|
+
// it yet would leave her holding nothing until her next birth.
|
|
182
|
+
override async boot(): Promise<void> {
|
|
183
|
+
await this.#lease();
|
|
184
|
+
if (this.lent) this.lend = await stand(this, this.lent);
|
|
185
|
+
await super.boot();
|
|
186
|
+
// Every being who takes what this box lends does it at her birth, and
|
|
187
|
+
// birth is synchronous while taking a standing is not. A box is up when
|
|
188
|
+
// they have landed, not before.
|
|
189
|
+
await settled();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Every ward's writing finished, then the lease. A ward says it wrote and
|
|
193
|
+
// the harbor writes it after, so a close that only dropped the lease would
|
|
194
|
+
// let the process end with a write still on its way to the disk, and the
|
|
195
|
+
// next daemon would boot on a partition older than the last reply this one
|
|
196
|
+
// gave. The lease goes last: while a write is in flight this box is still
|
|
197
|
+
// the one holding these wards.
|
|
198
|
+
async close(): Promise<void> {
|
|
199
|
+
for (const w of this.wards.values()) await w.save().catch(() => {});
|
|
200
|
+
if (!this.#held) return;
|
|
201
|
+
await unlink(join(this.dir, 'lease')).catch(() => {});
|
|
202
|
+
HELD.delete(this.dir);
|
|
203
|
+
this.#held = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Boot a ward whose folder was placed here by hand.
|
|
207
|
+
async adoptFolder(name: string): Promise<Hosted> {
|
|
208
|
+
const kept = await this.store.load(name);
|
|
209
|
+
if (!kept) throw new Error(`no ward ${name} in ${this.dir}`);
|
|
210
|
+
return this.host(name, kept);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #lease(): Promise<void> {
|
|
214
|
+
if (HELD.has(this.dir)) throw new Error(`harbor ${this.dir} is held by pid ${process.pid}`);
|
|
215
|
+
const path = join(this.dir, 'lease');
|
|
216
|
+
if (existsSync(path)) {
|
|
217
|
+
const pid = Number(await readFile(path, 'utf8'));
|
|
218
|
+
let alive = pid !== process.pid; // our own pid with no entry in HELD is a lease we left behind
|
|
219
|
+
if (alive) {
|
|
220
|
+
try {
|
|
221
|
+
process.kill(pid, 0);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
alive = (e as { code?: string }).code === 'EPERM'; // EPERM: somebody is home, and not us. anything else: a corpse
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (alive) throw new Error(`harbor ${this.dir} is held by pid ${pid}`);
|
|
227
|
+
await unlink(path).catch(() => {}); // the corpse, so the write below can be exclusive
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await writeFile(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
231
|
+
} catch {
|
|
232
|
+
throw new Error(`harbor ${this.dir} is held: another daemon took the lease first`);
|
|
233
|
+
}
|
|
234
|
+
HELD.add(this.dir);
|
|
235
|
+
this.#held = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The box's timer on this terrain. One method of hers knows a platform, the
|
|
3
|
+
// arming, and this replaces it: the object's own storage alarm in place of a
|
|
4
|
+
// `setTimeout` that would die with the request. Everything else about her is
|
|
5
|
+
// the same class, the same door and the same suite, because when a box wakes
|
|
6
|
+
// is a terrain's answer and what it does when it wakes is not.
|
|
7
|
+
//
|
|
8
|
+
// One harbor is one Durable Object, and every ward of it lives in that
|
|
9
|
+
// object, so the alarm wakes the whole harbor and a tick from her to a home's
|
|
10
|
+
// Clock is in-process here as it is on a disk. Only the object may set its
|
|
11
|
+
// own alarm, so the two calls are handed to her after the box ward stands,
|
|
12
|
+
// the way the grants are handed their save: a faculty is built by her ward
|
|
13
|
+
// with a stance and nothing else.
|
|
14
|
+
import { Timer } from '../../dock/index.ts';
|
|
15
|
+
import type { Storage } from './platform.d.ts';
|
|
16
|
+
|
|
17
|
+
// Be woken at this moment, or at no moment at all. One alarm per object,
|
|
18
|
+
// replaced by the next set and gone when it is deleted, which is exactly what
|
|
19
|
+
// one armed moment is.
|
|
20
|
+
export type Alarms = (at: number | null) => Promise<void>;
|
|
21
|
+
|
|
22
|
+
export const alarmsIn =
|
|
23
|
+
(storage: Storage): Alarms =>
|
|
24
|
+
async (at) => {
|
|
25
|
+
if (at === null) await storage.deleteAlarm();
|
|
26
|
+
else await storage.setAlarm(at);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class Alarm extends Timer {
|
|
30
|
+
// Handed in after the box ward stands. A timer holding none arms nothing
|
|
31
|
+
// and wakes nobody, which is a harbor standing no box ward.
|
|
32
|
+
alarms: Alarms | undefined;
|
|
33
|
+
#setting: Promise<void> = Promise.resolve();
|
|
34
|
+
|
|
35
|
+
// The arming written, for the device's own code and a suite: on this
|
|
36
|
+
// terrain an arming is a write to storage, and a reader of the alarm waits
|
|
37
|
+
// for it. It crosses no door.
|
|
38
|
+
get setting(): Promise<void> {
|
|
39
|
+
return this.#setting;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
protected override arm(when: number | null): void {
|
|
43
|
+
// A faculty arms inside whatever ask learned the moment, and a write is
|
|
44
|
+
// not something that ask can answer for, so this is left to finish on its
|
|
45
|
+
// own. An alarm the platform refused is armed again by the next round.
|
|
46
|
+
this.#setting = (this.alarms?.(when) ?? Promise.resolve()).catch(() => {});
|
|
47
|
+
}
|
|
48
|
+
}
|