@condorinst/mobile-bridge 0.18.0 → 0.19.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/AGENTS.md ADDED
@@ -0,0 +1,120 @@
1
+ # AGENTS.md — building a Condor Canvas client
2
+
3
+ You are writing the **Home screen** of a Condor Canvas app. It is a single web
4
+ bundle that renders inside a sandboxed iframe in a native mobile app. The app
5
+ owns login, settings and the Bluetooth connection to the devices; you own what
6
+ the person sees on the Home.
7
+
8
+ This file is the contract. Read it before writing code. Everything here is
9
+ enforced — either by a gate in the app, or by the browser runtime on a real
10
+ device, where mistakes are expensive to diagnose.
11
+
12
+ ## The one thing that makes this different
13
+
14
+ **Failures are silent.** If you send a malformed command, an oversized payload,
15
+ or a command this device's firmware does not know, nothing happens. No
16
+ exception, no rejected promise, no console error. The native side logs it and
17
+ moves on.
18
+
19
+ So: never write code whose correctness you infer from the absence of an error.
20
+ Drive your UI from the events that arrive, and put a timeout on anything you
21
+ wait for.
22
+
23
+ ## Hard rules
24
+
25
+ 1. **No storage.** `localStorage`, `sessionStorage`, `indexedDB` and cookies all
26
+ fail — the iframe has a `null` origin. Keep state in memory and accept that
27
+ it is lost on reload. Re-request what you need at startup.
28
+ 2. **No network.** `connect-src 'self'` on a null origin blocks every `fetch`,
29
+ `XMLHttpRequest` and `WebSocket`. All data comes through the bridge.
30
+ 3. **No `eval`, no `new Function`.** The CSP has no `'unsafe-eval'`. Configure
31
+ your bundler so it does not emit eval-based sourcemaps.
32
+ 4. **One self-contained `index.html`.** Every script inline, every stylesheet
33
+ inline, every image and font as a `data:` URI. A `<script src>` or a
34
+ `<link rel="stylesheet">` pointing anywhere else makes the app **reject the
35
+ whole bundle**.
36
+ 5. **Build single-chunk.** No code splitting, no ES module imports between
37
+ chunks — `srcdoc` cannot resolve them. Vite: `vite-plugin-singlefile`.
38
+ 6. **The `.zip` must be at most 2 MB.** Remember that `data:` URIs inflate
39
+ assets by about a third.
40
+ 7. **Use `BridgeClient`, never raw `postMessage`.** Hand-rolled messages fail
41
+ schema validation and are dropped without a trace.
42
+ 8. **Never assume a capability.** Read `nativeVersion` from the handshake and
43
+ branch on it. See the capability matrix in `docs/bridge-contract.md`.
44
+
45
+ ## What you cannot reach
46
+
47
+ `window.parent` properties · `window.top` · `window.open` ·
48
+ `navigator.sendBeacon` (neutralized before your code runs) · service workers ·
49
+ camera, microphone and geolocation (denied unconditionally) · the `dfu` key and
50
+ command (denied by policy).
51
+
52
+ ## The API
53
+
54
+ ```bash
55
+ npm install @condorinst/mobile-bridge
56
+ ```
57
+
58
+ ```ts
59
+ import { BridgeClient, BRIDGE_KEYS, type UserDevicePayload } from '@condorinst/mobile-bridge';
60
+
61
+ // 1. Handshake first. It is how you learn what this device can do.
62
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
63
+ // ack.nativeVersion is the ONLY capability signal. ack.accepted is always
64
+ // true and carries no information — never branch on it.
65
+
66
+ // 2. Subscribe before you ask for anything.
67
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (payload: UserDevicePayload) => render(payload));
68
+ BridgeClient.on(BRIDGE_KEYS.systemStatus, (s) => renderEnvironment(s));
69
+
70
+ // 3. Commands are fire-and-forget. No return value, no throw.
71
+ BridgeClient.send('userDevice', { getFromServer: false });
72
+
73
+ // 4. The one request/response helper: it correlates the id and times out.
74
+ const page = await BridgeClient.fetchSensorLog({ requestId: crypto.randomUUID(), serialNo });
75
+ // Resolves with { success: false, error } on failure — it only REJECTS on timeout.
76
+ ```
77
+
78
+ Three commands open a confirmation modal owned by Condor: `syncTime`,
79
+ `deleteMemory` and `logout`. If the person declines, you get nothing — the ack
80
+ simply never arrives. Do not block your UI on them.
81
+
82
+ `systemStatus` and `language` are emitted on attach, again right after the
83
+ handshake, and on every change. Render from the latest one you received.
84
+
85
+ ## The validation loop — run this before you call the work done
86
+
87
+ ```bash
88
+ npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip
89
+ npx condor-mobile-bridge validate -i dist/bundle.zip --strict
90
+ ```
91
+
92
+ `validate` applies the same gates the app applies on download, plus a lint for
93
+ the APIs the sandbox blocks at runtime. Under `--strict` it exits non-zero on
94
+ warnings too. **Treat a non-zero exit as a build failure.**
95
+
96
+ It is a textual check, so it cannot see through minification: a clean run is
97
+ necessary, not sufficient. It will not catch a capability you assumed without
98
+ checking `nativeVersion`.
99
+
100
+ ## Definition of done
101
+
102
+ - [ ] `validate --strict` exits 0.
103
+ - [ ] The UI renders correctly before any event arrives — first paint must not
104
+ depend on the bridge.
105
+ - [ ] Every capability beyond the baseline is guarded by a `nativeVersion` check.
106
+ - [ ] Nothing waits forever on an ack that may never come.
107
+ - [ ] Tested in the browser with the mock (`npm run dev` in the example), where
108
+ `nativeVersion` is `null` — your code must survive that.
109
+
110
+ ## Where to read more
111
+
112
+ | Question | File |
113
+ | --- | --- |
114
+ | Every key, command, error shape and capability | `docs/bridge-contract.md` |
115
+ | The same, as JSON you can parse | `contract/bridge-contract.json` |
116
+ | Step-by-step build of a Home | `docs/agent-guide.md` |
117
+ | Every hard limit, with the code that enforces it | `docs/limits.md` |
118
+ | "My command did nothing" | `docs/troubleshooting.md` |
119
+ | Payload shapes, authoritative | `dist/index.d.ts` (your editor shows them) |
120
+ | A working client you can copy | `examples/minimal-home/` |
package/CHANGELOG.md CHANGED
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.1] — 2026-09-22
11
+
12
+ ### Changed
13
+
14
+ - **`limits.maxDevicesPerUser` now states the commercial rule without dressing
15
+ it up as a platform guarantee.** It was `null` with a note saying no cap
16
+ exists, which was accurate about the app and unhelpful about what a client
17
+ will actually see. It is now an object: `enforcedByApp: false`,
18
+ `currentProductLimit: 2`, and guidance saying explicitly not to hard-code
19
+ two.
20
+
21
+ The distinction is the whole point. The Canvas app enforces no cap — no code
22
+ path rejects a third device. A commercial rule currently limits a user to two
23
+ linked devices, and it can be raised with no change to this contract, to the
24
+ app, or to any version a client could check. A two-slot UI with the slots
25
+ hard-coded looks correct for as long as the rule holds and breaks silently on
26
+ the day it does not.
27
+
28
+ `docs/limits.md` and the `UserDevicePayload` TSDoc carry the same framing.
29
+
30
+
31
+ ## [0.19.0] — 2026-09-22
32
+
33
+ Documentation release: the package is now consumable by an AI agent building a
34
+ client bundle, and the bundle can be verified against the contract before it is
35
+ delivered.
36
+
37
+ ### Added
38
+
39
+ - **`condor-mobile-bridge validate`.** New subcommand that checks a bundle
40
+ against every gate the Canvas app applies on download — a single `index.html`
41
+ at the archive root, self-contained (no external `<script src>` or stylesheet
42
+ `<link>`), within the 2 MB limit — and lints for the APIs the sandbox and the
43
+ CSP block at runtime: Web Storage, IndexedDB, cookies, `eval`/`new Function`,
44
+ service workers, `sendBeacon`, `window.open`, `window.top` and outbound
45
+ network calls. Errors are app-level rejections; warnings install fine and fail
46
+ on the device. `--strict` makes warnings fail too. Accepts a `.zip` or a
47
+ pre-build `.html`.
48
+ - **`AGENTS.md` and `llms.txt`** at the package root — the entry point most AI
49
+ coding tools look for, and an index of every document with absolute, pinned,
50
+ unauthenticated URLs.
51
+ - **`contract/bridge-contract.json`** — the bridge contract as data: every key
52
+ with its client reachability, every command with its confirmation
53
+ requirement, the hard limits, the three error shapes, the six silent-drop
54
+ paths, and the capability matrix by native version. Importable as
55
+ `@condorinst/mobile-bridge/contract`. Carries a `verifiedAgainst` block
56
+ citing the file and line of every constant mirrored from the app, and a test
57
+ locks its key set against `BRIDGE_KEYS`.
58
+ - **`docs/agent-guide.md`**, **`docs/bridge-contract.md`**,
59
+ **`docs/limits.md`** and **`docs/troubleshooting.md`**.
60
+ - `NavigationBackPayload` and `NavigationStatePayload` are now exported. They
61
+ were part of the observable contract but had no importable name.
62
+
63
+ ### Changed
64
+
65
+ - **`docs/`, `contract/`, `AGENTS.md`, `llms.txt` and `examples/minimal-home`
66
+ now ship in the npm tarball.** Until 0.18.0 only the README reached a
67
+ consumer, so the Client Guide was unreachable from `node_modules` — and from
68
+ any tool reading the installed package. They are also served as plain text by
69
+ jsDelivr and unpkg, pinned per version, which the public repository cannot
70
+ offer: its landing page is a JavaScript application that returns no readable
71
+ text to a fetcher.
72
+ - `ClientToShellMessage` and `ShellToClientMessage` are now **type-only**
73
+ exports. The Valibot schemas behind them were renamed to
74
+ `ClientToShellMessageSchema` and `ShellToClientMessageSchema`. Previously each
75
+ name was both a value and a type, the `.d.ts` rollup collapsed the two, and a
76
+ consumer importing the name as a value type-checked and then got `undefined`
77
+ at runtime. The schemas remain internal.
78
+ - README: the client-facing `BridgeClient` quickstart now comes before the
79
+ legacy shell-side `Bridge`; the pin recommendation moved from `^0.8.0` to
80
+ `^0.19.0`; a broken relative link to a file absent from the tarball was
81
+ fixed.
82
+ - README: corrected a stale claim that the client handshake resolves without
83
+ `nativeVersion`. It has carried `nativeVersion` since 0.17.0, and that value
84
+ is the only capability signal a client gets.
85
+ - Examples pinned to `^0.19.0` (they were on `^0.8.0` and `^0.17.0`).
86
+
87
+
10
88
  ## [0.18.0] — 2026-09-16
11
89
 
12
90
  ### Added
package/README.md CHANGED
@@ -9,9 +9,49 @@ mobile app.
9
9
  > background, commands to the foreground service, and hooks into native
10
10
  > screens (Settings).
11
11
 
12
- > **New here?** Start with the [Client Guide](https://bitbucket.org/condorinstprogdev/mobile-bridge/src/main/docs/client-guide.md) —
13
- > _"Build your White Label Home"_ — and the runnable
14
- > [`examples/minimal-home`](https://bitbucket.org/condorinstprogdev/mobile-bridge/src/main/examples/minimal-home).
12
+ ## Reading this with an AI agent?
13
+
14
+ Start at **[`AGENTS.md`](AGENTS.md)** — the contract in one screen — then
15
+ `docs/agent-guide.md`. Both ship inside the npm tarball, so after
16
+ `npm install` they are on disk at
17
+ `node_modules/@condorinst/mobile-bridge/`, readable with no network access.
18
+
19
+ Over the network, every document is plain text at a pinned, unauthenticated
20
+ URL. `llms.txt` is the index:
21
+
22
+ ```
23
+ https://cdn.jsdelivr.net/npm/@condorinst/mobile-bridge@0.19.0/llms.txt
24
+ ```
25
+
26
+ The machine-readable contract — keys, commands, limits, capability matrix — is
27
+ `contract/bridge-contract.json`, importable as
28
+ `@condorinst/mobile-bridge/contract`.
29
+
30
+ > Note for fetchers: the Bitbucket repository landing page is a JavaScript
31
+ > application and returns no readable text. Use the jsDelivr URLs above, or
32
+ > Bitbucket's `raw/` file URLs.
33
+
34
+ ## New here, as a human?
35
+
36
+ You are almost certainly building the **client bundle** that renders as the
37
+ app's Home. That means `BridgeClient`:
38
+
39
+ ```typescript
40
+ import { BridgeClient, BRIDGE_KEYS } from '@condorinst/mobile-bridge';
41
+
42
+ const ack = await BridgeClient.handshake({ clientVersion: '1.0.0' });
43
+ // ack.nativeVersion is your capability signal. ack.accepted is always true.
44
+
45
+ BridgeClient.on(BRIDGE_KEYS.userDevice, (devices) => render(devices));
46
+ BridgeClient.send('userDevice', { getFromServer: false });
47
+ ```
48
+
49
+ Then read the [Client Guide](docs/client-guide.md) — _"Build your White Label
50
+ Home"_ — and copy the runnable [`examples/minimal-home`](examples/minimal-home).
51
+
52
+ The `Bridge` singleton shown under *Quickstart* below is the **legacy,
53
+ shell-side** API. It talks to the native layer directly and is not reachable
54
+ from a sandboxed client bundle. If you are the client, you want `BridgeClient`.
15
55
 
16
56
  ## Installation
17
57
 
@@ -23,8 +63,8 @@ The published package ships a prebuilt `dist/` (ESM + CJS + type
23
63
  declarations) — **no build toolchain is required on your side**.
24
64
 
25
65
  > **Versioning during the pilot.** The package is on `0.x` (beta). Pin
26
- > `"@condorinst/mobile-bridge": "^0.8.0"`. Under semver, while on `0.x` a
27
- > caret range auto-updates **patches only** (`0.8.x`); moving to `0.9.0` is
66
+ > `"@condorinst/mobile-bridge": "^0.19.0"`. Under semver, while on `0.x` a
67
+ > caret range auto-updates **patches only** (`0.19.x`); moving to `0.20.0` is
28
68
  > an intentional opt-in. `1.0.0` is reserved for production.
29
69
 
30
70
  ## Quickstart
@@ -87,12 +127,15 @@ BridgeClient.onBack(() => goToPreviousPage()); // fired only when you r
87
127
  The `reportNavigation` / `onBack` pair lets your page own the Android back
88
128
  button — walking your in-page navigation before the app leaves the Home. Opt-in;
89
129
  skip it and the app just stays on Home when back is pressed. See
90
- [docs/client-guide.md](docs/client-guide.md#handle-the-system-back-button).
130
+ [docs/bridge-contract.md](docs/bridge-contract.md).
91
131
 
92
132
  **Differences vs `Bridge`:**
93
133
  - Does not expose `mockEmit`, `installDevPanel`, `isMock`, `getMockState` —
94
134
  dev/mock APIs live only on the shell.
95
- - `handshake` resolves with `{ shellVersion, accepted }` (not `nativeVersion`).
135
+ - `handshake` resolves with `{ shellVersion, nativeVersion, accepted }`. Since
136
+ 0.17.0 the shell relays `nativeVersion` into the iframe — it is the only
137
+ capability signal you get, and it is `null` in mock mode and against shells
138
+ older than 0.6.0. `accepted` is always `true`; never branch on it.
96
139
  - Commands not allowed by the `ShellPolicy` are silently dropped (with a
97
140
  `console.warn`). The external client has no visibility into which commands
98
141
  do or do not exist.
@@ -403,10 +446,22 @@ npm run build # tsup (ESM + CJS + types in dist/)
403
446
 
404
447
  ## Further reading
405
448
 
406
- - [Pilot Onboarding](https://bitbucket.org/condorinstprogdev/mobile-bridge/src/main/docs/pilot-onboarding.md) — quick checklist: zero to a published Home
407
- - [Client Guide — Build your White Label Home](https://bitbucket.org/condorinstprogdev/mobile-bridge/src/main/docs/client-guide.md)
408
- - [`examples/minimal-home`](https://bitbucket.org/condorinstprogdev/mobile-bridge/src/main/examples/minimal-home) — runnable minimal client
409
- - [CHANGELOG](./CHANGELOG.md)
449
+ Every file below ships in the npm tarball, so these relative links resolve
450
+ inside `node_modules/@condorinst/mobile-bridge/` as well as in the repository.
451
+
452
+ | Document | What it answers |
453
+ | --- | --- |
454
+ | [`AGENTS.md`](./AGENTS.md) | The contract in one screen. Start here if you are, or are using, an AI agent. |
455
+ | [`llms.txt`](./llms.txt) | Index of every document, with absolute pinned URLs. |
456
+ | [Agent guide](./docs/agent-guide.md) | Step-by-step construction of a client Home. |
457
+ | [Bridge contract](./docs/bridge-contract.md) | Every key, command, error shape and capability, by native version. |
458
+ | [`contract/bridge-contract.json`](./contract/bridge-contract.json) | The same, as JSON. |
459
+ | [Limits](./docs/limits.md) | Every hard limit, and what happens when you cross it. |
460
+ | [Troubleshooting](./docs/troubleshooting.md) | Why a command did nothing. |
461
+ | [Client guide](./docs/client-guide.md) | Narrative walkthrough — build your White Label Home. |
462
+ | [Pilot onboarding](./docs/pilot-onboarding.md) | Checklist: zero to a delivered bundle. |
463
+ | [`examples/minimal-home`](./examples/minimal-home) | A runnable client in ~160 lines. |
464
+ | [CHANGELOG](./CHANGELOG.md) | When each capability landed — the precise record for version checks. |
410
465
 
411
466
  ## License
412
467
 
@@ -0,0 +1,202 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "contractVersion": "0.19.1",
4
+ "description": "Machine-readable contract of the Condor Canvas JS<->Native bridge. It states what the TypeScript declarations in dist/index.d.ts cannot express: which keys and commands a sandboxed client can actually reach, which ones need human confirmation, where messages are dropped in silence, the hard limits, and which capability each native version carries. The TypeScript types remain canonical for payload shapes.",
5
+ "audience": "Developers and AI agents building the client bundle that renders inside the Canvas Home.",
6
+
7
+ "versions": {
8
+ "note": "Four independent numbers coexist. Only nativeVersion tells you what the device can do.",
9
+ "package": "0.19.1",
10
+ "shellVersion": "0.6.0",
11
+ "clientProtocolVersion": "0.5.0",
12
+ "nativeVersion": "0.16.0",
13
+ "capabilityDetection": {
14
+ "how": "await BridgeClient.handshake({ clientVersion }) resolves to { shellVersion, nativeVersion, accepted }. Compare ack.nativeVersion against the minNativeVersion in capabilities[].",
15
+ "acceptedIsAlwaysTrue": true,
16
+ "acceptedWarning": "The native side never rejects a handshake, so `accepted` carries no information. Never branch on it.",
17
+ "nativeVersionMayBeNull": "null in a plain browser (mock mode) and in bundles compiled against SDK <= 0.16.x, where the shell did not relay it. Treat null as 'assume nothing beyond the 0.13.0 baseline'."
18
+ }
19
+ },
20
+
21
+ "limits": {
22
+ "jsToNativePayloadBytes": {
23
+ "value": 65536,
24
+ "onExceed": "silent-drop",
25
+ "note": "Measured on the JSON payload. The message is discarded with no feedback to JS."
26
+ },
27
+ "jsErrorPerWindow": { "value": 20, "windowSeconds": 10, "onExceed": "silent-drop" },
28
+ "jsErrorFieldBytes": { "value": 4096, "onExceed": "truncate", "appliesTo": ["message", "stack"] },
29
+ "bundleZipBytes": { "value": 2097152, "onExceed": "app-rejects-bundle" },
30
+ "sensorLogLimit": { "min": 1, "max": 1000, "backendDefault": 10, "onExceed": "error-code" },
31
+ "timeoutsMs": {
32
+ "handshake": 5000,
33
+ "fetchSensorLog": 10000,
34
+ "verifySandbox": 2000,
35
+ "nativeVersionRelay": 2000
36
+ },
37
+ "presignedBundleUrlTtlSeconds": 86400,
38
+ "maxDevicesPerUser": {
39
+ "enforcedByApp": false,
40
+ "currentProductLimit": 2,
41
+ "stability": "commercial rule, may change without a contract version bump",
42
+ "guidance": "Today a user can link at most two devices, so design for a short list rather than a virtualised one. Do NOT hard-code two: the cap is a commercial rule, not a platform invariant, and raising it requires no change to this contract or to the app. Render whatever userDevice delivers and read its length."
43
+ }
44
+ },
45
+
46
+ "storage": {
47
+ "available": false,
48
+ "reason": "The shell is loaded with loadHtmlString and no base URL, so its origin is opaque; the client runs in <iframe sandbox=\"allow-scripts\"> from srcdoc, so its origin is null.",
49
+ "localStorage": "throws SecurityError",
50
+ "sessionStorage": "throws SecurityError",
51
+ "indexedDB": "unavailable",
52
+ "cookies": "writes are no-ops",
53
+ "serviceWorker": "cannot be registered",
54
+ "guidance": "Hold all state in memory. It is lost whenever the WebView reloads. Re-request what you need through the bridge on startup."
55
+ },
56
+
57
+ "csp": {
58
+ "policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none';",
59
+ "inheritedBySrcdoc": true,
60
+ "cannotBeRelaxedByClient": true,
61
+ "consequences": [
62
+ "No 'unsafe-eval': eval() and new Function() are blocked.",
63
+ "connect-src 'self' on a null origin blocks every fetch, XHR and WebSocket. There is no network from the client.",
64
+ "Images and fonts must be inline or data: URIs."
65
+ ]
66
+ },
67
+
68
+ "sandbox": {
69
+ "iframeAttribute": "sandbox=\"allow-scripts\"",
70
+ "unavailable": [
71
+ "window.parent property access (postMessage is the only permitted use, and BridgeClient does it for you)",
72
+ "window.top (no allow-top-navigation)",
73
+ "window.open (no allow-popups)",
74
+ "navigator.sendBeacon (neutralized by the shell before the client runs)",
75
+ "window.CondorNative, window.CondorBridge, window.flutter_inappwebview (deleted before the client runs)",
76
+ "every native WebView permission: camera, microphone and geolocation are denied unconditionally"
77
+ ]
78
+ },
79
+
80
+ "keys": {
81
+ "note": "13 keys exist; 12 are reachable by the sandboxed client. Subscribe with BridgeClient.on(key, cb). Payload shapes are canonical in dist/index.d.ts under the listed type.",
82
+ "entries": {
83
+ "pingPong": { "reachableByClient": true, "payloadType": "PingPongPayload", "emittedWhen": "reply to the `ping` command" },
84
+ "userDevice": { "reachableByClient": true, "payloadType": "UserDevicePayload", "emittedWhen": "reply to the `userDevice` command, and on device roster changes" },
85
+ "deviceLightSensors": { "reachableByClient": true, "payloadType": "DeviceLightSensorsPayload", "emittedWhen": "real-time stream, only while realTime is on for that device", "discriminator": "type", "variants": ["actlumus", "actlumus2"] },
86
+ "syncTime": { "reachableByClient": true, "payloadType": "SyncTimeAckPayload", "emittedWhen": "ack of the `syncTime` command" },
87
+ "realTime": { "reachableByClient": true, "payloadType": "RealTimeAckPayload", "emittedWhen": "ack of the `realTime` command" },
88
+ "deleteMemory": { "reachableByClient": true, "payloadType": "DeleteMemoryAckPayload", "emittedWhen": "ack of the `deleteMemory` command" },
89
+ "deviceNotification": { "reachableByClient": true, "payloadType": "DeviceNotificationPayload", "emittedWhen": "device-level notice, including errors" },
90
+ "deviceConnection": { "reachableByClient": true, "payloadType": "DeviceConnectionPayload", "emittedWhen": "a device connects or disconnects" },
91
+ "sensorLog": { "reachableByClient": true, "payloadType": "SensorLogPayload", "emittedWhen": "reply to the `sensorLog` command; correlate by requestId" },
92
+ "systemStatus": { "reachableByClient": true, "payloadType": "SystemStatusPayload", "emittedWhen": "on attach, again right after handshake-ack, and on every change" },
93
+ "navigationBack": { "reachableByClient": true, "payloadType": "NavigationBackPayload", "emittedWhen": "the OS back button was pressed and you had reported canGoBack: true" },
94
+ "language": { "reachableByClient": true, "payloadType": "LanguagePayload", "emittedWhen": "on attach, again right after handshake-ack, and on every change in Settings", "minNativeVersion": "0.14.0" },
95
+ "dfu": { "reachableByClient": false, "payloadType": null, "deniedBy": "ShellPolicy.deniedCommands and skipped natively", "note": "Firmware update traffic. A subscribe attempt is answered with nack reason not-read-only." }
96
+ }
97
+ },
98
+
99
+ "commands": {
100
+ "note": "Send with BridgeClient.send(command, args). It is fire-and-forget: there is no return value and no throw. Replies arrive on the key named in repliesOn.",
101
+ "entries": {
102
+ "ping": { "args": {}, "requiresConfirmation": false, "repliesOn": "pingPong" },
103
+ "userDevice": { "args": { "getFromServer": "boolean, default false" }, "requiresConfirmation": false, "repliesOn": "userDevice" },
104
+ "realTime": { "args": { "serialNo": "int", "value": "boolean" }, "requiresConfirmation": false, "repliesOn": "realTime", "note": "Off by default. The deviceLightSensors stream only flows while this is on." },
105
+ "deviceConnection": { "args": { "type": "'add' | 'remove'", "serialNo": "int" }, "requiresConfirmation": false, "repliesOn": "deviceConnection" },
106
+ "sensorLog": { "args": { "requestId": "string", "serialNo": "int", "bodyPart": "'wrist' | 'necklace', optional", "limit": "1..1000, optional", "startingAfter": "opaque cursor, optional", "endingBefore": "opaque cursor, optional", "startTime": "timestamp, optional, minNativeVersion 0.15.0", "endTime": "timestamp, optional, minNativeVersion 0.15.0" }, "requiresConfirmation": false, "repliesOn": "sensorLog", "prefer": "BridgeClient.fetchSensorLog(query), which correlates requestId and applies a timeout for you." },
107
+ "openSettings": { "args": {}, "requiresConfirmation": false, "repliesOn": null },
108
+ "requestPermission": { "args": { "permission": "string, must match a native AppPermission name" }, "requiresConfirmation": false, "repliesOn": null, "note": "There is no permission-result key. The OS shows its own prompt and the outcome reaches you as a fresh systemStatus emission." },
109
+ "setStatusBarColor": { "args": { "color": "'#RRGGBB'" }, "requiresConfirmation": false, "repliesOn": null },
110
+ "navigationState": { "args": { "canGoBack": "boolean" }, "requiresConfirmation": false, "repliesOn": null, "prefer": "BridgeClient.reportNavigation(canGoBack)" },
111
+ "language": { "args": {}, "requiresConfirmation": false, "repliesOn": "language", "note": "Pull. The native push happens before window.CondorBridge exists, so a client that mounts later must ask.", "minNativeVersion": "0.14.0" },
112
+ "jsError": { "args": { "message": "string", "stack": "optional", "source": "optional", "lineno": "optional", "colno": "optional" }, "requiresConfirmation": false, "repliesOn": null, "note": "Rate limited: 20 per 10 s, fields truncated at 4 KB." },
113
+ "syncTime": { "args": { "serialNo": "int" }, "requiresConfirmation": true, "repliesOn": "syncTime" },
114
+ "deleteMemory": { "args": { "serialNo": "int" }, "requiresConfirmation": true, "repliesOn": "deleteMemory" },
115
+ "logout": { "args": {}, "requiresConfirmation": true, "repliesOn": null },
116
+ "dfu": { "args": {}, "denied": true, "repliesOn": null }
117
+ },
118
+ "confirmationSemantics": "A command with requiresConfirmation true opens a modal owned by the Condor shell. If the person declines, the shell answers nack with reason user-rejected, which your code only sees as a console warning. The ack simply never arrives. Never block your UI waiting for it: drive the screen from the ack key when it comes, and keep a timeout."
119
+ },
120
+
121
+ "errorShapes": [
122
+ {
123
+ "name": "nack",
124
+ "channel": "shell -> client postMessage",
125
+ "shape": { "type": "nack", "key": "string, optional", "command": "string, optional", "reason": "string" },
126
+ "reasons": ["not-read-only", "denied", "no-confirmation-handler", "confirmation-error", "user-rejected", "not-allowed"],
127
+ "visibility": "console.warn only. BridgeClient.send does not throw and does not return a status."
128
+ },
129
+ {
130
+ "name": "sensorLog failure",
131
+ "channel": "sensorLog key",
132
+ "shape": { "requestId": "string", "success": false, "error": "string code" },
133
+ "codes": ["CONFLICTING_CURSORS", "INVALID_BODY_PART", "INVALID_LIMIT", "INVALID_START_TIME", "INVALID_END_TIME", "INVALID_TIME_RANGE", "MISSING_DEVICE_SERIAL_NO", "invalid session", "device not found for this user"],
134
+ "visibility": "fetchSensorLog RESOLVES with success false. It only rejects on timeout.",
135
+ "warning": "error is a diagnostic code, never user-facing text. Map it to your own copy."
136
+ },
137
+ {
138
+ "name": "silent drop",
139
+ "channel": "none",
140
+ "shape": null,
141
+ "visibility": "Nothing reaches JS. This is the dominant failure mode of the platform."
142
+ }
143
+ ],
144
+
145
+ "silentDropPaths": [
146
+ { "cause": "shell nonce missing or wrong", "trigger": "a message reached the native channel from anywhere but the Condor shell, e.g. a nested iframe you created", "observable": "nothing" },
147
+ { "cause": "payload over 65536 bytes", "trigger": "oversized command args", "observable": "nothing" },
148
+ { "cause": "unknown command type", "trigger": "typo, or a command added in a newer nativeVersion than the device runs", "observable": "nothing" },
149
+ { "cause": "permission name outside the native enum", "trigger": "requestPermission with an unrecognized string", "observable": "nothing" },
150
+ { "cause": "malformed args", "trigger": "realTime, syncTime, deleteMemory or deviceConnection with wrong types", "observable": "nothing" },
151
+ { "cause": "postMessage failing schema validation", "trigger": "hand-rolled postMessage instead of BridgeClient", "observable": "nothing" }
152
+ ],
153
+
154
+ "capabilities": [
155
+ { "feature": "languageKey", "minNativeVersion": "0.14.0", "description": "The language key and the language pull command exist." },
156
+ { "feature": "sensorLogTimeRange", "minNativeVersion": "0.15.0", "description": "sensorLog accepts startTime and endTime." },
157
+ { "feature": "lightSensorRawChannels", "minNativeVersion": "0.16.0", "description": "deviceLightSensors carries the raw light_sensor_* channels and the actlumus2 variant." },
158
+ { "feature": "nativeVersionRelay", "minShellVersion": "0.6.0", "description": "The shell relays nativeVersion into the sandboxed client. Below this, handshake-ack carried only shellVersion and capability detection was impossible from inside the iframe." }
159
+ ],
160
+
161
+ "bundle": {
162
+ "artifact": ".zip containing exactly one index.html at the root",
163
+ "rules": [
164
+ { "id": "entry", "rule": "index.html at the archive root", "enforcedBy": "app", "onViolation": "reject (falls back to the first .html found, then to the default screen)" },
165
+ { "id": "self-contained-js", "rule": "no <script src> pointing anywhere but a data: URI", "enforcedBy": "app", "onViolation": "reject" },
166
+ { "id": "self-contained-css", "rule": "no <link rel=stylesheet href> pointing anywhere but a data: URI", "enforcedBy": "app", "onViolation": "reject" },
167
+ { "id": "size", "rule": "the .zip is at most 2097152 bytes", "enforcedBy": "app", "onViolation": "reject" },
168
+ { "id": "single-chunk", "rule": "build without code splitting or ES module imports between chunks", "enforcedBy": "runtime", "onViolation": "blank screen inside srcdoc" },
169
+ { "id": "media", "rule": "embed images, fonts and media as data: URIs", "enforcedBy": "runtime", "onViolation": "broken assets" }
170
+ ],
171
+ "integrity": { "algorithm": "sha256", "format": "sha256-<lowercase hex>", "computedOver": "the exact bytes of the .zip" },
172
+ "tooling": {
173
+ "build": "npx condor-mobile-bridge build -i dist/index.html -o dist/bundle.zip",
174
+ "validate": "npx condor-mobile-bridge validate -i dist/bundle.zip --strict"
175
+ },
176
+ "statusBarHook": "<meta name=\"theme-color\" content=\"#RRGGBB\"> in the bundle sets the native status bar colour. The setStatusBarColor command overrides it at runtime."
177
+ },
178
+
179
+ "verifiedAgainst": {
180
+ "monorepo": "condorinstprogdev/mobile_applications",
181
+ "branch": "dev_canvas",
182
+ "commit": "a6b19cd29704db241c29dd71b267f712610cb013",
183
+ "verifiedOn": "2026-09-22",
184
+ "sources": {
185
+ "nativeVersion": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:32",
186
+ "jsToNativePayloadBytes": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:88",
187
+ "jsErrorPerWindow": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:93-94",
188
+ "jsErrorFieldBytes": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:97",
189
+ "acceptedIsAlwaysTrue": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:417-425",
190
+ "silentDropPaths": "apps/canvas/lib/ui/pages/home/bridge/condor_bridge.dart:218-238,291-295,452-467,529-536",
191
+ "bundleZipBytes": "apps/canvas/lib/core/config/whitelabel_bundle_limits.dart:14",
192
+ "bundleGuards": "apps/canvas/lib/data/services/whitelabel_bundle_service.dart:227-398",
193
+ "integrity": "apps/canvas/lib/data/services/whitelabel_bundle_service.dart:244-259",
194
+ "sensorLogValidation": "apps/canvas/lib/domain/entities/sensor_log_entity.dart:70-85",
195
+ "csp": "apps/canvas/web-mock-src/shell.template.html:28",
196
+ "shellPolicy": "apps/canvas/web-mock-src/src/shell.ts:48-98",
197
+ "webViewPermissionDeny": "apps/canvas/lib/ui/pages/home/home_page.dart:260-268",
198
+ "opaqueOrigin": "apps/canvas/lib/ui/pages/home/home_page.dart:296-298"
199
+ },
200
+ "maintenance": "Re-verify every entry in sources before cutting a release. A published contract that drifts from the app is worse than no contract."
201
+ }
202
+ }