@braidlabs/console 0.7.1 → 0.8.1

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/src/lib/styles.js CHANGED
@@ -488,6 +488,16 @@ export const CONSOLE_STYLES = `
488
488
  .braid-console__area:focus-visible { outline: 2px solid var(--bc-accent); outline-offset: 1px; }
489
489
  .braid-console__areaname { font-family: var(--bc-mono); font-size: 11px; opacity: 0.85; }
490
490
  .braid-console__areafragment { font-family: var(--bc-mono); font-size: 13px; font-weight: 600; }
491
+ .braid-console__area.is-workspace {
492
+ border: 1px solid var(--bc-accent); color: var(--bc-text);
493
+ background: repeating-linear-gradient(135deg, var(--bc-surface) 0 10px, var(--bc-surface-alt) 10px 20px);
494
+ }
495
+ .braid-console__areaapps { display: flex; flex-wrap: wrap; gap: 4px; font-family: var(--bc-mono); font-size: 12px; }
496
+ .braid-console__areaapps > span { padding: 1px 6px; border: 1px solid var(--bc-border-strong); border-radius: 999px; background: var(--bc-bg); }
497
+ .braid-console__areaapps > em { color: var(--bc-muted); }
498
+ .braid-console__applist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
499
+ .braid-console__applist > li { display: flex; align-items: center; gap: 6px; }
500
+ .braid-console__preview > .braid-console__check { align-items: baseline; padding-bottom: 0; }
491
501
  .braid-console__head { border: 1px solid var(--bc-border); border-radius: var(--bc-radius); margin: 0; }
492
502
  .braid-console__head .braid-console__notice, .braid-console__subhead, .braid-console__head > .braid-console__desc, .braid-console__add { grid-column: 1 / -1; }
493
503
  .braid-console__subhead { margin: 6px 0 0; font-size: 13px; font-weight: 600; }
@@ -1 +1 @@
1
- {"version":3,"file":"styles.js","sourceRoot":"","sources":["../../../../../libs/braid-console/src/lib/styles.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqf7B,GAAG,UAAU,CAAC;AAEf,MAAM,gBAAgB,GAAG,sBAAsB,CAAC;AAEhD,0FAA0F;AAC1F,MAAM,UAAU,YAAY,CAAC,MAAgB,QAAQ;IACnD,IAAI,GAAG,CAAC,cAAc,CAAC,gBAAgB,CAAC;QAAE,OAAO;IAEjD,MAAM,KAAK,GAAG,GAAG,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IACzC,KAAK,CAAC,EAAE,GAAG,gBAAgB,CAAC;IAC5B,KAAK,CAAC,WAAW,GAAG,cAAc,CAAC;IACnC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"styles.js","sourceRoot":"","sources":["../../../../../libs/braid-console/src/lib/styles.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAEvC;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+f7B,GAAG,UAAU,CAAC;AAEf,MAAM,gBAAgB,GAAG,sBAAsB,CAAC;AAEhD,0FAA0F;AAC1F,MAAM,UAAU,YAAY,CAAC,MAAgB,QAAQ;IACnD,IAAI,GAAG,CAAC,cAAc,CAAC,gBAAgB,CAAC;QAAE,OAAO;IAEjD,MAAM,KAAK,GAAG,GAAG,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IACzC,KAAK,CAAC,EAAE,GAAG,gBAAgB,CAAC;IAC5B,KAAK,CAAC,WAAW,GAAG,cAAc,CAAC;IACnC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACzB,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Where the workspace keeps what it remembers for a user: intent choices, host preferences, each
3
+ * app's settings and saved layouts. Values are JSON; keys are `/`-separated paths such as
4
+ * `apps/billing/theme` (see {@link isStoreKey}).
5
+ *
6
+ * Two are provided, and any other storage can implement the interface:
7
+ *
8
+ * - {@link localStorageStore} — in the browser, for this origin and browser profile.
9
+ * - {@link serverStore} — on the gateway (`createGateway({ preferences })`), keyed by the
10
+ * signed-in user, so it follows them to any browser.
11
+ */
12
+ export interface PreferenceStore {
13
+ /** The value, or undefined when there is none. */
14
+ get(key: string): Promise<unknown>;
15
+ set(key: string, value: unknown): Promise<void>;
16
+ delete(key: string): Promise<void>;
17
+ /** Keys that start with `prefix`, sorted. */
18
+ list(prefix: string): Promise<string[]>;
19
+ }
20
+ /**
21
+ * Whether `key` can be stored: up to 512 characters in at most eight `/`-separated segments of
22
+ * `A-Z a-z 0-9 . _ ~ % -`, none of them `.` or `..`. The gateway checks the same rule.
23
+ */
24
+ export declare function isStoreKey(key: string): boolean;
25
+ /** A segment for a store key from any string — a layout's name, an intent, an app id. */
26
+ export declare function keySegment(value: string): string;
27
+ /** Entries in memory, gone on reload. For tests, and where nothing should persist. */
28
+ export declare function memoryStore(): PreferenceStore;
29
+ export interface LocalStorageStoreOptions {
30
+ /** Prepended to every key in `localStorage`, so the workspace's entries stay apart from the page's. Defaults to `braid.workspace/`. */
31
+ prefix?: string;
32
+ /** Defaults to `localStorage`. */
33
+ storage?: Storage;
34
+ }
35
+ /**
36
+ * Entries in `localStorage`: per origin and browser profile, kept across reloads. Where
37
+ * `localStorage` is unavailable (blocked, or not a browser) it keeps them in memory instead, so a
38
+ * page never fails to compose over its preferences.
39
+ */
40
+ export declare function localStorageStore(options?: LocalStorageStoreOptions): PreferenceStore;
41
+ export interface ServerStoreOptions {
42
+ /** The gateway's preferences endpoint. Defaults to `/__braid/preferences`. */
43
+ url?: string;
44
+ /** Defaults to `globalThis.fetch`. */
45
+ fetch?: typeof fetch;
46
+ }
47
+ /**
48
+ * Entries on the gateway (`createGateway({ preferences })`), keyed by the signed-in user: they
49
+ * follow the user to any browser. Requests carry the page's cookies (`credentials: 'same-origin'`);
50
+ * a failed request rejects with the status, so the caller sees it rather than silently losing a
51
+ * write.
52
+ */
53
+ export declare function serverStore(options?: ServerStoreOptions): PreferenceStore;
@@ -0,0 +1,139 @@
1
+ const SEGMENT = /^[A-Za-z0-9._~%-]{1,128}$/;
2
+ /**
3
+ * Whether `key` can be stored: up to 512 characters in at most eight `/`-separated segments of
4
+ * `A-Z a-z 0-9 . _ ~ % -`, none of them `.` or `..`. The gateway checks the same rule.
5
+ */
6
+ export function isStoreKey(key) {
7
+ if (key.length === 0 || key.length > 512)
8
+ return false;
9
+ const segments = key.split('/');
10
+ return segments.length <= 8 && segments.every((segment) => SEGMENT.test(segment) && segment !== '.' && segment !== '..');
11
+ }
12
+ /** A segment for a store key from any string — a layout's name, an intent, an app id. */
13
+ export function keySegment(value) {
14
+ // encodeURIComponent leaves only A-Z a-z 0-9 - _ . ! ~ * ' ( ) unescaped; escape the rest of those too
15
+ const encoded = encodeURIComponent(value).replace(/[!*'()]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
16
+ return encoded === '.' ? '%2E' : encoded === '..' ? '%2E%2E' : encoded || '%00';
17
+ }
18
+ function checkKey(key) {
19
+ if (!isStoreKey(key))
20
+ throw new TypeError(`braid-workspace: "${key}" is not a store key`);
21
+ }
22
+ /** A JSON round trip: what a store holds is data, never a live object shared with the caller. */
23
+ function toJson(value) {
24
+ const json = JSON.stringify(value);
25
+ if (json === undefined)
26
+ throw new TypeError('braid-workspace: a stored value must be JSON');
27
+ return json;
28
+ }
29
+ /** Entries in memory, gone on reload. For tests, and where nothing should persist. */
30
+ export function memoryStore() {
31
+ const entries = new Map();
32
+ return {
33
+ async get(key) {
34
+ checkKey(key);
35
+ const json = entries.get(key);
36
+ return json === undefined ? undefined : JSON.parse(json);
37
+ },
38
+ async set(key, value) {
39
+ checkKey(key);
40
+ entries.set(key, toJson(value));
41
+ },
42
+ async delete(key) {
43
+ checkKey(key);
44
+ entries.delete(key);
45
+ },
46
+ async list(prefix) {
47
+ return [...entries.keys()].filter((key) => key.startsWith(prefix)).sort();
48
+ },
49
+ };
50
+ }
51
+ /**
52
+ * Entries in `localStorage`: per origin and browser profile, kept across reloads. Where
53
+ * `localStorage` is unavailable (blocked, or not a browser) it keeps them in memory instead, so a
54
+ * page never fails to compose over its preferences.
55
+ */
56
+ export function localStorageStore(options = {}) {
57
+ const prefix = options.prefix ?? 'braid.workspace/';
58
+ let storage;
59
+ try {
60
+ storage = options.storage ?? globalThis.localStorage;
61
+ }
62
+ catch {
63
+ // reading localStorage throws a SecurityError where storage is blocked
64
+ }
65
+ if (!storage)
66
+ return memoryStore();
67
+ const local = storage;
68
+ return {
69
+ async get(key) {
70
+ checkKey(key);
71
+ const json = local.getItem(prefix + key);
72
+ if (json === null)
73
+ return undefined;
74
+ try {
75
+ return JSON.parse(json);
76
+ }
77
+ catch {
78
+ // someone else's write under our prefix: not ours to interpret
79
+ return undefined;
80
+ }
81
+ },
82
+ async set(key, value) {
83
+ checkKey(key);
84
+ local.setItem(prefix + key, toJson(value));
85
+ },
86
+ async delete(key) {
87
+ checkKey(key);
88
+ local.removeItem(prefix + key);
89
+ },
90
+ async list(keyPrefix) {
91
+ const keys = [];
92
+ for (let index = 0; index < local.length; index++) {
93
+ const name = local.key(index);
94
+ if (name?.startsWith(prefix + keyPrefix) && isStoreKey(name.slice(prefix.length)))
95
+ keys.push(name.slice(prefix.length));
96
+ }
97
+ return keys.sort();
98
+ },
99
+ };
100
+ }
101
+ /**
102
+ * Entries on the gateway (`createGateway({ preferences })`), keyed by the signed-in user: they
103
+ * follow the user to any browser. Requests carry the page's cookies (`credentials: 'same-origin'`);
104
+ * a failed request rejects with the status, so the caller sees it rather than silently losing a
105
+ * write.
106
+ */
107
+ export function serverStore(options = {}) {
108
+ const base = (options.url ?? '/__braid/preferences').replace(/\/+$/, '');
109
+ const send = (input, init = {}) => (options.fetch ?? globalThis.fetch)(input, { credentials: 'same-origin', cache: 'no-store', ...init });
110
+ const url = (key) => {
111
+ checkKey(key);
112
+ return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
113
+ };
114
+ async function ok(response, what) {
115
+ if (!response.ok)
116
+ throw new Error(`braid-workspace: ${what} failed with ${response.status}`);
117
+ return response;
118
+ }
119
+ return {
120
+ async get(key) {
121
+ const response = await send(url(key), { headers: { accept: 'application/json' } });
122
+ if (response.status === 404)
123
+ return undefined;
124
+ return (await ok(response, `reading "${key}"`)).json();
125
+ },
126
+ async set(key, value) {
127
+ await ok(await send(url(key), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: toJson(value) }), `writing "${key}"`);
128
+ },
129
+ async delete(key) {
130
+ await ok(await send(url(key), { method: 'DELETE' }), `deleting "${key}"`);
131
+ },
132
+ async list(prefix) {
133
+ const response = await ok(await send(`${base}?prefix=${encodeURIComponent(prefix)}`), `listing "${prefix}"`);
134
+ const { keys } = (await response.json());
135
+ return Array.isArray(keys) ? keys.filter((key) => typeof key === 'string').sort() : [];
136
+ },
137
+ };
138
+ }
139
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../../../../libs/braid-console/src/workspace/store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,159 @@
1
+ import { type PreferenceStore } from './store.js';
2
+ /**
3
+ * A workspace for one Braid page: an area of the page where apps are launched as
4
+ * `<fragment-slot>`s, plus what the page remembers for its user — preferences, each app's
5
+ * settings, and named layouts of that area.
6
+ *
7
+ * It is a feature of the page, not of any interop standard, and needs none: with no FDC3 agent it
8
+ * launches, saves and restores plain fragments. Beside `@braidlabs/fdc3` it composes around the
9
+ * agent through the agent's two hooks, and adds what FDC3 leaves to the platform:
10
+ *
11
+ * ```ts
12
+ * const workspace = createWorkspace(); // the page's [data-braid-workspace]
13
+ * const agent = createBraidDesktopAgent({
14
+ * directory,
15
+ * ...workspace.fdc3(dialogResolver(document, { remember: true })),
16
+ * });
17
+ * initBraid({ realmGlobals: workspace.realmGlobals(agent.realmGlobals) });
18
+ * ```
19
+ */
20
+ export interface WorkspaceOptions {
21
+ /**
22
+ * The workspace area: launched apps are mounted here, and a layout is its `<fragment-slot>`
23
+ * children. Defaults to the page's `[data-braid-workspace]` element, which is what a shell
24
+ * designed in the console with a workspace area renders — looked up when first needed, so a
25
+ * framework may render it after the workspace is created. `null` is no area. Without one,
26
+ * preferences and app settings still work; launching and layouts reject.
27
+ */
28
+ container?: Element | null;
29
+ /**
30
+ * Where preferences, app settings and layouts are kept. Defaults to {@link localStorageStore};
31
+ * {@link serverStore} keeps them on the gateway, per signed-in user.
32
+ */
33
+ store?: PreferenceStore;
34
+ }
35
+ /** The user's preferences for the whole page. */
36
+ export interface HostPreferences {
37
+ /** An FDC3 user channel id (`fdc3.channel.1`…`8`) every fragment joins before its code runs. Needs an FDC3 agent. */
38
+ defaultUserChannel?: string;
39
+ /** App ids never offered as a choice when resolving an intent. */
40
+ hiddenApps?: string[];
41
+ }
42
+ /** What {@link Workspace.layouts} saves: the workspace area's slots, in order. */
43
+ export interface SavedLayout {
44
+ version: 1;
45
+ savedAt: string;
46
+ slots: SavedSlot[];
47
+ }
48
+ export interface SavedSlot {
49
+ appId: string;
50
+ src?: string;
51
+ props?: Record<string, unknown>;
52
+ /** The FDC3 user channel the instance was on, when the page runs an agent. */
53
+ channel?: string;
54
+ /** What the app returned from `braidDesktop.onSaveLayout`. */
55
+ state?: unknown;
56
+ }
57
+ /** The workspace's additions for one fragment, as `window.braidDesktop`. */
58
+ export interface BraidDesktopApi {
59
+ /** This app's settings for the signed-in user: shared by its instances, apart from other apps'. */
60
+ settings: {
61
+ get(key: string): Promise<unknown>;
62
+ set(key: string, value: unknown): Promise<void>;
63
+ delete(key: string): Promise<void>;
64
+ keys(): Promise<string[]>;
65
+ };
66
+ /**
67
+ * Supplies this instance's state when the workspace's layout is saved: JSON, within 2 seconds.
68
+ * The handler registered last is the one asked. Returns an unsubscribe.
69
+ */
70
+ onSaveLayout(handler: () => unknown): () => void;
71
+ /** The state this instance saved, when it was mounted by restoring a layout; otherwise undefined. */
72
+ readonly restoredState: unknown;
73
+ }
74
+ /** What `initBraid({ realmGlobals })` calls for each fragment. */
75
+ export interface WorkspaceFragment {
76
+ fragmentId: string;
77
+ signal: AbortSignal;
78
+ /** The `<fragment-slot>` the fragment boots in. */
79
+ slot?: Element;
80
+ }
81
+ /** A `realmGlobals` hook, such as an FDC3 agent's. */
82
+ export type RealmGlobals<F extends WorkspaceFragment = WorkspaceFragment> = (fragment: F) => Record<string, unknown> | undefined | Promise<Record<string, unknown> | undefined>;
83
+ /** The parts of an intent candidate the workspace reads; an FDC3 agent's `IntentCandidate` is one. */
84
+ export interface WorkspaceIntentCandidate {
85
+ intent: string;
86
+ appId: string;
87
+ instanceId?: string;
88
+ }
89
+ /** An intent resolver hook, as `createBraidDesktopAgent({ resolveIntent })` takes. */
90
+ export type WorkspaceIntentResolver<C extends WorkspaceIntentCandidate, R extends {
91
+ context?: {
92
+ type?: unknown;
93
+ };
94
+ candidates: C[];
95
+ }> = (request: R) => Promise<{
96
+ candidate: C;
97
+ remember?: boolean;
98
+ } | undefined>;
99
+ export interface Workspace {
100
+ /** The workspace area, or undefined when the page has none. */
101
+ readonly container: Element | undefined;
102
+ /**
103
+ * The hook for `initBraid({ realmGlobals })`. Every fragment gets `braidDesktop`, on top of
104
+ * whatever `inner` gives it — pass an FDC3 agent's `realmGlobals` and a fragment with an `fdc3`
105
+ * also joins its saved channel, or the user's default one, before any of its code runs.
106
+ */
107
+ realmGlobals<F extends WorkspaceFragment>(inner?: RealmGlobals<F>): (fragment: F) => Promise<Record<string, unknown>>;
108
+ /**
109
+ * Wraps an intent resolver with the user's preferences: hidden apps are never offered, a
110
+ * remembered choice answers without asking, and an answer with `remember: true` is kept.
111
+ * Without `inner`, a raise that still has several candidates is cancelled.
112
+ *
113
+ * Hiding governs the choice, not delivery: an agent asks a resolver only when several apps
114
+ * could take an intent, so an app that is the only one able to still gets it.
115
+ */
116
+ intentResolver<C extends WorkspaceIntentCandidate, R extends {
117
+ context?: {
118
+ type?: unknown;
119
+ };
120
+ candidates: C[];
121
+ }>(inner?: WorkspaceIntentResolver<C, R>): WorkspaceIntentResolver<C, R>;
122
+ /**
123
+ * The options that put an FDC3 agent on this workspace, to spread into
124
+ * `createBraidDesktopAgent({ directory, ...workspace.fdc3(resolver) })`: the agent launches into
125
+ * the workspace area (looked up at each launch, so a framework may render it late), and `inner`
126
+ * is wrapped as {@link Workspace.intentResolver} does.
127
+ */
128
+ fdc3<C extends WorkspaceIntentCandidate, R extends {
129
+ context?: {
130
+ type?: unknown;
131
+ };
132
+ candidates: C[];
133
+ }>(inner?: WorkspaceIntentResolver<C, R>): {
134
+ container: () => Element | undefined;
135
+ resolveIntent: WorkspaceIntentResolver<C, R>;
136
+ };
137
+ /** Mounts an app at the end of the workspace area, and returns its `<fragment-slot>`. */
138
+ open(appId: string, options?: {
139
+ src?: string;
140
+ props?: Record<string, unknown>;
141
+ }): Element;
142
+ preferences: {
143
+ getHost(): Promise<HostPreferences>;
144
+ /** Replaces the page's preferences; fragments that boot from now on get them. */
145
+ setHost(preferences: HostPreferences): Promise<void>;
146
+ /** The app remembered for an intent and context type, if any. */
147
+ intentChoice(intent: string, contextType: string): Promise<string | undefined>;
148
+ forgetIntentChoice(intent: string, contextType: string): Promise<void>;
149
+ };
150
+ /** Saved layouts of the workspace area. */
151
+ layouts: {
152
+ save(name: string): Promise<SavedLayout>;
153
+ /** Replaces the area's slots with the saved ones. Rejects if there is no such layout. */
154
+ restore(name: string): Promise<void>;
155
+ list(): Promise<string[]>;
156
+ delete(name: string): Promise<void>;
157
+ };
158
+ }
159
+ export declare function createWorkspace(options?: WorkspaceOptions): Workspace;
@@ -0,0 +1,274 @@
1
+ import { keySegment, localStorageStore } from './store.js';
2
+ /** How long saving a layout waits for each app's state. */
3
+ const SAVE_STATE_TIMEOUT_MS = 2_000;
4
+ /** How long a fragment's boot waits to read the page's preferences before going without. */
5
+ const HOST_READ_TIMEOUT_MS = 1_000;
6
+ /** How long a fragment's boot waits to join its channel. */
7
+ const JOIN_TIMEOUT_MS = 2_000;
8
+ const HOST_KEY = 'host';
9
+ const intentKey = (intent, contextType) => `intents/${keySegment(intent)}/${keySegment(contextType)}`;
10
+ const layoutKey = (name) => `layouts/${keySegment(name)}`;
11
+ const appPrefix = (appId) => `apps/${keySegment(appId)}/`;
12
+ export function createWorkspace(options = {}) {
13
+ let found;
14
+ /** The area: the one given, or the page's, once it is there. */
15
+ const area = () => {
16
+ if (options.container !== undefined)
17
+ return options.container ?? undefined;
18
+ if (!found?.isConnected)
19
+ found = (typeof document === 'undefined' ? null : document.querySelector('[data-braid-workspace]')) ?? undefined;
20
+ return found;
21
+ };
22
+ const store = options.store ?? localStorageStore();
23
+ /** What a slot mounted by a restore gives its fragment when it boots. */
24
+ const restores = new WeakMap();
25
+ /** Booted fragments, by the slot they are in. */
26
+ const bySlot = new WeakMap();
27
+ /**
28
+ * The page's preferences, read once and kept: every fragment's boot waits on them, so a slow
29
+ * store must not slow every boot. A read that fails or takes too long is no preferences — not a
30
+ * fragment that fails to boot — and is tried again next time.
31
+ */
32
+ let host;
33
+ function hostPreferences() {
34
+ host ??= withTimeout(store.get(HOST_KEY), HOST_READ_TIMEOUT_MS).then((stored) => (stored && typeof stored === 'object' ? stored : {}), () => {
35
+ host = undefined;
36
+ return {};
37
+ });
38
+ return host;
39
+ }
40
+ /**
41
+ * The remembered app's candidate: its one running instance, or the app itself when it is not
42
+ * running. Anything else — two instances, or the app no longer a candidate — is the user's call.
43
+ */
44
+ async function remembered(candidates, contextType) {
45
+ const matches = [];
46
+ for (const intent of new Set(candidates.map((candidate) => candidate.intent))) {
47
+ let appId;
48
+ try {
49
+ appId = (await store.get(intentKey(intent, contextType)))?.appId;
50
+ }
51
+ catch {
52
+ continue;
53
+ }
54
+ const ofApp = candidates.filter((candidate) => candidate.intent === intent && candidate.appId === appId);
55
+ const running = ofApp.filter((candidate) => candidate.instanceId);
56
+ const pick = running.length === 1 ? running[0] : running.length === 0 && ofApp.length === 1 ? ofApp[0] : undefined;
57
+ if (pick)
58
+ matches.push(pick);
59
+ }
60
+ return matches.length === 1 ? matches[0] : undefined;
61
+ }
62
+ function braidDesktopFor(appId, instance, restoredState) {
63
+ const prefix = appPrefix(appId);
64
+ return Object.freeze({
65
+ settings: Object.freeze({
66
+ get: (key) => store.get(prefix + keySegment(String(key))),
67
+ set: (key, value) => store.set(prefix + keySegment(String(key)), value),
68
+ delete: (key) => store.delete(prefix + keySegment(String(key))),
69
+ keys: async () => decodedSegments(await store.list(prefix), prefix),
70
+ }),
71
+ onSaveLayout(handler) {
72
+ if (typeof handler !== 'function')
73
+ throw new TypeError('braidDesktop.onSaveLayout takes a function');
74
+ instance.saveHandlers.add(handler);
75
+ return () => void instance.saveHandlers.delete(handler);
76
+ },
77
+ restoredState: restoredState === undefined ? undefined : structuredClone(restoredState),
78
+ });
79
+ }
80
+ function needContainer() {
81
+ const container = area();
82
+ if (!container) {
83
+ throw new Error('braid-workspace: the page has no workspace area (createWorkspace({ container }), or a [data-braid-workspace] element)');
84
+ }
85
+ return container;
86
+ }
87
+ /** The state an instance gives for a layout: JSON, within the timeout, or nothing. */
88
+ async function stateOf(instance) {
89
+ const handler = instance && [...instance.saveHandlers].at(-1);
90
+ if (!handler)
91
+ return undefined;
92
+ try {
93
+ const state = await withTimeout(Promise.resolve().then(handler), SAVE_STATE_TIMEOUT_MS);
94
+ return state === undefined ? undefined : JSON.parse(JSON.stringify(state));
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
100
+ async function savedSlot(slot) {
101
+ const saved = { appId: slot.getAttribute('name') };
102
+ const instance = bySlot.get(slot);
103
+ const src = slot.getAttribute('src');
104
+ if (src !== null)
105
+ saved.src = src;
106
+ const props = slot.props;
107
+ if (props && typeof props === 'object' && Object.keys(props).length > 0)
108
+ saved.props = JSON.parse(JSON.stringify(props));
109
+ const fdc3 = instance?.fdc3;
110
+ const [channel, state] = await Promise.all([
111
+ fdc3 ? withTimeout(fdc3.getCurrentChannel(), SAVE_STATE_TIMEOUT_MS).catch(() => null) : null,
112
+ stateOf(instance),
113
+ ]);
114
+ if (channel?.type === 'user' && typeof channel.id === 'string')
115
+ saved.channel = channel.id;
116
+ if (state !== undefined)
117
+ saved.state = state;
118
+ return saved;
119
+ }
120
+ /** The candidates left once the user's hidden apps are taken out. */
121
+ async function visible(candidates) {
122
+ const { hiddenApps = [] } = await hostPreferences();
123
+ const hidden = Array.isArray(hiddenApps) ? hiddenApps : [];
124
+ return candidates.filter((candidate) => !hidden.includes(candidate.appId));
125
+ }
126
+ const workspace = {
127
+ get container() {
128
+ return area();
129
+ },
130
+ fdc3(inner) {
131
+ return { container: area, resolveIntent: workspace.intentResolver(inner) };
132
+ },
133
+ realmGlobals(inner) {
134
+ return async (fragment) => {
135
+ const { fragmentId, signal, slot } = fragment;
136
+ // a hook that fails has given nothing: the fragment still gets its braidDesktop
137
+ const globals = (inner ? await Promise.resolve(inner(fragment)).catch(() => undefined) : undefined) ?? {};
138
+ const restore = slot ? restores.get(slot) : undefined;
139
+ const fdc3 = isFdc3(globals.fdc3) ? globals.fdc3 : undefined;
140
+ if (fdc3 && !signal.aborted) {
141
+ const channel = restore?.channel ?? (await hostPreferences()).defaultUserChannel;
142
+ // a channel the agent does not have, or one slow to join, is no channel
143
+ if (typeof channel === 'string')
144
+ await withTimeout(fdc3.joinUserChannel(channel), JOIN_TIMEOUT_MS).catch(() => undefined);
145
+ }
146
+ const instance = { ...(fdc3 ? { fdc3 } : {}), saveHandlers: new Set() };
147
+ if (slot && !signal.aborted) {
148
+ restores.delete(slot);
149
+ bySlot.set(slot, instance);
150
+ signal.addEventListener('abort', () => void (bySlot.get(slot) === instance && bySlot.delete(slot)), { once: true });
151
+ }
152
+ return { ...globals, braidDesktop: braidDesktopFor(fragmentId, instance, restore?.state) };
153
+ };
154
+ },
155
+ intentResolver(inner) {
156
+ return async (request) => {
157
+ const candidates = await visible(request.candidates);
158
+ if (candidates.length === 0)
159
+ return undefined;
160
+ if (candidates.length === 1)
161
+ return { candidate: candidates[0] };
162
+ const contextType = typeof request.context?.type === 'string' ? request.context.type : 'fdc3.nothing';
163
+ const known = await remembered(candidates, contextType);
164
+ if (known)
165
+ return { candidate: known };
166
+ const answer = await inner?.({ ...request, candidates });
167
+ if (!answer)
168
+ return undefined;
169
+ // only what was offered is remembered: the agent refuses anything else anyway
170
+ const offered = candidates.find((candidate) => candidate.intent === answer.candidate?.intent &&
171
+ candidate.appId === answer.candidate.appId &&
172
+ (candidate.instanceId ?? null) === (answer.candidate.instanceId ?? null));
173
+ if (offered && answer.remember)
174
+ await store.set(intentKey(offered.intent, contextType), { appId: offered.appId }).catch(() => undefined);
175
+ return answer;
176
+ };
177
+ },
178
+ open(appId, app = {}) {
179
+ if (typeof appId !== 'string' || appId === '')
180
+ throw new TypeError('braid-workspace: open takes an app id');
181
+ return mountSlot(needContainer(), { appId, ...app });
182
+ },
183
+ preferences: {
184
+ getHost: hostPreferences,
185
+ async setHost(preferences) {
186
+ const value = JSON.parse(JSON.stringify(preferences ?? {}));
187
+ await store.set(HOST_KEY, value);
188
+ host = Promise.resolve(value);
189
+ },
190
+ async intentChoice(intent, contextType) {
191
+ const stored = (await store.get(intentKey(intent, contextType)));
192
+ return typeof stored?.appId === 'string' ? stored.appId : undefined;
193
+ },
194
+ async forgetIntentChoice(intent, contextType) {
195
+ await store.delete(intentKey(intent, contextType));
196
+ },
197
+ },
198
+ layouts: {
199
+ async save(name) {
200
+ // every app at once: one that is slow to give its state does not hold up the rest
201
+ const named = slotsIn(needContainer()).filter((slot) => slot.getAttribute('name'));
202
+ const layout = { version: 1, savedAt: new Date().toISOString(), slots: await Promise.all(named.map(savedSlot)) };
203
+ await store.set(layoutKey(name), layout);
204
+ return layout;
205
+ },
206
+ async restore(name) {
207
+ const target = needContainer();
208
+ const layout = (await store.get(layoutKey(name)));
209
+ if (!layout || layout.version !== 1 || !Array.isArray(layout.slots)) {
210
+ throw new Error(`braid-workspace: no saved layout "${name}"`);
211
+ }
212
+ for (const slot of slotsIn(target))
213
+ slot.remove();
214
+ for (const saved of layout.slots) {
215
+ if (typeof saved?.appId !== 'string' || saved.appId === '')
216
+ continue;
217
+ mountSlot(target, saved, (slot) => restores.set(slot, { channel: saved.channel, state: saved.state }));
218
+ }
219
+ },
220
+ async list() {
221
+ return decodedSegments(await store.list('layouts/'), 'layouts/');
222
+ },
223
+ async delete(name) {
224
+ await store.delete(layoutKey(name));
225
+ },
226
+ },
227
+ };
228
+ return workspace;
229
+ }
230
+ function isFdc3(value) {
231
+ const agent = value;
232
+ return typeof agent?.joinUserChannel === 'function' && typeof agent.getCurrentChannel === 'function';
233
+ }
234
+ /** `promise`, or a rejection once `ms` have passed; the timer does not outlive it. */
235
+ function withTimeout(promise, ms) {
236
+ let timer;
237
+ return Promise.race([
238
+ promise,
239
+ new Promise((_resolve, reject) => {
240
+ timer = setTimeout(() => reject(new Error('timed out')), ms);
241
+ }),
242
+ ]).finally(() => clearTimeout(timer));
243
+ }
244
+ /** Keys under `prefix`, decoded back to the names they were made from; one that does not decode is skipped. */
245
+ function decodedSegments(keys, prefix) {
246
+ return keys.flatMap((key) => {
247
+ try {
248
+ return [decodeURIComponent(key.slice(prefix.length))];
249
+ }
250
+ catch {
251
+ return [];
252
+ }
253
+ });
254
+ }
255
+ /** The `<fragment-slot>` children of the workspace area, in document order. */
256
+ function slotsIn(container) {
257
+ return [...container.children].filter((child) => child.localName === 'fragment-slot');
258
+ }
259
+ /**
260
+ * Mounts a `<fragment-slot>` for an app at the end of the area. `prepare` runs before the slot
261
+ * connects — which is when it boots and its fragment asks for its globals.
262
+ */
263
+ function mountSlot(container, app, prepare) {
264
+ const slot = container.ownerDocument.createElement('fragment-slot');
265
+ slot.setAttribute('name', app.appId);
266
+ if (typeof app.src === 'string')
267
+ slot.setAttribute('src', app.src);
268
+ if (app.props && typeof app.props === 'object')
269
+ slot.setAttribute('props', JSON.stringify(app.props));
270
+ prepare?.(slot);
271
+ container.append(slot);
272
+ return slot;
273
+ }
274
+ //# sourceMappingURL=workspace.js.map