@agentshouse/app-bridge 0.1.0-alpha.4 → 0.1.0-alpha.6

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/README.md CHANGED
@@ -6,15 +6,23 @@ port's one listener. It carries four families:
6
6
 
7
7
  - **Navigation** - the part of the shell's URL below the App's path, with its
8
8
  query and fragment. The shell delivers it when the App starts and whenever the
9
- User navigates; the App asks the shell to push or replace it.
9
+ User navigates; the App asks the shell to push or replace it. The App also
10
+ sets its tab's title and icon with `setTab({ title, icon })`, either or both:
11
+ a title of at most 200 characters and an icon given as a base64 `data:` URL
12
+ of an SVG, PNG, GIF or WebP image of at most 1,048,576 characters. The shell
13
+ shows them over the App's display name, or a Site's title, and its tab icon
14
+ until the next load, and ignores a request outside those bounds.
10
15
  - **Ceremony** - the App asks the shell to leave for one of House's own
11
16
  purpose-bound authenticated pages. The shell navigates only to a same-origin
12
17
  absolute path, carries its current address as the return target, and the page
13
18
  returns there when it completes. An App names an address House gave it in a
14
19
  reading and composes none.
15
20
  - **Status** - the App reference the Runtime port is attached under, sync
16
- state, credential state, release availability and observed failure, and
17
- `ga`: whether the page measures and the visitor's stored analytics choice.
21
+ state, credential state, release availability and observed failure, `ga`:
22
+ whether the page measures and the visitor's stored analytics choice, and
23
+ `service_mode`: `open`, `write-halted` while House refuses writes, so the
24
+ App can stop retrying them, or `null` while the runtime holds no House
25
+ connection.
18
26
  The App reports the visitor's choice with `gaConsent('accept')` or
19
27
  `gaConsent('decline')`; the shell hands it to House's analytics loader, and
20
28
  the choice never reaches House.
@@ -37,6 +45,7 @@ const md = connectMdRuntime(await bridge.runtime);
37
45
 
38
46
  bridge.onAddress((address) => render(address));
39
47
  bridge.onStatus((status) => showSync(status.sync));
48
+ bridge.setTab({ title: 'Atlas (3)' });
40
49
 
41
50
  open('/rooms/atlas');
42
51
  function open(address) {
@@ -75,6 +84,8 @@ slice below the App's path, admitting nothing, so it delivers `//notes` exactly
75
84
  as a browser at `/apps/<app>//notes` does. It records every push and replace
76
85
  and answers each with the address the shell would then show, records an
77
86
  admitted ceremony, refuses a push, replace or ceremony that is not a
78
- same-origin absolute path, publishes a status, records in `consents` each
87
+ same-origin absolute path, publishes a status, its `service_mode` included,
88
+ records in `consents` each
79
89
  `gaConsent` it takes while that status measures and publishes the status the
80
- choice leaves, and attaches a Runtime port.
90
+ choice leaves, records in `tabs` each `setTab` the shell would take and ignores
91
+ any other, and attaches a Runtime port.
@@ -1,4 +1,4 @@
1
- import type { HouseBridgePort, HouseGaChoice, HouseMessagePort, HouseStatus } from './wire.js';
1
+ import type { HouseBridgePort, HouseGaChoice, HouseMessagePort, HouseStatus, HouseTab } from './wire.js';
2
2
  export declare class HouseCeremonyRefused extends Error {
3
3
  readonly path: string | null;
4
4
  constructor(path: string | null);
@@ -20,6 +20,7 @@ export interface HouseBridge {
20
20
  push(address: string): Promise<string>;
21
21
  replace(address: string): Promise<string>;
22
22
  ceremony(path: string): Promise<never>;
23
+ setTab(tab: HouseTab): void;
23
24
  readAttachment(room: string, attachment: string): Promise<HouseImageCopy | null>;
24
25
  status(): HouseStatus | null;
25
26
  onStatus(listener: (status: HouseStatus) => void): () => void;
@@ -134,6 +134,9 @@ export function connectHouseBridge(source = heldBridge()) {
134
134
  send({ house: 'ceremony', path });
135
135
  return pending.promise;
136
136
  },
137
+ setTab(tab) {
138
+ send({ house: 'tab', title: tab.title, icon: tab.icon });
139
+ },
137
140
  readAttachment(room, attachment) {
138
141
  const pending = settled();
139
142
  const id = ++nextAttachment;
@@ -1,5 +1,5 @@
1
1
  import type { HouseImageCopy } from './bridge.js';
2
- import type { HouseBridgePort, HouseGaChoice, HouseMessagePort, HouseNavigationMode, HouseStatus } from './wire.js';
2
+ import type { HouseBridgePort, HouseGaChoice, HouseMessagePort, HouseNavigationMode, HouseStatus, HouseTab } from './wire.js';
3
3
  export interface HouseBridgeNavigation {
4
4
  readonly mode: HouseNavigationMode;
5
5
  readonly address: string;
@@ -9,6 +9,7 @@ export interface HouseBridgeTestHost {
9
9
  readonly navigations: readonly HouseBridgeNavigation[];
10
10
  readonly ceremonies: readonly string[];
11
11
  readonly consents: readonly HouseGaChoice[];
12
+ readonly tabs: readonly HouseTab[];
12
13
  address(): string;
13
14
  open(address: string): void;
14
15
  publish(status: HouseStatus): void;
@@ -1,4 +1,5 @@
1
1
  import { appAddressOf } from '../../src/apps/remainder.js';
2
+ import { tabChange } from '../../src/apps/tab.js';
2
3
  import { HOUSE_ADDRESS_MAX_CHARS } from './wire.js';
3
4
  const HOST_ORIGIN = 'https://house.invalid';
4
5
  function sameOriginPath(value) {
@@ -15,6 +16,7 @@ export function createHouseBridgeTestHost(options = {}) {
15
16
  const navigations = [];
16
17
  const ceremonies = [];
17
18
  const consents = [];
19
+ const tabs = [];
18
20
  const listeners = new Set();
19
21
  const queued = [];
20
22
  let started = false;
@@ -77,6 +79,11 @@ export function createHouseBridgeTestHost(options = {}) {
77
79
  else if (data.house === 'ceremony') {
78
80
  ceremony(data.path);
79
81
  }
82
+ else if (data.house === 'tab') {
83
+ const change = tabChange(data);
84
+ if (change !== null)
85
+ tabs.push(change);
86
+ }
80
87
  else if (data.house === 'ga/consent') {
81
88
  consent(data.choice);
82
89
  }
@@ -123,6 +130,7 @@ export function createHouseBridgeTestHost(options = {}) {
123
130
  navigations,
124
131
  ceremonies,
125
132
  consents,
133
+ tabs,
126
134
  address() {
127
135
  return address;
128
136
  },
@@ -1,3 +1,4 @@
1
+ import type { TabChange } from '../../src/apps/tab.js';
1
2
  export interface HouseBridgeEvent {
2
3
  readonly data: unknown;
3
4
  readonly ports: readonly HouseMessagePort[];
@@ -32,6 +33,8 @@ export interface HouseStatus {
32
33
  readonly loss: string | null;
33
34
  readonly release: unknown;
34
35
  readonly ga: HouseGa;
36
+ readonly service_mode: 'open' | 'write-halted' | null;
35
37
  }
36
38
  export type HouseNavigationMode = 'push' | 'replace';
39
+ export type HouseTab = TabChange;
37
40
  export declare const HOUSE_ADDRESS_MAX_CHARS = 1000;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agentshouse/app-bridge",
3
- "version": "0.1.0-alpha.4",
4
- "description": "Renderer-side client of the agents.house App bridge: navigation, ceremonies, status and Site image reads, with an in-page test host.",
3
+ "version": "0.1.0-alpha.6",
4
+ "description": "Renderer-side client of the agents.house App bridge: navigation, the tab title and icon, ceremonies, status and Site image reads, with an in-page test host.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -23,6 +23,8 @@
23
23
  "client/src/*.d.ts",
24
24
  "src/apps/remainder.js",
25
25
  "src/apps/remainder.d.ts",
26
+ "src/apps/tab.js",
27
+ "src/apps/tab.d.ts",
26
28
  "README.md"
27
29
  ]
28
30
  }
@@ -0,0 +1,6 @@
1
+ export declare const TAB_IMAGE: RegExp;
2
+ export interface TabChange {
3
+ readonly title?: string;
4
+ readonly icon?: string;
5
+ }
6
+ export declare function tabChange(asked: unknown): TabChange | null;
@@ -0,0 +1,27 @@
1
+ const TAB_TITLE_MAX_CHARS = 200;
2
+ const TAB_ICON_MAX_CHARS = 1_048_576;
3
+ function caseless(text) {
4
+ return text.replace(/[a-z]/gu, (letter) => `[${letter}${letter.toUpperCase()}]`);
5
+ }
6
+ export const TAB_IMAGE = new RegExp(`^${caseless('data:image/')}(?:svg\\+xml|${['png', 'gif', 'webp'].map(caseless).join('|')});${caseless('base64')},[A-Za-z0-9+/]+={0,2}$`, 'u');
7
+ function admittedTitle(title) {
8
+ return typeof title === 'string' && title.length <= TAB_TITLE_MAX_CHARS;
9
+ }
10
+ function admittedIcon(icon) {
11
+ return typeof icon === 'string' && icon.length <= TAB_ICON_MAX_CHARS && TAB_IMAGE.test(icon);
12
+ }
13
+ export function tabChange(asked) {
14
+ if (asked === null || typeof asked !== 'object')
15
+ return null;
16
+ const { title, icon } = asked;
17
+ if (title === undefined && icon === undefined)
18
+ return null;
19
+ if (title !== undefined && !admittedTitle(title))
20
+ return null;
21
+ if (icon !== undefined && !admittedIcon(icon))
22
+ return null;
23
+ return {
24
+ ...(title === undefined ? {} : { title: title }),
25
+ ...(icon === undefined ? {} : { icon: icon }),
26
+ };
27
+ }