@quo-systems/dock 0.2.5 → 0.2.7
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/beings/quo-dock.md +1 -1
- package/dist/harbor/capacitor.d.ts +2 -0
- package/dist/harbor/capacitor.js +1 -1
- package/dist/harbor/disk.js +13 -0
- package/dist/harbor/resolve.d.ts +16 -0
- package/dist/harbor/resolve.js +17 -0
- package/dist/human/html.d.ts +2 -0
- package/dist/human/html.js +12 -6
- package/dist/human/screen.d.ts +2 -0
- package/dist/human/screen.js +1 -1
- package/dist/human/tab.d.ts +1 -0
- package/dist/human/tab.js +4 -2
- package/dist/human/web.js +10 -3
- package/harbor/capacitor.ts +1 -1
- package/harbor/disk.ts +13 -0
- package/harbor/quo-harbor.md +35 -12
- package/harbor/resolve.ts +31 -0
- package/human/html.ts +14 -6
- package/human/quo-human.md +11 -3
- package/human/screen.ts +6 -3
- package/human/tab.ts +4 -3
- package/human/web.ts +9 -3
- package/package.json +3 -3
package/beings/quo-dock.md
CHANGED
|
@@ -83,7 +83,7 @@ Three roles exist, and they are held by where a thing runs, not by config:
|
|
|
83
83
|
| role | what it can do | who can hold it |
|
|
84
84
|
| --------- | ------------------------------------------- | --------------------------------------------------- |
|
|
85
85
|
| occupant | ask a being what her gate shows this asker | anyone, local or remote |
|
|
86
|
-
| owner |
|
|
86
|
+
| owner | reach into a being; boot is every being's | the root, on the device; or a ward the root invited |
|
|
87
87
|
| developer | write a being class the harbor will hold | only a process on the device |
|
|
88
88
|
|
|
89
89
|
Whoever holds all three keeps them apart: the owner creates and places, and
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { Directory } from '@capacitor/filesystem';
|
|
1
2
|
import type { BeingClass } from '@quo-systems/quo';
|
|
2
3
|
import type { Kept, Store, WardRecord } from '@quo-systems/quo/harbor';
|
|
3
4
|
import { BrowserHarbor } from './browser.ts';
|
|
4
5
|
export declare function nativeHarbor(name?: string, classes?: Record<string, BeingClass>): Promise<BrowserHarbor>;
|
|
6
|
+
export declare const directory: Directory;
|
|
5
7
|
export declare class Native implements Store {
|
|
6
8
|
#private;
|
|
7
9
|
readonly harbor: string;
|
package/dist/harbor/capacitor.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function nativeHarbor(name = 'quo', classes = {}) {
|
|
|
36
36
|
const { hex, unhex } = arithmetic;
|
|
37
37
|
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
38
38
|
// own files on Android, whose manifest says no backup.
|
|
39
|
-
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
39
|
+
export const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
40
40
|
async function exists(path) {
|
|
41
41
|
try {
|
|
42
42
|
await Filesystem.stat({ path, directory });
|
package/dist/harbor/disk.js
CHANGED
|
@@ -25,6 +25,7 @@ import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
|
|
25
25
|
import { existsSync } from 'node:fs';
|
|
26
26
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
27
27
|
import { pathToFileURL } from 'node:url';
|
|
28
|
+
import { register } from 'node:module';
|
|
28
29
|
import process from 'node:process';
|
|
29
30
|
import { User, Desk, Avatar } from '../beings/index.js';
|
|
30
31
|
import { setup } from '../beings/setup.js';
|
|
@@ -35,12 +36,24 @@ export const BUILT_IN = { User, Desk, Avatar };
|
|
|
35
36
|
// a second harbor in the same pid would pass that check while still being a
|
|
36
37
|
// second ward with one pk.
|
|
37
38
|
const HELD = new Set();
|
|
39
|
+
// A class file in the harbor folder resolves its packages from where the
|
|
40
|
+
// dock is installed, `resolve.ts`: the folder has no node_modules and is not
|
|
41
|
+
// meant to. Registered once per process, before the first class loads.
|
|
42
|
+
let hooked = false;
|
|
43
|
+
function hook() {
|
|
44
|
+
if (hooked)
|
|
45
|
+
return;
|
|
46
|
+
hooked = true;
|
|
47
|
+
const self = import.meta.url;
|
|
48
|
+
register(new URL(self.endsWith('.ts') ? './resolve.ts' : './resolve.js', self), { parentURL: self, data: { parent: self } });
|
|
49
|
+
}
|
|
38
50
|
// The class source is a module. Every export that is a class is a class the
|
|
39
51
|
// harbor holds, under its export name. The harbor never sees a body: it
|
|
40
52
|
// constructs when a ward names a class, and never chooses one.
|
|
41
53
|
async function loadClasses(path) {
|
|
42
54
|
if (!existsSync(path))
|
|
43
55
|
return {};
|
|
56
|
+
hook();
|
|
44
57
|
// Keyed by the file's own time, so a source that changed on disk is read
|
|
45
58
|
// again and a harbor boots on what it pins, not on what it once loaded.
|
|
46
59
|
const { mtimeMs } = await stat(path);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Context = {
|
|
2
|
+
parentURL?: string;
|
|
3
|
+
conditions: string[];
|
|
4
|
+
importAttributes: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
type Resolved = {
|
|
7
|
+
url: string;
|
|
8
|
+
format?: string | null;
|
|
9
|
+
shortCircuit?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type Next = (specifier: string, context: Context) => Promise<Resolved>;
|
|
12
|
+
export declare function initialize(data: {
|
|
13
|
+
parent: string;
|
|
14
|
+
}): void;
|
|
15
|
+
export declare function resolve(specifier: string, context: Context, next: Next): Promise<Resolved>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
let parent = '';
|
|
2
|
+
export function initialize(data) {
|
|
3
|
+
parent = data.parent;
|
|
4
|
+
}
|
|
5
|
+
const bare = (s) => !s.startsWith('.') && !s.startsWith('/') && !/^[a-z]+:/i.test(s);
|
|
6
|
+
export async function resolve(specifier, context, next) {
|
|
7
|
+
try {
|
|
8
|
+
return await next(specifier, context);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
const missing = e.code === 'ERR_MODULE_NOT_FOUND';
|
|
12
|
+
const from = context.parentURL ?? '';
|
|
13
|
+
if (missing && parent && bare(specifier) && from.startsWith('file:') && !from.includes('/node_modules/'))
|
|
14
|
+
return next(specifier, { ...context, parentURL: parent });
|
|
15
|
+
throw e;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/human/html.d.ts
CHANGED
package/dist/human/html.js
CHANGED
|
@@ -192,6 +192,8 @@ export function page(m) {
|
|
|
192
192
|
const l = look(kept);
|
|
193
193
|
const prefix = `${id}-`;
|
|
194
194
|
const tree = m.pages[id];
|
|
195
|
+
// where her own page is, when the world's page is known and this is not it already
|
|
196
|
+
const own = m.at !== undefined && m.focus !== id ? `<a class="open" href="${escape(`${m.at}/${encodeURIComponent(id)}`)}">open</a>` : '';
|
|
195
197
|
if (tree) {
|
|
196
198
|
const hers = {
|
|
197
199
|
form: (name) => {
|
|
@@ -202,11 +204,11 @@ export function page(m) {
|
|
|
202
204
|
standing: () => '',
|
|
203
205
|
standings: () => '',
|
|
204
206
|
};
|
|
205
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div
|
|
207
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div>${own}</section>`;
|
|
206
208
|
}
|
|
207
209
|
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
208
210
|
const inOrder = ordered(asks, { order: want });
|
|
209
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
211
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}${own}</section>`;
|
|
210
212
|
};
|
|
211
213
|
const far = Object.keys(groups).map(section).join('');
|
|
212
214
|
// Her page, when she answered one: painted from her tree, each name it
|
|
@@ -220,13 +222,17 @@ export function page(m) {
|
|
|
220
222
|
standing: section,
|
|
221
223
|
standings: () => far,
|
|
222
224
|
};
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
+
const mineAsks = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
226
|
+
// A being's page: one standing she carries as the whole page, and nothing of hers around it. A key her
|
|
227
|
+
// describe does not carry paints as nothing, the way a name on a page does: the address adds no right.
|
|
228
|
+
const focused = m.focus !== undefined;
|
|
229
|
+
const asks = focused ? section(m.focus) : m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : mineAsks + far;
|
|
225
230
|
const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
|
|
226
231
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown)}</aside>` : '';
|
|
227
232
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
228
233
|
const mine = look(m.look);
|
|
229
234
|
// a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
|
|
230
|
-
const
|
|
231
|
-
|
|
235
|
+
const back = focused && m.at !== undefined ? `<a class="back" href="${escape(m.at)}">${escape(title(bp, m.look))}</a>` : '';
|
|
236
|
+
const head = focused ? back : m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
|
|
237
|
+
return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${focused ? '' : notes}<main${focused ? ' class="focus"' : ''}${mine.style}>${asks}</main>${pushes}`;
|
|
232
238
|
}
|
package/dist/human/screen.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type Options = {
|
|
|
9
9
|
after?: () => Promise<void>;
|
|
10
10
|
notice?: string;
|
|
11
11
|
admit?: (invitation: Invitation) => Promise<void>;
|
|
12
|
+
focus?: string;
|
|
13
|
+
at?: string;
|
|
12
14
|
};
|
|
13
15
|
export declare function screenSide(avatar: Subject, surface: Surface, options?: Options): Promise<Serving & {
|
|
14
16
|
model: Model;
|
package/dist/human/screen.js
CHANGED
|
@@ -16,7 +16,7 @@ import { isSilence, isWord } from '@quo-systems/quo';
|
|
|
16
16
|
export async function screenSide(avatar, surface, options = {}) {
|
|
17
17
|
const after = options.after ?? (async () => { });
|
|
18
18
|
const notice = options.notice ?? '';
|
|
19
|
-
const model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
|
|
19
|
+
const model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [], ...(options.focus !== undefined ? { focus: options.focus } : {}), ...(options.at !== undefined ? { at: options.at } : {}) };
|
|
20
20
|
let seen = null;
|
|
21
21
|
const show = () => surface.show(page(model));
|
|
22
22
|
// Her describe, again: the page follows the digest.
|
package/dist/human/tab.d.ts
CHANGED
package/dist/human/tab.js
CHANGED
|
@@ -162,7 +162,8 @@ export async function start(cfg, root = document.body) {
|
|
|
162
162
|
keep(AT(pk), rel.key);
|
|
163
163
|
const mine = el('div', '', { class: 'far' });
|
|
164
164
|
screen.append(mine);
|
|
165
|
-
|
|
165
|
+
// the page's address: the world's page, or one being she carries as the whole page
|
|
166
|
+
const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice, at: pageOf(at.name), ...(cfg.being ? { focus: cfg.being } : {}) });
|
|
166
167
|
side = s;
|
|
167
168
|
const bp = s.model.blueprint;
|
|
168
169
|
const notesName = typeof bp?.notes?.name === 'string' ? bp.notes.name : '';
|
|
@@ -171,7 +172,8 @@ export async function start(cfg, root = document.body) {
|
|
|
171
172
|
keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
172
173
|
switcher();
|
|
173
174
|
people();
|
|
174
|
-
|
|
175
|
+
if (!cfg.being)
|
|
176
|
+
await boot(rel); // a being's page is hers alone: the world's tab beings stay on the world's page
|
|
175
177
|
};
|
|
176
178
|
// The way in, from any of the three: a fresh avatar joins, the ward is
|
|
177
179
|
// saved, and she is the one on screen.
|
package/dist/human/web.js
CHANGED
|
@@ -54,8 +54,8 @@ export function webRoute(harbor, o) {
|
|
|
54
54
|
return true;
|
|
55
55
|
};
|
|
56
56
|
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
57
|
-
const tab = (ward) => {
|
|
58
|
-
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
|
|
57
|
+
const tab = (ward, being) => {
|
|
58
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}), ...(being ? { being } : {}) };
|
|
59
59
|
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
60
60
|
};
|
|
61
61
|
return async (req, res, rest) => {
|
|
@@ -88,6 +88,10 @@ export function webRoute(harbor, o) {
|
|
|
88
88
|
return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
89
89
|
if (parts.length === 1 && req.method === 'GET')
|
|
90
90
|
return html(200, res, tab(wardName));
|
|
91
|
+
// a being's page: one standing the user being carries, by its id, as the whole page. No router:
|
|
92
|
+
// the path is a key, and the tab paints nothing for a key her describe does not carry
|
|
93
|
+
if (parts.length === 2 && req.method === 'GET' && /^[\w.-]{1,80}$/.test(parts[1]))
|
|
94
|
+
return html(200, res, tab(wardName, parts[1]));
|
|
91
95
|
return false;
|
|
92
96
|
};
|
|
93
97
|
}
|
|
@@ -116,8 +120,11 @@ const CSS = [
|
|
|
116
120
|
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
117
121
|
// a row with a picture is a line about someone or something: the picture small and the words beside it, centred
|
|
118
122
|
'.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
|
|
119
|
-
// a far page inside a standing's section: her title is a section's, not the page's
|
|
123
|
+
// a far page inside a standing's section: her title is a section's, not the page's; a link to her own page
|
|
120
124
|
'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
|
|
125
|
+
'a.open{display:inline-block;font-size:.85rem;color:var(--mute);margin:.25rem 0 .5rem}a.back{font-size:.9rem;color:var(--mute);text-decoration:none}a.back::before{content:"\\2190 "}',
|
|
126
|
+
// a being\'s page, the whole page: her section is the page and her title the page\'s
|
|
127
|
+
'main.focus section.standing{border:0;padding:0;margin:0}main.focus section.standing .page .t-title{font-size:2rem;margin:.5rem 0}',
|
|
121
128
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
122
129
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
123
130
|
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
package/harbor/capacitor.ts
CHANGED
|
@@ -41,7 +41,7 @@ type Blob = { seed: string; partition: Record<string, unknown>; record: WardReco
|
|
|
41
41
|
|
|
42
42
|
// Where the files live: the folder iCloud does not copy on iOS, the app's
|
|
43
43
|
// own files on Android, whose manifest says no backup.
|
|
44
|
-
const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
44
|
+
export const directory = Capacitor.getPlatform() === 'ios' ? Directory.LibraryNoCloud : Directory.Data;
|
|
45
45
|
|
|
46
46
|
async function exists(path: string): Promise<boolean> {
|
|
47
47
|
try {
|
package/harbor/disk.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { mkdir, readFile, writeFile, unlink, stat } from 'node:fs/promises';
|
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { join, resolve, isAbsolute } from 'node:path';
|
|
19
19
|
import { pathToFileURL } from 'node:url';
|
|
20
|
+
import { register } from 'node:module';
|
|
20
21
|
import process from 'node:process';
|
|
21
22
|
import type { BeingClass } from '@quo-systems/quo';
|
|
22
23
|
import { User, Desk, Avatar } from '../beings/index.ts';
|
|
@@ -33,11 +34,23 @@ export const BUILT_IN: Record<string, BeingClass> = { User, Desk, Avatar };
|
|
|
33
34
|
// second ward with one pk.
|
|
34
35
|
const HELD = new Set<string>();
|
|
35
36
|
|
|
37
|
+
// A class file in the harbor folder resolves its packages from where the
|
|
38
|
+
// dock is installed, `resolve.ts`: the folder has no node_modules and is not
|
|
39
|
+
// meant to. Registered once per process, before the first class loads.
|
|
40
|
+
let hooked = false;
|
|
41
|
+
function hook(): void {
|
|
42
|
+
if (hooked) return;
|
|
43
|
+
hooked = true;
|
|
44
|
+
const self = import.meta.url;
|
|
45
|
+
register(new URL(self.endsWith('.ts') ? './resolve.ts' : './resolve.js', self), { parentURL: self, data: { parent: self } });
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
// The class source is a module. Every export that is a class is a class the
|
|
37
49
|
// harbor holds, under its export name. The harbor never sees a body: it
|
|
38
50
|
// constructs when a ward names a class, and never chooses one.
|
|
39
51
|
async function loadClasses(path: string): Promise<Record<string, BeingClass>> {
|
|
40
52
|
if (!existsSync(path)) return {};
|
|
53
|
+
hook();
|
|
41
54
|
// Keyed by the file's own time, so a source that changed on disk is read
|
|
42
55
|
// again and a harbor boots on what it pins, not on what it once loaded.
|
|
43
56
|
const { mtimeMs } = await stat(path);
|
package/harbor/quo-harbor.md
CHANGED
|
@@ -339,6 +339,15 @@ core plus files, the loader and the pid, and holds the `ws` listener's end of
|
|
|
339
339
|
every socket dialed to it; the browser harbor, `browser.ts`, is the core plus
|
|
340
340
|
IndexedDB, the built-in beings, the lock and one dialer per world.
|
|
341
341
|
|
|
342
|
+
The harbor folder is the device's and not a package: it has no node_modules
|
|
343
|
+
and is not meant to, and a class file in it imports the library by name like
|
|
344
|
+
any code. So the disk harbor resolves a class file's packages from where the
|
|
345
|
+
dock itself is installed, `resolve.ts`, a module resolution hook registered
|
|
346
|
+
once before the first class loads: a bare name that does not resolve from a
|
|
347
|
+
file outside any node_modules is resolved again from the dock's own place.
|
|
348
|
+
A class sees the packages the daemon sees, the library and the dock's own,
|
|
349
|
+
and one copy of each, so a `Being` in a class file is the daemon's `Being`.
|
|
350
|
+
|
|
342
351
|
A tab's store keeps values through JSON, as a file does, because the ward
|
|
343
352
|
hands its partition out through a guard that structured clone refuses.
|
|
344
353
|
The daemon's `/quo` and `/health` answer any origin, with the preflight a
|
|
@@ -426,18 +435,32 @@ the App plugin's foreground event tells it `wake`, which tells every
|
|
|
426
435
|
dialer, because a phone asleep loses its sockets silently and the wake is
|
|
427
436
|
what dials them back.
|
|
428
437
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
438
|
+
On Android the same store stands on the same two plugins: the key in the
|
|
439
|
+
Keystore-backed secure store, the files in the app's own data folder,
|
|
440
|
+
which the manifest excludes from backup with `allowBackup` false, so a
|
|
441
|
+
restore finds nothing there either.
|
|
442
|
+
|
|
443
|
+
The proof is two tests behind `npm run check:terrain` on one stage,
|
|
444
|
+
`packages/dock/test/terrain/phone.ts`: `ios.test.ts` inside the real app
|
|
445
|
+
in the iOS Simulator and `android.test.ts` inside it in the Android
|
|
446
|
+
emulator. Each runs the store suite, untouched, and the custody rule
|
|
447
|
+
against a real secret store and a real folder; the whole conformance
|
|
448
|
+
suite over two harbors on native stores, both dialing a daemon on the
|
|
449
|
+
Mac's loopback through the tab's own probe; and the wake, the app sent
|
|
450
|
+
behind Settings and brought back, its dialer told and its socket held
|
|
451
|
+
again. The app is synced with the stage's origin as its page, built with
|
|
452
|
+
xcodebuild or gradle, installed fresh and launched with simctl or adb; the
|
|
453
|
+
page loads the bundled exercise, `native.ts`, runs it and posts the list
|
|
454
|
+
back. The device names the Mac `localhost`, the Simulator because it
|
|
455
|
+
shares the Mac's network and the emulator because adb reverses the
|
|
456
|
+
stage's two ports, and it has to be that name: a webview grants WebCrypto
|
|
457
|
+
to a plain-http page only on `localhost`, the one origin it counts secure
|
|
458
|
+
without TLS. Through adb's reversed port a socket that Node dropped stays
|
|
459
|
+
open on the device's side, so the stage answers every request with the
|
|
460
|
+
connection closed and every fetch the page makes carries a timeout. Two
|
|
461
|
+
things the plugins taught, held in the store: the secure store keeps
|
|
462
|
+
JSON, so a value is read with the call that parses; and mkdir refuses a
|
|
463
|
+
folder that exists, recursive or not.
|
|
441
464
|
|
|
442
465
|
## The link
|
|
443
466
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// How a class file the harbor folder holds finds its packages. The folder
|
|
3
|
+
// is the device's, not a package: it has no node_modules, and a class in it
|
|
4
|
+
// that imports `@quo-systems/quo` would otherwise fail to resolve from where
|
|
5
|
+
// it sits. This is a module resolution hook, registered once by the disk
|
|
6
|
+
// harbor: a bare specifier that does not resolve from a file outside any
|
|
7
|
+
// node_modules is resolved again from where the dock itself is installed,
|
|
8
|
+
// so a class file sees exactly the packages the daemon sees, the library
|
|
9
|
+
// and the dock's own, and one copy of each.
|
|
10
|
+
type Context = { parentURL?: string; conditions: string[]; importAttributes: Record<string, string> };
|
|
11
|
+
type Resolved = { url: string; format?: string | null; shortCircuit?: boolean };
|
|
12
|
+
type Next = (specifier: string, context: Context) => Promise<Resolved>;
|
|
13
|
+
|
|
14
|
+
let parent = '';
|
|
15
|
+
|
|
16
|
+
export function initialize(data: { parent: string }): void {
|
|
17
|
+
parent = data.parent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const bare = (s: string) => !s.startsWith('.') && !s.startsWith('/') && !/^[a-z]+:/i.test(s);
|
|
21
|
+
|
|
22
|
+
export async function resolve(specifier: string, context: Context, next: Next): Promise<Resolved> {
|
|
23
|
+
try {
|
|
24
|
+
return await next(specifier, context);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
const missing = (e as { code?: string }).code === 'ERR_MODULE_NOT_FOUND';
|
|
27
|
+
const from = context.parentURL ?? '';
|
|
28
|
+
if (missing && parent && bare(specifier) && from.startsWith('file:') && !from.includes('/node_modules/')) return next(specifier, { ...context, parentURL: parent });
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/human/html.ts
CHANGED
|
@@ -143,6 +143,8 @@ export type Model = {
|
|
|
143
143
|
look: Look; // her own look, from her `look` ask, or nothing
|
|
144
144
|
tree: Node | null; // her page, from her `page` ask, or nothing: then her asks are painted as forms
|
|
145
145
|
pages: Record<string, Node | null>; // the page of each standing she carries, by id, from its carried `page` ask
|
|
146
|
+
focus?: string; // one standing she carries shown as the whole page, its id: the page at `web./<ward>/<being>`
|
|
147
|
+
at?: string; // the world's page path, so a section can say where its own page is and a focused page where back is
|
|
146
148
|
notice: string; // one line about where the human stands: in, not in, an error before an ask
|
|
147
149
|
answers: Record<string, Word>; // the last answer per ask, shown under its form
|
|
148
150
|
pushes: JsonObject[]; // every push from the world, newest last
|
|
@@ -215,6 +217,8 @@ export function page(m: Model): string {
|
|
|
215
217
|
const l = look(kept);
|
|
216
218
|
const prefix = `${id}-`;
|
|
217
219
|
const tree = m.pages[id];
|
|
220
|
+
// where her own page is, when the world's page is known and this is not it already
|
|
221
|
+
const own = m.at !== undefined && m.focus !== id ? `<a class="open" href="${escape(`${m.at}/${encodeURIComponent(id)}`)}">open</a>` : '';
|
|
218
222
|
if (tree) {
|
|
219
223
|
const hers: Painter = {
|
|
220
224
|
form: (name) => {
|
|
@@ -225,11 +229,11 @@ export function page(m: Model): string {
|
|
|
225
229
|
standing: () => '',
|
|
226
230
|
standings: () => '',
|
|
227
231
|
};
|
|
228
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div
|
|
232
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}><div class="page">${paint(tree, hers)}</div>${own}</section>`;
|
|
229
233
|
}
|
|
230
234
|
const want = (kept.order ?? []).map((n) => prefix + n);
|
|
231
235
|
const inOrder = ordered(asks, { order: want });
|
|
232
|
-
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}</section>`;
|
|
236
|
+
return `<section class="standing" data-standing="${escape(id)}"${l.style}>${l.head || `<h2>${escape(id)}</h2>`}${inOrder.map(one(kept, prefix)).join('')}${own}</section>`;
|
|
233
237
|
};
|
|
234
238
|
const far = Object.keys(groups).map(section).join('');
|
|
235
239
|
// Her page, when she answered one: painted from her tree, each name it
|
|
@@ -243,13 +247,17 @@ export function page(m: Model): string {
|
|
|
243
247
|
standing: section,
|
|
244
248
|
standings: () => far,
|
|
245
249
|
};
|
|
246
|
-
const
|
|
247
|
-
|
|
250
|
+
const mineAsks = bp ? ordered(bp.asks.filter((a) => !taken.has(a.name) && !pres.has(a.name)), m.look).map(one(m.look)).join('') : '';
|
|
251
|
+
// A being's page: one standing she carries as the whole page, and nothing of hers around it. A key her
|
|
252
|
+
// describe does not carry paints as nothing, the way a name on a page does: the address adds no right.
|
|
253
|
+
const focused = m.focus !== undefined;
|
|
254
|
+
const asks = focused ? section(m.focus!) : m.tree ? `<div class="page">${paint(m.tree, painter)}</div>` : mineAsks + far;
|
|
248
255
|
const shown = bp && bp.notes !== null && typeof bp.notes === 'object' && !Array.isArray(bp.notes) ? Object.fromEntries(Object.entries(bp.notes).filter(([k]) => k !== 'standings' && k !== 'name')) : bp?.notes;
|
|
249
256
|
const notes = bp && shown !== null && shown !== undefined && !(typeof shown === 'object' && !Array.isArray(shown) && !Object.keys(shown).length) ? `<aside class="notes">${view(shown as Json)}</aside>` : '';
|
|
250
257
|
const pushes = m.pushes.length ? `<section class="pushes"><h2>pushes</h2><ol>${m.pushes.map((p) => `<li>${view(p)}</li>`).join('')}</ol></section>` : '';
|
|
251
258
|
const mine = look(m.look);
|
|
252
259
|
// a page carries its own title, so the header keeps only the notice; a being painted as forms is headed by her name
|
|
253
|
-
const
|
|
254
|
-
|
|
260
|
+
const back = focused && m.at !== undefined ? `<a class="back" href="${escape(m.at)}">${escape(title(bp, m.look))}</a>` : '';
|
|
261
|
+
const head = focused ? back : m.tree ? '' : `${m.look.logo ? `<img class="logo" alt="" src="${m.look.logo}">` : ''}<h1>${escape(title(bp, m.look))}</h1>`;
|
|
262
|
+
return `<header${mine.style}>${head}<p class="notice">${escape(m.notice)}</p></header>${focused ? '' : notes}<main${focused ? ' class="focus"' : ''}${mine.style}>${asks}</main>${pushes}`;
|
|
255
263
|
}
|
package/human/quo-human.md
CHANGED
|
@@ -159,9 +159,17 @@ Read with the trunk's "Carrying" and "The look", which this applies.
|
|
|
159
159
|
page is a knock on the public invitation, and an answer that is an
|
|
160
160
|
invitation is the way in: the side hands it to `admit`, the avatar joins,
|
|
161
161
|
and the page becomes hers. Nothing in the screen knows the word desk.
|
|
162
|
-
- **Worlds have addresses
|
|
163
|
-
lists the worlds, and a hostname per world
|
|
164
|
-
estate's choice. `quo.` stays one per harbor,
|
|
162
|
+
- **Worlds have addresses, and so do the beings one holds.** `web./<ward>`
|
|
163
|
+
is that world's page, `web./` lists the worlds, and a hostname per world
|
|
164
|
+
is one proxy line, the estate's choice. `quo.` stays one per harbor,
|
|
165
|
+
since bytes route by pk. `web./<ward>/<being>` is the page of one
|
|
166
|
+
standing the user being carries, by its id, as the whole page: her
|
|
167
|
+
section is the page, her title the page's, the shell's own asks and the
|
|
168
|
+
world's tab beings stay on the world's page, and a line at the top leads
|
|
169
|
+
back. No router: the path is a key, the server hands it to the tab as it
|
|
170
|
+
hands the ward's name, and a key her describe does not carry for this
|
|
171
|
+
device paints as nothing, since an address adds no right. Every section
|
|
172
|
+
on the world's page says where its own page is.
|
|
165
173
|
- **A link is the page with the invitation in the fragment**, under the one
|
|
166
174
|
reserved key `quo`, the invitation compact as `ward.heir.secret` or
|
|
167
175
|
`ward` alone: `web.acme.com/shop#quo=...`. The fragment never leaves the
|
package/human/screen.ts
CHANGED
|
@@ -31,12 +31,15 @@ export type Surface = {
|
|
|
31
31
|
// would bring her back from a reload with a stale count, and the far door
|
|
32
32
|
// would refuse her. `admit` is what to do
|
|
33
33
|
// with an answer that is an invitation: a guest at a world's door is let in
|
|
34
|
-
// by it, and a side with no `admit` shows it as any answer.
|
|
35
|
-
|
|
34
|
+
// by it, and a side with no `admit` shows it as any answer. `focus` is one
|
|
35
|
+
// standing she carries shown as the whole page, the page at
|
|
36
|
+
// `web./<ward>/<being>`, and `at` the world's page path the sections and a
|
|
37
|
+
// focused page link to.
|
|
38
|
+
export type Options = { after?: () => Promise<void>; notice?: string; admit?: (invitation: Invitation) => Promise<void>; focus?: string; at?: string };
|
|
36
39
|
export async function screenSide(avatar: Subject, surface: Surface, options: Options = {}): Promise<Serving & { model: Model; refresh(): Promise<void> }> {
|
|
37
40
|
const after = options.after ?? (async () => {});
|
|
38
41
|
const notice = options.notice ?? '';
|
|
39
|
-
const model: Model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [] };
|
|
42
|
+
const model: Model = { blueprint: null, look: {}, tree: null, pages: {}, notice, answers: {}, pushes: [], ...(options.focus !== undefined ? { focus: options.focus } : {}), ...(options.at !== undefined ? { at: options.at } : {}) };
|
|
40
43
|
let seen: string | null = null;
|
|
41
44
|
const show = () => surface.show(page(model));
|
|
42
45
|
|
package/human/tab.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { BeingClass } from '@quo-systems/quo';
|
|
|
40
40
|
// every ward on that harbor by name, its pk and whether a public being is
|
|
41
41
|
// at its door, which of them this page is, if it is one's, and whether the
|
|
42
42
|
// world serves code for the tab.
|
|
43
|
-
export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; beings?: boolean };
|
|
43
|
+
export type Config = { quo: string; web: string; wards: Record<string, { pk: string; public: boolean }>; ward?: string; being?: string; beings?: boolean };
|
|
44
44
|
|
|
45
45
|
// What the tab remembers between pages, beside the harbor: the worlds it
|
|
46
46
|
// has joined, by pk, where each lives and what it is called; which relation
|
|
@@ -190,7 +190,8 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
|
|
|
190
190
|
keep(AT(pk), rel.key);
|
|
191
191
|
const mine = el('div', '', { class: 'far' });
|
|
192
192
|
screen.append(mine);
|
|
193
|
-
|
|
193
|
+
// the page's address: the world's page, or one being she carries as the whole page
|
|
194
|
+
const s = await screenSide(rel.avatar, domSurface(mine), { after: () => ward.save(), notice, at: pageOf(at.name), ...(cfg.being ? { focus: cfg.being } : {}) });
|
|
194
195
|
side = s;
|
|
195
196
|
const bp = s.model.blueprint;
|
|
196
197
|
const notesName = typeof (bp?.notes as JsonObject | null)?.name === 'string' ? ((bp!.notes as JsonObject).name as string) : '';
|
|
@@ -198,7 +199,7 @@ export async function start(cfg: Config, root: HTMLElement = document.body): Pro
|
|
|
198
199
|
if (!names()[rel.key]) keep(NAMES(pk), { ...names(), [rel.key]: called });
|
|
199
200
|
switcher();
|
|
200
201
|
people();
|
|
201
|
-
await boot(rel);
|
|
202
|
+
if (!cfg.being) await boot(rel); // a being's page is hers alone: the world's tab beings stay on the world's page
|
|
202
203
|
};
|
|
203
204
|
|
|
204
205
|
// The way in, from any of the three: a fresh avatar joins, the ward is
|
package/human/web.ts
CHANGED
|
@@ -84,8 +84,8 @@ export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessa
|
|
|
84
84
|
return true;
|
|
85
85
|
};
|
|
86
86
|
const wards = () => Object.fromEntries([...harbor.wards].map(([n, h]) => [n, { pk: h.pk, public: publicOf(h) !== null }]));
|
|
87
|
-
const tab = (ward?: string) => {
|
|
88
|
-
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}) };
|
|
87
|
+
const tab = (ward?: string, being?: string) => {
|
|
88
|
+
const cfg = { quo: o.at.quo, web: o.at.web, wards: wards(), beings: existsSync(beingsEntry), ...(ward ? { ward } : {}), ...(being ? { being } : {}) };
|
|
89
89
|
return `<script id="quo" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script type="module" src="${o.at.web}/tab.js"></script>`;
|
|
90
90
|
};
|
|
91
91
|
return async (req, res, rest) => {
|
|
@@ -114,6 +114,9 @@ export function webRoute(harbor: DiskHarbor, o: WebOptions): (req: IncomingMessa
|
|
|
114
114
|
const hosted = harbor.wards.get(wardName);
|
|
115
115
|
if (!hosted) return html(404, res, `<h1>no such world</h1><p>no ward named ${esc(wardName)} on this harbor.</p>`);
|
|
116
116
|
if (parts.length === 1 && req.method === 'GET') return html(200, res, tab(wardName));
|
|
117
|
+
// a being's page: one standing the user being carries, by its id, as the whole page. No router:
|
|
118
|
+
// the path is a key, and the tab paints nothing for a key her describe does not carry
|
|
119
|
+
if (parts.length === 2 && req.method === 'GET' && /^[\w.-]{1,80}$/.test(parts[1]!)) return html(200, res, tab(wardName, parts[1]!));
|
|
117
120
|
return false;
|
|
118
121
|
};
|
|
119
122
|
}
|
|
@@ -143,8 +146,11 @@ const CSS = [
|
|
|
143
146
|
'.page .picture{max-height:6rem}.cards{display:flex;flex-wrap:wrap;gap:.75rem}.card{border:1px solid color-mix(in srgb,currentColor 20%,transparent);border-radius:var(--radius);padding:.5rem .75rem}',
|
|
144
147
|
// a row with a picture is a line about someone or something: the picture small and the words beside it, centred
|
|
145
148
|
'.page .row:has(>.picture){align-items:center;flex-wrap:nowrap}.page .row>.picture{max-height:2.75rem;flex:none}.page .row>.stack{gap:0;min-width:0}.page .row>.stack>p{margin:0}',
|
|
146
|
-
// a far page inside a standing's section: her title is a section's, not the page's
|
|
149
|
+
// a far page inside a standing's section: her title is a section's, not the page's; a link to her own page
|
|
147
150
|
'section.standing .page{padding:.5rem 0}section.standing .page .t-title{font-size:1.3rem;margin:.25rem 0}',
|
|
151
|
+
'a.open{display:inline-block;font-size:.85rem;color:var(--mute);margin:.25rem 0 .5rem}a.back{font-size:.9rem;color:var(--mute);text-decoration:none}a.back::before{content:"\\2190 "}',
|
|
152
|
+
// a being\'s page, the whole page: her section is the page and her title the page\'s
|
|
153
|
+
'main.focus section.standing{border:0;padding:0;margin:0}main.focus section.standing .page .t-title{font-size:2rem;margin:.5rem 0}',
|
|
148
154
|
// the door page: one column, generous air, the mark, a word, a sentence
|
|
149
155
|
'main.door{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;max-width:32rem;margin:0 auto;padding:3rem 0;font-family:ui-serif,Georgia,"Times New Roman",serif}',
|
|
150
156
|
'main.door .picture{width:3.5rem;height:3.5rem;color:var(--ink);opacity:.9;margin-bottom:1.25rem}',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quo-systems/dock",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "The dock: what every estate on Quo needs and nobody writes twice. A daemon and the quo command, the front desk, the user being and the avatar, harbors on disk, in a tab and on the edge, the model sides and the screen.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quo",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp harbor/edge/platform.d.ts dist/harbor/edge/ && cp -R cli/estate dist/cli/",
|
|
73
73
|
"test": "node --test \"test/*.test.ts\"",
|
|
74
74
|
"check:terrain": "node --test \"test/terrain/*.test.ts\"",
|
|
75
|
-
"prepublishOnly": "
|
|
75
|
+
"prepublishOnly": "test \"$QUO_GATED\" = 1 || { echo 'publish from the root, gated once: npm run release' >&2; exit 1; }"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@capacitor/core": "^8.5.1",
|
|
81
81
|
"@capacitor/filesystem": "^8.1.3",
|
|
82
82
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
83
|
-
"@quo-systems/quo": "^0.2.
|
|
83
|
+
"@quo-systems/quo": "^0.2.7",
|
|
84
84
|
"esbuild": "^0.28.2",
|
|
85
85
|
"ws": "^8.21.3"
|
|
86
86
|
},
|