feat: establish Alloy app foundation #10
Merged@github-anthodev wants to merge
feature/application-contracts/foundation/2 into developMerged as
ffd859a8f313.51 files changed
added
.gitignore +11 -0 | new file mode 100644 | ||
| @@ -0,0 +1,11 @@ | ||
| 1 | +node_modules/ | |
| 2 | +packages/watch/build/ | |
| 3 | +packages/watch/src/pkjs/ | |
| 4 | +docs/ | |
| 5 | +*.pbw | |
| 6 | +.DS_Store | |
| 7 | + | |
| 8 | +graft/ | |
| 9 | + | |
| 10 | +AGENTS.md | |
| 11 | +.agents/ | |
| new file mode 100644 | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | +node_modules/ | ||
| 2 | +packages/watch/build/ | ||
| 3 | +packages/watch/src/pkjs/ | ||
| 4 | +docs/ | ||
| 5 | +*.pbw | ||
| 6 | +.DS_Store | ||
| 7 | + | ||
| 8 | +graft/ | ||
| 9 | + | ||
| 10 | +AGENTS.md | ||
| 11 | +.agents/ | ||
added
.ignore +5 -0 | new file mode 100644 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +# graft's cards are gitignored but should stay greppable: ripgrep reads | |
| 2 | +# .ignore before .gitignore, so this re-admits the tree to search only. | |
| 3 | +!graft/ | |
| 4 | +graft/.cache/ | |
| 5 | +graft/.graph/ | |
| new file mode 100644 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +# graft's cards are gitignored but should stay greppable: ripgrep reads | ||
| 2 | +# .ignore before .gitignore, so this re-admits the tree to search only. | ||
| 3 | +!graft/ | ||
| 4 | +graft/.cache/ | ||
| 5 | +graft/.graph/ | ||
added
.node-version +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +24.18.0 | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +24.18.0 | ||
added
README.md +130 -0 | new file mode 100644 | ||
| @@ -0,0 +1,130 @@ | ||
| 1 | +<h1 align="center">Lapin Futé</h1> | |
| 2 | + | |
| 3 | +<p align="center"> | |
| 4 | + <strong>Île-de-France departures on Pebble, without background traffic.</strong> | |
| 5 | + <br> | |
| 6 | + One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary. | |
| 7 | +</p> | |
| 8 | + | |
| 9 | +<p align="center"> | |
| 10 | + <a href="#about">About</a> | |
| 11 | + · | |
| 12 | + <a href="#architecture">Architecture</a> | |
| 13 | + · | |
| 14 | + <a href="#build-from-source">Build</a> | |
| 15 | + · | |
| 16 | + <a href="#quality-gates">Quality gates</a> | |
| 17 | +</p> | |
| 18 | + | |
| 19 | +<p align="center"> | |
| 20 | + <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> | |
| 21 | + <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript"> | |
| 22 | + <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24"> | |
| 23 | + <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status"> | |
| 24 | +</p> | |
| 25 | + | |
| 26 | +> [!IMPORTANT] | |
| 27 | +> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host. | |
| 28 | + | |
| 29 | +## About | |
| 30 | + | |
| 31 | +Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback. | |
| 32 | + | |
| 33 | +```text | |
| 34 | +Minimal Piu fixture view | |
| 35 | + │ symbolic AppMessage aliases | |
| 36 | + ▼ | |
| 37 | +PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM | |
| 38 | + │ | |
| 39 | + └── phone-local favorites + personal key | |
| 40 | +``` | |
| 41 | + | |
| 42 | +**The watch never receives the personal PRIM key and never starts network or background work.** | |
| 43 | + | |
| 44 | +## Highlights | |
| 45 | + | |
| 46 | +- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap. | |
| 47 | +- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`. | |
| 48 | +- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state. | |
| 49 | +- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR. | |
| 50 | +- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two. | |
| 51 | +- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary. | |
| 52 | + | |
| 53 | +## Architecture | |
| 54 | + | |
| 55 | +| Component | Owns | Must not | | |
| 56 | +|---|---|---| | |
| 57 | +| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences | | |
| 58 | +| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening | | |
| 59 | +| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage | | |
| 60 | +| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage | | |
| 61 | + | |
| 62 | +The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`. | |
| 63 | + | |
| 64 | +## Targets and requirements | |
| 65 | + | |
| 66 | +| Layer | Pinned requirement | | |
| 67 | +|---|---| | |
| 68 | +| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator | | |
| 69 | +| Firmware | 4.32 or newer | | |
| 70 | +| Pebble SDK | 4.33.1 | | |
| 71 | +| Pebble CLI | `pebble-tool` 5.0.40 | | |
| 72 | +| Host runtime | Node.js 24.18.0 | | |
| 73 | +| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media | | |
| 74 | + | |
| 75 | +See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands. | |
| 76 | + | |
| 77 | + | |
| 78 | +## Build from source | |
| 79 | + | |
| 80 | +Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain. | |
| 81 | + | |
| 82 | +```sh | |
| 83 | +npm test | |
| 84 | +npm run measure:radio | |
| 85 | +npm run build | |
| 86 | +``` | |
| 87 | + | |
| 88 | +`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs: | |
| 89 | + | |
| 90 | +```sh | |
| 91 | +LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \ | |
| 92 | +LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \ | |
| 93 | +npm run build | |
| 94 | +``` | |
| 95 | + | |
| 96 | +Personal PRIM keys are never build inputs. | |
| 97 | + | |
| 98 | +## Privacy | |
| 99 | + | |
| 100 | +The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence. | |
| 101 | + | |
| 102 | +Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request. | |
| 103 | + | |
| 104 | +## Quality gates | |
| 105 | + | |
| 106 | +| Gate | Budget | | |
| 107 | +|---|---| | |
| 108 | +| Alloy app binary | At most 120 KiB on each target | | |
| 109 | +| Loaded image and RAM | At most 56 KiB each | | |
| 110 | +| XS arenas | At least 25% free at the worst measured point | | |
| 111 | +| Settled XS drift | At most 1 KiB over the final ten of 50 cycles | | |
| 112 | +| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes | | |
| 113 | +| Watch request | At most 192 encoded bytes | | |
| 114 | +| Phone-to-watch dictionary | At most 768 encoded bytes | | |
| 115 | +| Watch unsent queue | Four messages, at most once, one write per `onWritable` | | |
| 116 | + | |
| 117 | +The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md). | |
| 118 | + | |
| 119 | +## Documentation | |
| 120 | + | |
| 121 | +- [Contracts](docs/contracts.md) | |
| 122 | +- [Toolchain](docs/toolchain.md) | |
| 123 | +- [Platforms](docs/platforms.md) | |
| 124 | +- [Performance and battery](docs/performance.md) | |
| 125 | +- [Localization](docs/localization.md) | |
| 126 | +- [Evidence ledger](docs/evidence.md) | |
| 127 | + | |
| 128 | +## Contributing | |
| 129 | + | |
| 130 | +Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates. | |
| new file mode 100644 | |||
| @@ -0,0 +1,130 @@ | |||
| 1 | +<h1 align="center">Lapin Futé</h1> | ||
| 2 | + | ||
| 3 | +<p align="center"> | ||
| 4 | + <strong>Île-de-France departures on Pebble, without background traffic.</strong> | ||
| 5 | + <br> | ||
| 6 | + One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary. | ||
| 7 | +</p> | ||
| 8 | + | ||
| 9 | +<p align="center"> | ||
| 10 | + <a href="#about">About</a> | ||
| 11 | + · | ||
| 12 | + <a href="#architecture">Architecture</a> | ||
| 13 | + · | ||
| 14 | + <a href="#build-from-source">Build</a> | ||
| 15 | + · | ||
| 16 | + <a href="#quality-gates">Quality gates</a> | ||
| 17 | +</p> | ||
| 18 | + | ||
| 19 | +<p align="center"> | ||
| 20 | + <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> | ||
| 21 | + <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript"> | ||
| 22 | + <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24"> | ||
| 23 | + <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status"> | ||
| 24 | +</p> | ||
| 25 | + | ||
| 26 | +> [!IMPORTANT] | ||
| 27 | +> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host. | ||
| 28 | + | ||
| 29 | +## About | ||
| 30 | + | ||
| 31 | +Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback. | ||
| 32 | + | ||
| 33 | +```text | ||
| 34 | +Minimal Piu fixture view | ||
| 35 | + │ symbolic AppMessage aliases | ||
| 36 | + ▼ | ||
| 37 | +PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM | ||
| 38 | + │ | ||
| 39 | + └── phone-local favorites + personal key | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +**The watch never receives the personal PRIM key and never starts network or background work.** | ||
| 43 | + | ||
| 44 | +## Highlights | ||
| 45 | + | ||
| 46 | +- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap. | ||
| 47 | +- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`. | ||
| 48 | +- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state. | ||
| 49 | +- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR. | ||
| 50 | +- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two. | ||
| 51 | +- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary. | ||
| 52 | + | ||
| 53 | +## Architecture | ||
| 54 | + | ||
| 55 | +| Component | Owns | Must not | | ||
| 56 | +|---|---|---| | ||
| 57 | +| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences | | ||
| 58 | +| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening | | ||
| 59 | +| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage | | ||
| 60 | +| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage | | ||
| 61 | + | ||
| 62 | +The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`. | ||
| 63 | + | ||
| 64 | +## Targets and requirements | ||
| 65 | + | ||
| 66 | +| Layer | Pinned requirement | | ||
| 67 | +|---|---| | ||
| 68 | +| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator | | ||
| 69 | +| Firmware | 4.32 or newer | | ||
| 70 | +| Pebble SDK | 4.33.1 | | ||
| 71 | +| Pebble CLI | `pebble-tool` 5.0.40 | | ||
| 72 | +| Host runtime | Node.js 24.18.0 | | ||
| 73 | +| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media | | ||
| 74 | + | ||
| 75 | +See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands. | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +## Build from source | ||
| 79 | + | ||
| 80 | +Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain. | ||
| 81 | + | ||
| 82 | +```sh | ||
| 83 | +npm test | ||
| 84 | +npm run measure:radio | ||
| 85 | +npm run build | ||
| 86 | +``` | ||
| 87 | + | ||
| 88 | +`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs: | ||
| 89 | + | ||
| 90 | +```sh | ||
| 91 | +LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \ | ||
| 92 | +LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \ | ||
| 93 | +npm run build | ||
| 94 | +``` | ||
| 95 | + | ||
| 96 | +Personal PRIM keys are never build inputs. | ||
| 97 | + | ||
| 98 | +## Privacy | ||
| 99 | + | ||
| 100 | +The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence. | ||
| 101 | + | ||
| 102 | +Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request. | ||
| 103 | + | ||
| 104 | +## Quality gates | ||
| 105 | + | ||
| 106 | +| Gate | Budget | | ||
| 107 | +|---|---| | ||
| 108 | +| Alloy app binary | At most 120 KiB on each target | | ||
| 109 | +| Loaded image and RAM | At most 56 KiB each | | ||
| 110 | +| XS arenas | At least 25% free at the worst measured point | | ||
| 111 | +| Settled XS drift | At most 1 KiB over the final ten of 50 cycles | | ||
| 112 | +| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes | | ||
| 113 | +| Watch request | At most 192 encoded bytes | | ||
| 114 | +| Phone-to-watch dictionary | At most 768 encoded bytes | | ||
| 115 | +| Watch unsent queue | Four messages, at most once, one write per `onWritable` | | ||
| 116 | + | ||
| 117 | +The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md). | ||
| 118 | + | ||
| 119 | +## Documentation | ||
| 120 | + | ||
| 121 | +- [Contracts](docs/contracts.md) | ||
| 122 | +- [Toolchain](docs/toolchain.md) | ||
| 123 | +- [Platforms](docs/platforms.md) | ||
| 124 | +- [Performance and battery](docs/performance.md) | ||
| 125 | +- [Localization](docs/localization.md) | ||
| 126 | +- [Evidence ledger](docs/evidence.md) | ||
| 127 | + | ||
| 128 | +## Contributing | ||
| 129 | + | ||
| 130 | +Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates. | ||
added
fixtures/departures/foundation.json +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +{ | |
| 2 | + "favorite": { | |
| 3 | + "schemaVersion": 1, | |
| 4 | + "id": "foundation-home", | |
| 5 | + "serviceId": "opaque:foundation:service:1", | |
| 6 | + "displayName": "Commute home", | |
| 7 | + "stopLabel": "Châtelet", | |
| 8 | + "lineLabel": "Métro 1", | |
| 9 | + "destinationLabel": "La Défense", | |
| 10 | + "sortOrder": 0 | |
| 11 | + }, | |
| 12 | + "result": { | |
| 13 | + "schemaVersion": 1, | |
| 14 | + "requestId": "foundation-request", | |
| 15 | + "favoriteId": "foundation-home", | |
| 16 | + "fetchedAt": 1788000000, | |
| 17 | + "sourceUpdatedAt": 1787999990, | |
| 18 | + "freshness": "REALTIME", | |
| 19 | + "departures": [ | |
| 20 | + { | |
| 21 | + "expectedAt": 1788000120, | |
| 22 | + "aimedAt": 1788000100, | |
| 23 | + "minutes": 2, | |
| 24 | + "status": "DELAYED", | |
| 25 | + "nextIntervalMinutes": 4 | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + "expectedAt": 1788000360, | |
| 29 | + "minutes": 6, | |
| 30 | + "status": "ON_TIME" | |
| 31 | + } | |
| 32 | + ] | |
| 33 | + } | |
| 34 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +{ | ||
| 2 | + "favorite": { | ||
| 3 | + "schemaVersion": 1, | ||
| 4 | + "id": "foundation-home", | ||
| 5 | + "serviceId": "opaque:foundation:service:1", | ||
| 6 | + "displayName": "Commute home", | ||
| 7 | + "stopLabel": "Châtelet", | ||
| 8 | + "lineLabel": "Métro 1", | ||
| 9 | + "destinationLabel": "La Défense", | ||
| 10 | + "sortOrder": 0 | ||
| 11 | + }, | ||
| 12 | + "result": { | ||
| 13 | + "schemaVersion": 1, | ||
| 14 | + "requestId": "foundation-request", | ||
| 15 | + "favoriteId": "foundation-home", | ||
| 16 | + "fetchedAt": 1788000000, | ||
| 17 | + "sourceUpdatedAt": 1787999990, | ||
| 18 | + "freshness": "REALTIME", | ||
| 19 | + "departures": [ | ||
| 20 | + { | ||
| 21 | + "expectedAt": 1788000120, | ||
| 22 | + "aimedAt": 1788000100, | ||
| 23 | + "minutes": 2, | ||
| 24 | + "status": "DELAYED", | ||
| 25 | + "nextIntervalMinutes": 4 | ||
| 26 | + }, | ||
| 27 | + { | ||
| 28 | + "expectedAt": 1788000360, | ||
| 29 | + "minutes": 6, | ||
| 30 | + "status": "ON_TIME" | ||
| 31 | + } | ||
| 32 | + ] | ||
| 33 | + } | ||
| 34 | +} | ||
added
package.json +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +{ | |
| 2 | + "name": "lapin-fute", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "workspaces": [ | |
| 7 | + "packages/*" | |
| 8 | + ], | |
| 9 | + "engines": { | |
| 10 | + "node": "24.18.0" | |
| 11 | + }, | |
| 12 | + "scripts": { | |
| 13 | + "build": "node scripts/build.mjs", | |
| 14 | + "measure:radio": "node scripts/measure-radio.mjs", | |
| 15 | + "test": "node scripts/test.mjs", | |
| 16 | + "test:contracts": "node --test packages/contracts/test/contracts.test.ts", | |
| 17 | + "test:watch": "node --test packages/watch/test/*.test.js", | |
| 18 | + "verify:watch-build": "node scripts/verify-watch-build.mjs" | |
| 19 | + } | |
| 20 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +{ | ||
| 2 | + "name": "lapin-fute", | ||
| 3 | + "version": "0.1.0", | ||
| 4 | + "private": true, | ||
| 5 | + "type": "module", | ||
| 6 | + "workspaces": [ | ||
| 7 | + "packages/*" | ||
| 8 | + ], | ||
| 9 | + "engines": { | ||
| 10 | + "node": "24.18.0" | ||
| 11 | + }, | ||
| 12 | + "scripts": { | ||
| 13 | + "build": "node scripts/build.mjs", | ||
| 14 | + "measure:radio": "node scripts/measure-radio.mjs", | ||
| 15 | + "test": "node scripts/test.mjs", | ||
| 16 | + "test:contracts": "node --test packages/contracts/test/contracts.test.ts", | ||
| 17 | + "test:watch": "node --test packages/watch/test/*.test.js", | ||
| 18 | + "verify:watch-build": "node scripts/verify-watch-build.mjs" | ||
| 19 | + } | ||
| 20 | +} | ||
added
packages/backend/package.json +10 -0 | new file mode 100644 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@lapin-fute/backend", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "exports": "./src/index.ts", | |
| 7 | + "scripts": { | |
| 8 | + "test": "node --test test/backend.test.ts" | |
| 9 | + } | |
| 10 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +{ | ||
| 2 | + "name": "@lapin-fute/backend", | ||
| 3 | + "version": "0.1.0", | ||
| 4 | + "private": true, | ||
| 5 | + "type": "module", | ||
| 6 | + "exports": "./src/index.ts", | ||
| 7 | + "scripts": { | ||
| 8 | + "test": "node --test test/backend.test.ts" | ||
| 9 | + } | ||
| 10 | +} | ||
added
packages/backend/src/index.ts +708 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/backend/test/backend.test.ts +516 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/package.json +10 -0 | new file mode 100644 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@lapin-fute/companion", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "commonjs", | |
| 6 | + "main": "src/index.js", | |
| 7 | + "scripts": { | |
| 8 | + "test": "node --test test/*.test.js" | |
| 9 | + } | |
| 10 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +{ | ||
| 2 | + "name": "@lapin-fute/companion", | ||
| 3 | + "version": "0.1.0", | ||
| 4 | + "private": true, | ||
| 5 | + "type": "commonjs", | ||
| 6 | + "main": "src/index.js", | ||
| 7 | + "scripts": { | ||
| 8 | + "test": "node --test test/*.test.js" | ||
| 9 | + } | ||
| 10 | +} | ||
added
packages/companion/src/codec.js +155 -0 | new file mode 100644 | ||
| @@ -0,0 +1,155 @@ | ||
| 1 | +"use strict"; | |
| 2 | + | |
| 3 | +var contracts = require("./contracts"); | |
| 4 | +var T = contracts.MESSAGE_TYPE; | |
| 5 | + | |
| 6 | +function base(messageType) { | |
| 7 | + return { | |
| 8 | + SCHEMA_VERSION: contracts.SCHEMA_VERSION, | |
| 9 | + MESSAGE_TYPE: messageType | |
| 10 | + }; | |
| 11 | +} | |
| 12 | + | |
| 13 | +function requireId(value, name) { | |
| 14 | + if (!contracts.boundedString(value, contracts.LIMITS.idUtf8Bytes)) { | |
| 15 | + throw new TypeError(name + " must be a bounded identifier"); | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +function encodeRequest(request) { | |
| 20 | + var message; | |
| 21 | + requireId(request && request.requestId, "requestId"); | |
| 22 | + requireId(request && request.favoriteId, "favoriteId"); | |
| 23 | + if (Object.keys(contracts.REQUEST_TRIGGER).every(function (name) { | |
| 24 | + return contracts.REQUEST_TRIGGER[name] !== request.trigger; | |
| 25 | + })) { | |
| 26 | + throw new TypeError("trigger is not supported"); | |
| 27 | + } | |
| 28 | + message = base(T.REQUEST); | |
| 29 | + message.REQUEST_ID = request.requestId; | |
| 30 | + message.FAVORITE_ID = request.favoriteId; | |
| 31 | + message.REQUEST_TRIGGER = request.trigger; | |
| 32 | + return message; | |
| 33 | +} | |
| 34 | + | |
| 35 | +function decodeRequest(message) { | |
| 36 | + var trigger; | |
| 37 | + if (!contracts.isObject(message) | |
| 38 | + || !contracts.hasOnlyKeys(message, [ | |
| 39 | + "SCHEMA_VERSION", | |
| 40 | + "MESSAGE_TYPE", | |
| 41 | + "REQUEST_ID", | |
| 42 | + "FAVORITE_ID", | |
| 43 | + "REQUEST_TRIGGER" | |
| 44 | + ]) | |
| 45 | + || message.SCHEMA_VERSION !== contracts.SCHEMA_VERSION | |
| 46 | + || message.MESSAGE_TYPE !== T.REQUEST) return null; | |
| 47 | + trigger = message.REQUEST_TRIGGER; | |
| 48 | + if (!contracts.boundedString(message.REQUEST_ID, contracts.LIMITS.idUtf8Bytes) | |
| 49 | + || !contracts.boundedString(message.FAVORITE_ID, contracts.LIMITS.idUtf8Bytes) | |
| 50 | + || Object.keys(contracts.REQUEST_TRIGGER).every(function (name) { | |
| 51 | + return contracts.REQUEST_TRIGGER[name] !== trigger; | |
| 52 | + })) return null; | |
| 53 | + return { | |
| 54 | + requestId: message.REQUEST_ID, | |
| 55 | + favoriteId: message.FAVORITE_ID, | |
| 56 | + trigger: trigger | |
| 57 | + }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +function encodeConfiguration(sequenceId, favorites, keyStatus, language) { | |
| 61 | + var messages = []; | |
| 62 | + var begin; | |
| 63 | + requireId(sequenceId, "sequenceId"); | |
| 64 | + if (!contracts.isFavoriteList(favorites)) throw new TypeError("favorites do not match the contract"); | |
| 65 | + if (Object.keys(contracts.KEY_STATUS).every(function (name) { | |
| 66 | + return contracts.KEY_STATUS[name] !== keyStatus; | |
| 67 | + })) throw new TypeError("keyStatus is not supported"); | |
| 68 | + if (language !== contracts.WIRE_LANGUAGE.EN && language !== contracts.WIRE_LANGUAGE.FR) { | |
| 69 | + throw new TypeError("language must be en or fr"); | |
| 70 | + } | |
| 71 | + | |
| 72 | + begin = base(T.CONFIG_BEGIN); | |
| 73 | + begin.REQUEST_ID = sequenceId; | |
| 74 | + begin.ITEM_COUNT = favorites.length; | |
| 75 | + begin.KEY_STATUS = keyStatus; | |
| 76 | + begin.DISPLAY_NAME = language; | |
| 77 | + messages.push(begin); | |
| 78 | + | |
| 79 | + favorites.forEach(function (favorite, index) { | |
| 80 | + var message = base(T.FAVORITE); | |
| 81 | + message.REQUEST_ID = sequenceId; | |
| 82 | + message.ITEM_INDEX = index; | |
| 83 | + message.FAVORITE_ID = favorite.id; | |
| 84 | + message.SERVICE_ID = favorite.serviceId; | |
| 85 | + if (typeof favorite.displayName !== "undefined") message.DISPLAY_NAME = favorite.displayName; | |
| 86 | + message.STOP_LABEL = favorite.stopLabel; | |
| 87 | + message.LINE_LABEL = favorite.lineLabel; | |
| 88 | + message.DESTINATION_LABEL = favorite.destinationLabel; | |
| 89 | + message.SORT_ORDER = favorite.sortOrder; | |
| 90 | + messages.push(message); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + begin = base(T.CONFIG_COMMIT); | |
| 94 | + begin.REQUEST_ID = sequenceId; | |
| 95 | + messages.push(begin); | |
| 96 | + return messages; | |
| 97 | +} | |
| 98 | + | |
| 99 | +function encodeResult(result) { | |
| 100 | + var messages = []; | |
| 101 | + var message; | |
| 102 | + if (!contracts.isDepartureResult(result)) throw new TypeError("result does not match the contract"); | |
| 103 | + | |
| 104 | + message = base(T.RESULT_BEGIN); | |
| 105 | + message.REQUEST_ID = result.requestId; | |
| 106 | + message.FAVORITE_ID = result.favoriteId; | |
| 107 | + message.ITEM_COUNT = result.departures.length; | |
| 108 | + message.FETCHED_AT = result.fetchedAt; | |
| 109 | + if (typeof result.sourceUpdatedAt !== "undefined") message.SOURCE_UPDATED_AT = result.sourceUpdatedAt; | |
| 110 | + message.FRESHNESS = contracts.FRESHNESS.indexOf(result.freshness); | |
| 111 | + messages.push(message); | |
| 112 | + | |
| 113 | + result.departures.forEach(function (departure, index) { | |
| 114 | + var item = base(T.DEPARTURE); | |
| 115 | + item.REQUEST_ID = result.requestId; | |
| 116 | + item.FAVORITE_ID = result.favoriteId; | |
| 117 | + item.ITEM_INDEX = index; | |
| 118 | + item.EXPECTED_AT = departure.expectedAt; | |
| 119 | + if (typeof departure.aimedAt !== "undefined") item.AIMED_AT = departure.aimedAt; | |
| 120 | + item.MINUTES = departure.minutes; | |
| 121 | + item.DEPARTURE_STATUS = contracts.DEPARTURE_STATUS.indexOf(departure.status); | |
| 122 | + if (typeof departure.nextIntervalMinutes !== "undefined") { | |
| 123 | + item.NEXT_INTERVAL_MINUTES = departure.nextIntervalMinutes; | |
| 124 | + } | |
| 125 | + messages.push(item); | |
| 126 | + }); | |
| 127 | + | |
| 128 | + message = base(T.RESULT_COMMIT); | |
| 129 | + message.REQUEST_ID = result.requestId; | |
| 130 | + message.FAVORITE_ID = result.favoriteId; | |
| 131 | + messages.push(message); | |
| 132 | + return messages; | |
| 133 | +} | |
| 134 | + | |
| 135 | +function encodeError(error) { | |
| 136 | + var message; | |
| 137 | + if (!contracts.isErrorResult(error)) throw new TypeError("error does not match the contract"); | |
| 138 | + message = base(T.ERROR); | |
| 139 | + message.REQUEST_ID = error.requestId; | |
| 140 | + if (typeof error.favoriteId !== "undefined") message.FAVORITE_ID = error.favoriteId; | |
| 141 | + message.ERROR_CODE = contracts.ERROR_CODE.indexOf(error.code); | |
| 142 | + message.OCCURRED_AT = error.occurredAt; | |
| 143 | + if (typeof error.retryAfterSeconds !== "undefined") { | |
| 144 | + message.RETRY_AFTER_SECONDS = error.retryAfterSeconds; | |
| 145 | + } | |
| 146 | + return message; | |
| 147 | +} | |
| 148 | + | |
| 149 | +module.exports = { | |
| 150 | + encodeRequest: encodeRequest, | |
| 151 | + decodeRequest: decodeRequest, | |
| 152 | + encodeConfiguration: encodeConfiguration, | |
| 153 | + encodeResult: encodeResult, | |
| 154 | + encodeError: encodeError | |
| 155 | +}; | |
| new file mode 100644 | |||
| @@ -0,0 +1,155 @@ | |||
| 1 | +"use strict"; | ||
| 2 | + | ||
| 3 | +var contracts = require("./contracts"); | ||
| 4 | +var T = contracts.MESSAGE_TYPE; | ||
| 5 | + | ||
| 6 | +function base(messageType) { | ||
| 7 | + return { | ||
| 8 | + SCHEMA_VERSION: contracts.SCHEMA_VERSION, | ||
| 9 | + MESSAGE_TYPE: messageType | ||
| 10 | + }; | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +function requireId(value, name) { | ||
| 14 | + if (!contracts.boundedString(value, contracts.LIMITS.idUtf8Bytes)) { | ||
| 15 | + throw new TypeError(name + " must be a bounded identifier"); | ||
| 16 | + } | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +function encodeRequest(request) { | ||
| 20 | + var message; | ||
| 21 | + requireId(request && request.requestId, "requestId"); | ||
| 22 | + requireId(request && request.favoriteId, "favoriteId"); | ||
| 23 | + if (Object.keys(contracts.REQUEST_TRIGGER).every(function (name) { | ||
| 24 | + return contracts.REQUEST_TRIGGER[name] !== request.trigger; | ||
| 25 | + })) { | ||
| 26 | + throw new TypeError("trigger is not supported"); | ||
| 27 | + } | ||
| 28 | + message = base(T.REQUEST); | ||
| 29 | + message.REQUEST_ID = request.requestId; | ||
| 30 | + message.FAVORITE_ID = request.favoriteId; | ||
| 31 | + message.REQUEST_TRIGGER = request.trigger; | ||
| 32 | + return message; | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +function decodeRequest(message) { | ||
| 36 | + var trigger; | ||
| 37 | + if (!contracts.isObject(message) | ||
| 38 | + || !contracts.hasOnlyKeys(message, [ | ||
| 39 | + "SCHEMA_VERSION", | ||
| 40 | + "MESSAGE_TYPE", | ||
| 41 | + "REQUEST_ID", | ||
| 42 | + "FAVORITE_ID", | ||
| 43 | + "REQUEST_TRIGGER" | ||
| 44 | + ]) | ||
| 45 | + || message.SCHEMA_VERSION !== contracts.SCHEMA_VERSION | ||
| 46 | + || message.MESSAGE_TYPE !== T.REQUEST) return null; | ||
| 47 | + trigger = message.REQUEST_TRIGGER; | ||
| 48 | + if (!contracts.boundedString(message.REQUEST_ID, contracts.LIMITS.idUtf8Bytes) | ||
| 49 | + || !contracts.boundedString(message.FAVORITE_ID, contracts.LIMITS.idUtf8Bytes) | ||
| 50 | + || Object.keys(contracts.REQUEST_TRIGGER).every(function (name) { | ||
| 51 | + return contracts.REQUEST_TRIGGER[name] !== trigger; | ||
| 52 | + })) return null; | ||
| 53 | + return { | ||
| 54 | + requestId: message.REQUEST_ID, | ||
| 55 | + favoriteId: message.FAVORITE_ID, | ||
| 56 | + trigger: trigger | ||
| 57 | + }; | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +function encodeConfiguration(sequenceId, favorites, keyStatus, language) { | ||
| 61 | + var messages = []; | ||
| 62 | + var begin; | ||
| 63 | + requireId(sequenceId, "sequenceId"); | ||
| 64 | + if (!contracts.isFavoriteList(favorites)) throw new TypeError("favorites do not match the contract"); | ||
| 65 | + if (Object.keys(contracts.KEY_STATUS).every(function (name) { | ||
| 66 | + return contracts.KEY_STATUS[name] !== keyStatus; | ||
| 67 | + })) throw new TypeError("keyStatus is not supported"); | ||
| 68 | + if (language !== contracts.WIRE_LANGUAGE.EN && language !== contracts.WIRE_LANGUAGE.FR) { | ||
| 69 | + throw new TypeError("language must be en or fr"); | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + begin = base(T.CONFIG_BEGIN); | ||
| 73 | + begin.REQUEST_ID = sequenceId; | ||
| 74 | + begin.ITEM_COUNT = favorites.length; | ||
| 75 | + begin.KEY_STATUS = keyStatus; | ||
| 76 | + begin.DISPLAY_NAME = language; | ||
| 77 | + messages.push(begin); | ||
| 78 | + | ||
| 79 | + favorites.forEach(function (favorite, index) { | ||
| 80 | + var message = base(T.FAVORITE); | ||
| 81 | + message.REQUEST_ID = sequenceId; | ||
| 82 | + message.ITEM_INDEX = index; | ||
| 83 | + message.FAVORITE_ID = favorite.id; | ||
| 84 | + message.SERVICE_ID = favorite.serviceId; | ||
| 85 | + if (typeof favorite.displayName !== "undefined") message.DISPLAY_NAME = favorite.displayName; | ||
| 86 | + message.STOP_LABEL = favorite.stopLabel; | ||
| 87 | + message.LINE_LABEL = favorite.lineLabel; | ||
| 88 | + message.DESTINATION_LABEL = favorite.destinationLabel; | ||
| 89 | + message.SORT_ORDER = favorite.sortOrder; | ||
| 90 | + messages.push(message); | ||
| 91 | + }); | ||
| 92 | + | ||
| 93 | + begin = base(T.CONFIG_COMMIT); | ||
| 94 | + begin.REQUEST_ID = sequenceId; | ||
| 95 | + messages.push(begin); | ||
| 96 | + return messages; | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +function encodeResult(result) { | ||
| 100 | + var messages = []; | ||
| 101 | + var message; | ||
| 102 | + if (!contracts.isDepartureResult(result)) throw new TypeError("result does not match the contract"); | ||
| 103 | + | ||
| 104 | + message = base(T.RESULT_BEGIN); | ||
| 105 | + message.REQUEST_ID = result.requestId; | ||
| 106 | + message.FAVORITE_ID = result.favoriteId; | ||
| 107 | + message.ITEM_COUNT = result.departures.length; | ||
| 108 | + message.FETCHED_AT = result.fetchedAt; | ||
| 109 | + if (typeof result.sourceUpdatedAt !== "undefined") message.SOURCE_UPDATED_AT = result.sourceUpdatedAt; | ||
| 110 | + message.FRESHNESS = contracts.FRESHNESS.indexOf(result.freshness); | ||
| 111 | + messages.push(message); | ||
| 112 | + | ||
| 113 | + result.departures.forEach(function (departure, index) { | ||
| 114 | + var item = base(T.DEPARTURE); | ||
| 115 | + item.REQUEST_ID = result.requestId; | ||
| 116 | + item.FAVORITE_ID = result.favoriteId; | ||
| 117 | + item.ITEM_INDEX = index; | ||
| 118 | + item.EXPECTED_AT = departure.expectedAt; | ||
| 119 | + if (typeof departure.aimedAt !== "undefined") item.AIMED_AT = departure.aimedAt; | ||
| 120 | + item.MINUTES = departure.minutes; | ||
| 121 | + item.DEPARTURE_STATUS = contracts.DEPARTURE_STATUS.indexOf(departure.status); | ||
| 122 | + if (typeof departure.nextIntervalMinutes !== "undefined") { | ||
| 123 | + item.NEXT_INTERVAL_MINUTES = departure.nextIntervalMinutes; | ||
| 124 | + } | ||
| 125 | + messages.push(item); | ||
| 126 | + }); | ||
| 127 | + | ||
| 128 | + message = base(T.RESULT_COMMIT); | ||
| 129 | + message.REQUEST_ID = result.requestId; | ||
| 130 | + message.FAVORITE_ID = result.favoriteId; | ||
| 131 | + messages.push(message); | ||
| 132 | + return messages; | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +function encodeError(error) { | ||
| 136 | + var message; | ||
| 137 | + if (!contracts.isErrorResult(error)) throw new TypeError("error does not match the contract"); | ||
| 138 | + message = base(T.ERROR); | ||
| 139 | + message.REQUEST_ID = error.requestId; | ||
| 140 | + if (typeof error.favoriteId !== "undefined") message.FAVORITE_ID = error.favoriteId; | ||
| 141 | + message.ERROR_CODE = contracts.ERROR_CODE.indexOf(error.code); | ||
| 142 | + message.OCCURRED_AT = error.occurredAt; | ||
| 143 | + if (typeof error.retryAfterSeconds !== "undefined") { | ||
| 144 | + message.RETRY_AFTER_SECONDS = error.retryAfterSeconds; | ||
| 145 | + } | ||
| 146 | + return message; | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +module.exports = { | ||
| 150 | + encodeRequest: encodeRequest, | ||
| 151 | + decodeRequest: decodeRequest, | ||
| 152 | + encodeConfiguration: encodeConfiguration, | ||
| 153 | + encodeResult: encodeResult, | ||
| 154 | + encodeError: encodeError | ||
| 155 | +}; | ||
added
packages/companion/src/configuration.js +185 -0 | new file mode 100644 | ||
| @@ -0,0 +1,185 @@ | ||
| 1 | +"use strict"; | |
| 2 | + | |
| 3 | +var contracts = require("./contracts"); | |
| 4 | +var STORAGE_KEY = "lapin-fute.configuration.v1"; | |
| 5 | +var RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"]; | |
| 6 | +var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"]; | |
| 7 | +var MAX_CLOSE_RESPONSE_LENGTH = 32768; | |
| 8 | +var FAVORITE_STRING_KEYS = [ | |
| 9 | + "id", | |
| 10 | + "serviceId", | |
| 11 | + "displayName", | |
| 12 | + "stopLabel", | |
| 13 | + "lineLabel", | |
| 14 | + "destinationLabel" | |
| 15 | +]; | |
| 16 | + | |
| 17 | +function emptyConfiguration() { | |
| 18 | + return { schemaVersion: contracts.SCHEMA_VERSION, favorites: [], apiKey: null }; | |
| 19 | +} | |
| 20 | +function isFavoriteSecretFree(favorite, firstSecret, secondSecret) { | |
| 21 | + var field; | |
| 22 | + var value; | |
| 23 | + for (field = 0; field < FAVORITE_STRING_KEYS.length; field += 1) { | |
| 24 | + value = favorite[FAVORITE_STRING_KEYS[field]]; | |
| 25 | + if (typeof value !== "string") continue; | |
| 26 | + if (typeof firstSecret === "string" && firstSecret.length > 0 && value.indexOf(firstSecret) !== -1) return false; | |
| 27 | + if (typeof secondSecret === "string" && secondSecret.length > 0 && value.indexOf(secondSecret) !== -1) return false; | |
| 28 | + } | |
| 29 | + return true; | |
| 30 | +} | |
| 31 | + | |
| 32 | +function areFavoritesSecretFree(favorites, firstSecret, secondSecret) { | |
| 33 | + var index; | |
| 34 | + for (index = 0; index < favorites.length; index += 1) { | |
| 35 | + if (!isFavoriteSecretFree(favorites[index], firstSecret, secondSecret)) return false; | |
| 36 | + } | |
| 37 | + return true; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function normalizeLanguage(value) { | |
| 41 | + if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN; | |
| 42 | + return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR | |
| 43 | + ? contracts.WIRE_LANGUAGE.FR | |
| 44 | + : contracts.WIRE_LANGUAGE.EN; | |
| 45 | +} | |
| 46 | + | |
| 47 | +function activeWatchLanguage(Pebble) { | |
| 48 | + var info; | |
| 49 | + try { | |
| 50 | + if (!Pebble || typeof Pebble.getActiveWatchInfo !== "function") { | |
| 51 | + return contracts.WIRE_LANGUAGE.EN; | |
| 52 | + } | |
| 53 | + info = Pebble.getActiveWatchInfo(); | |
| 54 | + if (!contracts.isObject(info)) return contracts.WIRE_LANGUAGE.EN; | |
| 55 | + return normalizeLanguage(info.language); | |
| 56 | + } catch (ignored) { | |
| 57 | + return contracts.WIRE_LANGUAGE.EN; | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | + | |
| 62 | +function isStoredConfiguration(value) { | |
| 63 | + return contracts.isObject(value) | |
| 64 | + && contracts.hasOnlyKeys(value, RECORD_KEYS) | |
| 65 | + && value.schemaVersion === contracts.SCHEMA_VERSION | |
| 66 | + && contracts.isFavoriteList(value.favorites) | |
| 67 | + && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey)) | |
| 68 | + && areFavoritesSecretFree(value.favorites, value.apiKey, null); | |
| 69 | +} | |
| 70 | + | |
| 71 | +function isConfigurationUpdate(value) { | |
| 72 | + return contracts.isObject(value) | |
| 73 | + && contracts.hasOnlyKeys(value, UPDATE_KEYS) | |
| 74 | + && value.schemaVersion === contracts.SCHEMA_VERSION | |
| 75 | + && contracts.isFavoriteList(value.favorites) | |
| 76 | + && contracts.isApiKeyUpdate(value.apiKeyUpdate); | |
| 77 | +} | |
| 78 | + | |
| 79 | +function copyConfiguration(configuration) { | |
| 80 | + return { | |
| 81 | + schemaVersion: contracts.SCHEMA_VERSION, | |
| 82 | + favorites: configuration.favorites.map(contracts.copyFavorite), | |
| 83 | + apiKey: configuration.apiKey | |
| 84 | + }; | |
| 85 | +} | |
| 86 | + | |
| 87 | +function loadConfiguration(storage) { | |
| 88 | + var parsed; | |
| 89 | + var serialized; | |
| 90 | + try { | |
| 91 | + serialized = storage.getItem(STORAGE_KEY); | |
| 92 | + if (typeof serialized !== "string" || serialized.length === 0) return emptyConfiguration(); | |
| 93 | + parsed = JSON.parse(serialized); | |
| 94 | + } catch (ignored) { | |
| 95 | + return emptyConfiguration(); | |
| 96 | + } | |
| 97 | + return isStoredConfiguration(parsed) ? copyConfiguration(parsed) : emptyConfiguration(); | |
| 98 | +} | |
| 99 | + | |
| 100 | +function saveConfiguration(storage, configuration) { | |
| 101 | + if (!isStoredConfiguration(configuration)) return false; | |
| 102 | + try { | |
| 103 | + storage.setItem(STORAGE_KEY, JSON.stringify(configuration)); | |
| 104 | + return true; | |
| 105 | + } catch (ignored) { | |
| 106 | + return false; | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +function applyConfigurationUpdate(current, update) { | |
| 111 | + var apiKey; | |
| 112 | + var replacementKey = null; | |
| 113 | + if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null; | |
| 114 | + if (update.apiKeyUpdate.action === "REPLACE") replacementKey = update.apiKeyUpdate.value; | |
| 115 | + if (!areFavoritesSecretFree(update.favorites, current.apiKey, replacementKey)) return null; | |
| 116 | + apiKey = current.apiKey; | |
| 117 | + if (replacementKey !== null) apiKey = replacementKey; | |
| 118 | + if (update.apiKeyUpdate.action === "REMOVE") apiKey = null; | |
| 119 | + return { | |
| 120 | + schemaVersion: contracts.SCHEMA_VERSION, | |
| 121 | + favorites: update.favorites.map(contracts.copyFavorite), | |
| 122 | + apiKey: apiKey | |
| 123 | + }; | |
| 124 | +} | |
| 125 | + | |
| 126 | +function parseCloseFragment(response) { | |
| 127 | + var fragment; | |
| 128 | + var hashIndex; | |
| 129 | + var parsed; | |
| 130 | + if (typeof response !== "string" | |
| 131 | + || response.length === 0 | |
| 132 | + || response.length > MAX_CLOSE_RESPONSE_LENGTH | |
| 133 | + || response === "CANCELLED") return null; | |
| 134 | + hashIndex = response.indexOf("#"); | |
| 135 | + fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1); | |
| 136 | + if (fragment.charAt(0) === "#") fragment = fragment.slice(1); | |
| 137 | + if (fragment.length === 0 || fragment === "CANCELLED") return null; | |
| 138 | + try { | |
| 139 | + fragment = decodeURIComponent(fragment); | |
| 140 | + parsed = JSON.parse(fragment); | |
| 141 | + } catch (ignored) { | |
| 142 | + return null; | |
| 143 | + } | |
| 144 | + if (!isConfigurationUpdate(parsed)) return null; | |
| 145 | + return { | |
| 146 | + schemaVersion: contracts.SCHEMA_VERSION, | |
| 147 | + favorites: parsed.favorites.map(contracts.copyFavorite), | |
| 148 | + apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" | |
| 149 | + ? { schemaVersion: contracts.SCHEMA_VERSION, action: "REPLACE", value: parsed.apiKeyUpdate.value } | |
| 150 | + : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action } | |
| 151 | + }; | |
| 152 | +} | |
| 153 | + | |
| 154 | +function configurationPageState(configuration, language) { | |
| 155 | + return { | |
| 156 | + hasKey: configuration.apiKey !== null, | |
| 157 | + favorites: configuration.favorites.map(contracts.copyFavorite), | |
| 158 | + language: typeof language === "string" && language.length > 0 ? language : "en" | |
| 159 | + }; | |
| 160 | +} | |
| 161 | + | |
| 162 | +function configurationUrl(baseUrl, configuration, language) { | |
| 163 | + var withoutFragment; | |
| 164 | + if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(configuration)) return null; | |
| 165 | + withoutFragment = baseUrl.split("#")[0]; | |
| 166 | + return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(configuration, language))); | |
| 167 | +} | |
| 168 | + | |
| 169 | +module.exports = { | |
| 170 | + STORAGE_KEY: STORAGE_KEY, | |
| 171 | + MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, | |
| 172 | + emptyConfiguration: emptyConfiguration, | |
| 173 | + isStoredConfiguration: isStoredConfiguration, | |
| 174 | + isConfigurationUpdate: isConfigurationUpdate, | |
| 175 | + isFavoriteSecretFree: isFavoriteSecretFree, | |
| 176 | + areFavoritesSecretFree: areFavoritesSecretFree, | |
| 177 | + normalizeLanguage: normalizeLanguage, | |
| 178 | + activeWatchLanguage: activeWatchLanguage, | |
| 179 | + loadConfiguration: loadConfiguration, | |
| 180 | + saveConfiguration: saveConfiguration, | |
| 181 | + applyConfigurationUpdate: applyConfigurationUpdate, | |
| 182 | + parseCloseFragment: parseCloseFragment, | |
| 183 | + configurationPageState: configurationPageState, | |
| 184 | + configurationUrl: configurationUrl | |
| 185 | +}; | |
| new file mode 100644 | |||
| @@ -0,0 +1,185 @@ | |||
| 1 | +"use strict"; | ||
| 2 | + | ||
| 3 | +var contracts = require("./contracts"); | ||
| 4 | +var STORAGE_KEY = "lapin-fute.configuration.v1"; | ||
| 5 | +var RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"]; | ||
| 6 | +var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"]; | ||
| 7 | +var MAX_CLOSE_RESPONSE_LENGTH = 32768; | ||
| 8 | +var FAVORITE_STRING_KEYS = [ | ||
| 9 | + "id", | ||
| 10 | + "serviceId", | ||
| 11 | + "displayName", | ||
| 12 | + "stopLabel", | ||
| 13 | + "lineLabel", | ||
| 14 | + "destinationLabel" | ||
| 15 | +]; | ||
| 16 | + | ||
| 17 | +function emptyConfiguration() { | ||
| 18 | + return { schemaVersion: contracts.SCHEMA_VERSION, favorites: [], apiKey: null }; | ||
| 19 | +} | ||
| 20 | +function isFavoriteSecretFree(favorite, firstSecret, secondSecret) { | ||
| 21 | + var field; | ||
| 22 | + var value; | ||
| 23 | + for (field = 0; field < FAVORITE_STRING_KEYS.length; field += 1) { | ||
| 24 | + value = favorite[FAVORITE_STRING_KEYS[field]]; | ||
| 25 | + if (typeof value !== "string") continue; | ||
| 26 | + if (typeof firstSecret === "string" && firstSecret.length > 0 && value.indexOf(firstSecret) !== -1) return false; | ||
| 27 | + if (typeof secondSecret === "string" && secondSecret.length > 0 && value.indexOf(secondSecret) !== -1) return false; | ||
| 28 | + } | ||
| 29 | + return true; | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +function areFavoritesSecretFree(favorites, firstSecret, secondSecret) { | ||
| 33 | + var index; | ||
| 34 | + for (index = 0; index < favorites.length; index += 1) { | ||
| 35 | + if (!isFavoriteSecretFree(favorites[index], firstSecret, secondSecret)) return false; | ||
| 36 | + } | ||
| 37 | + return true; | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +function normalizeLanguage(value) { | ||
| 41 | + if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN; | ||
| 42 | + return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR | ||
| 43 | + ? contracts.WIRE_LANGUAGE.FR | ||
| 44 | + : contracts.WIRE_LANGUAGE.EN; | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +function activeWatchLanguage(Pebble) { | ||
| 48 | + var info; | ||
| 49 | + try { | ||
| 50 | + if (!Pebble || typeof Pebble.getActiveWatchInfo !== "function") { | ||
| 51 | + return contracts.WIRE_LANGUAGE.EN; | ||
| 52 | + } | ||
| 53 | + info = Pebble.getActiveWatchInfo(); | ||
| 54 | + if (!contracts.isObject(info)) return contracts.WIRE_LANGUAGE.EN; | ||
| 55 | + return normalizeLanguage(info.language); | ||
| 56 | + } catch (ignored) { | ||
| 57 | + return contracts.WIRE_LANGUAGE.EN; | ||
| 58 | + } | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +function isStoredConfiguration(value) { | ||
| 63 | + return contracts.isObject(value) | ||
| 64 | + && contracts.hasOnlyKeys(value, RECORD_KEYS) | ||
| 65 | + && value.schemaVersion === contracts.SCHEMA_VERSION | ||
| 66 | + && contracts.isFavoriteList(value.favorites) | ||
| 67 | + && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey)) | ||
| 68 | + && areFavoritesSecretFree(value.favorites, value.apiKey, null); | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +function isConfigurationUpdate(value) { | ||
| 72 | + return contracts.isObject(value) | ||
| 73 | + && contracts.hasOnlyKeys(value, UPDATE_KEYS) | ||
| 74 | + && value.schemaVersion === contracts.SCHEMA_VERSION | ||
| 75 | + && contracts.isFavoriteList(value.favorites) | ||
| 76 | + && contracts.isApiKeyUpdate(value.apiKeyUpdate); | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +function copyConfiguration(configuration) { | ||
| 80 | + return { | ||
| 81 | + schemaVersion: contracts.SCHEMA_VERSION, | ||
| 82 | + favorites: configuration.favorites.map(contracts.copyFavorite), | ||
| 83 | + apiKey: configuration.apiKey | ||
| 84 | + }; | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +function loadConfiguration(storage) { | ||
| 88 | + var parsed; | ||
| 89 | + var serialized; | ||
| 90 | + try { | ||
| 91 | + serialized = storage.getItem(STORAGE_KEY); | ||
| 92 | + if (typeof serialized !== "string" || serialized.length === 0) return emptyConfiguration(); | ||
| 93 | + parsed = JSON.parse(serialized); | ||
| 94 | + } catch (ignored) { | ||
| 95 | + return emptyConfiguration(); | ||
| 96 | + } | ||
| 97 | + return isStoredConfiguration(parsed) ? copyConfiguration(parsed) : emptyConfiguration(); | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +function saveConfiguration(storage, configuration) { | ||
| 101 | + if (!isStoredConfiguration(configuration)) return false; | ||
| 102 | + try { | ||
| 103 | + storage.setItem(STORAGE_KEY, JSON.stringify(configuration)); | ||
| 104 | + return true; | ||
| 105 | + } catch (ignored) { | ||
| 106 | + return false; | ||
| 107 | + } | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +function applyConfigurationUpdate(current, update) { | ||
| 111 | + var apiKey; | ||
| 112 | + var replacementKey = null; | ||
| 113 | + if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null; | ||
| 114 | + if (update.apiKeyUpdate.action === "REPLACE") replacementKey = update.apiKeyUpdate.value; | ||
| 115 | + if (!areFavoritesSecretFree(update.favorites, current.apiKey, replacementKey)) return null; | ||
| 116 | + apiKey = current.apiKey; | ||
| 117 | + if (replacementKey !== null) apiKey = replacementKey; | ||
| 118 | + if (update.apiKeyUpdate.action === "REMOVE") apiKey = null; | ||
| 119 | + return { | ||
| 120 | + schemaVersion: contracts.SCHEMA_VERSION, | ||
| 121 | + favorites: update.favorites.map(contracts.copyFavorite), | ||
| 122 | + apiKey: apiKey | ||
| 123 | + }; | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +function parseCloseFragment(response) { | ||
| 127 | + var fragment; | ||
| 128 | + var hashIndex; | ||
| 129 | + var parsed; | ||
| 130 | + if (typeof response !== "string" | ||
| 131 | + || response.length === 0 | ||
| 132 | + || response.length > MAX_CLOSE_RESPONSE_LENGTH | ||
| 133 | + || response === "CANCELLED") return null; | ||
| 134 | + hashIndex = response.indexOf("#"); | ||
| 135 | + fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1); | ||
| 136 | + if (fragment.charAt(0) === "#") fragment = fragment.slice(1); | ||
| 137 | + if (fragment.length === 0 || fragment === "CANCELLED") return null; | ||
| 138 | + try { | ||
| 139 | + fragment = decodeURIComponent(fragment); | ||
| 140 | + parsed = JSON.parse(fragment); | ||
| 141 | + } catch (ignored) { | ||
| 142 | + return null; | ||
| 143 | + } | ||
| 144 | + if (!isConfigurationUpdate(parsed)) return null; | ||
| 145 | + return { | ||
| 146 | + schemaVersion: contracts.SCHEMA_VERSION, | ||
| 147 | + favorites: parsed.favorites.map(contracts.copyFavorite), | ||
| 148 | + apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" | ||
| 149 | + ? { schemaVersion: contracts.SCHEMA_VERSION, action: "REPLACE", value: parsed.apiKeyUpdate.value } | ||
| 150 | + : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action } | ||
| 151 | + }; | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +function configurationPageState(configuration, language) { | ||
| 155 | + return { | ||
| 156 | + hasKey: configuration.apiKey !== null, | ||
| 157 | + favorites: configuration.favorites.map(contracts.copyFavorite), | ||
| 158 | + language: typeof language === "string" && language.length > 0 ? language : "en" | ||
| 159 | + }; | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +function configurationUrl(baseUrl, configuration, language) { | ||
| 163 | + var withoutFragment; | ||
| 164 | + if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(configuration)) return null; | ||
| 165 | + withoutFragment = baseUrl.split("#")[0]; | ||
| 166 | + return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(configuration, language))); | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +module.exports = { | ||
| 170 | + STORAGE_KEY: STORAGE_KEY, | ||
| 171 | + MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, | ||
| 172 | + emptyConfiguration: emptyConfiguration, | ||
| 173 | + isStoredConfiguration: isStoredConfiguration, | ||
| 174 | + isConfigurationUpdate: isConfigurationUpdate, | ||
| 175 | + isFavoriteSecretFree: isFavoriteSecretFree, | ||
| 176 | + areFavoritesSecretFree: areFavoritesSecretFree, | ||
| 177 | + normalizeLanguage: normalizeLanguage, | ||
| 178 | + activeWatchLanguage: activeWatchLanguage, | ||
| 179 | + loadConfiguration: loadConfiguration, | ||
| 180 | + saveConfiguration: saveConfiguration, | ||
| 181 | + applyConfigurationUpdate: applyConfigurationUpdate, | ||
| 182 | + parseCloseFragment: parseCloseFragment, | ||
| 183 | + configurationPageState: configurationPageState, | ||
| 184 | + configurationUrl: configurationUrl | ||
| 185 | +}; | ||
added
packages/companion/src/contracts.js +322 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/index.js +412 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/src/message-queue.js +98 -0 | new file mode 100644 | ||
| @@ -0,0 +1,98 @@ | ||
| 1 | +"use strict"; | |
| 2 | + | |
| 3 | +function MessageQueue(Pebble, defer, onFailure) { | |
| 4 | + if (!Pebble || typeof Pebble.sendAppMessage !== "function") { | |
| 5 | + throw new TypeError("Pebble.sendAppMessage is required"); | |
| 6 | + } | |
| 7 | + if (typeof defer !== "function") throw new TypeError("defer adapter is required"); | |
| 8 | + this._Pebble = Pebble; | |
| 9 | + this._defer = defer; | |
| 10 | + this._onFailure = typeof onFailure === "function" ? onFailure : function () {}; | |
| 11 | + this._batches = []; | |
| 12 | + this._active = null; | |
| 13 | + this._sending = false; | |
| 14 | + this._deferredGeneration = null; | |
| 15 | + this._generation = 0; | |
| 16 | +} | |
| 17 | + | |
| 18 | +MessageQueue.prototype.enqueue = function (messages) { | |
| 19 | + if (!Array.isArray(messages) || messages.length === 0) { | |
| 20 | + throw new TypeError("A non-empty message sequence is required"); | |
| 21 | + } | |
| 22 | + this._batches.push(messages.slice()); | |
| 23 | + this._advance(); | |
| 24 | +}; | |
| 25 | + | |
| 26 | +MessageQueue.prototype.clear = function () { | |
| 27 | + this._generation += 1; | |
| 28 | + this._batches = []; | |
| 29 | + this._active = null; | |
| 30 | + this._deferredGeneration = null; | |
| 31 | +}; | |
| 32 | + | |
| 33 | +MessageQueue.prototype.isSending = function () { | |
| 34 | + return this._sending; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +MessageQueue.prototype._scheduleAdvance = function (generation) { | |
| 38 | + var self = this; | |
| 39 | + if (this._deferredGeneration !== null) return; | |
| 40 | + this._deferredGeneration = generation; | |
| 41 | + try { | |
| 42 | + this._defer(function () { | |
| 43 | + if (self._deferredGeneration !== generation) return; | |
| 44 | + self._deferredGeneration = null; | |
| 45 | + if (generation !== self._generation) return; | |
| 46 | + self._advance(); | |
| 47 | + }); | |
| 48 | + } catch (ignored) { | |
| 49 | + this._deferredGeneration = null; | |
| 50 | + this.clear(); | |
| 51 | + this._onFailure("APP_MESSAGE_FAILED"); | |
| 52 | + } | |
| 53 | +}; | |
| 54 | + | |
| 55 | +MessageQueue.prototype._advance = function () { | |
| 56 | + var self = this; | |
| 57 | + var message; | |
| 58 | + var generation; | |
| 59 | + if (this._sending || this._deferredGeneration !== null) return; | |
| 60 | + if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null; | |
| 61 | + if (!this._active) return; | |
| 62 | + | |
| 63 | + message = this._active[0]; | |
| 64 | + this._sending = true; | |
| 65 | + generation = this._generation; | |
| 66 | + try { | |
| 67 | + this._Pebble.sendAppMessage(message, function () { | |
| 68 | + self._sending = false; | |
| 69 | + if (generation !== self._generation) { | |
| 70 | + if (self._active !== null || self._batches.length > 0) { | |
| 71 | + self._scheduleAdvance(self._generation); | |
| 72 | + } | |
| 73 | + return; | |
| 74 | + } | |
| 75 | + self._active.shift(); | |
| 76 | + if (self._active.length === 0) self._active = null; | |
| 77 | + if (self._active !== null || self._batches.length > 0) { | |
| 78 | + self._scheduleAdvance(generation); | |
| 79 | + } | |
| 80 | + }, function () { | |
| 81 | + self._sending = false; | |
| 82 | + if (generation !== self._generation) { | |
| 83 | + if (self._active !== null || self._batches.length > 0) { | |
| 84 | + self._scheduleAdvance(self._generation); | |
| 85 | + } | |
| 86 | + return; | |
| 87 | + } | |
| 88 | + self.clear(); | |
| 89 | + self._onFailure("APP_MESSAGE_FAILED"); | |
| 90 | + }); | |
| 91 | + } catch (ignored) { | |
| 92 | + this._sending = false; | |
| 93 | + this.clear(); | |
| 94 | + this._onFailure("APP_MESSAGE_FAILED"); | |
| 95 | + } | |
| 96 | +}; | |
| 97 | + | |
| 98 | +module.exports = MessageQueue; | |
| new file mode 100644 | |||
| @@ -0,0 +1,98 @@ | |||
| 1 | +"use strict"; | ||
| 2 | + | ||
| 3 | +function MessageQueue(Pebble, defer, onFailure) { | ||
| 4 | + if (!Pebble || typeof Pebble.sendAppMessage !== "function") { | ||
| 5 | + throw new TypeError("Pebble.sendAppMessage is required"); | ||
| 6 | + } | ||
| 7 | + if (typeof defer !== "function") throw new TypeError("defer adapter is required"); | ||
| 8 | + this._Pebble = Pebble; | ||
| 9 | + this._defer = defer; | ||
| 10 | + this._onFailure = typeof onFailure === "function" ? onFailure : function () {}; | ||
| 11 | + this._batches = []; | ||
| 12 | + this._active = null; | ||
| 13 | + this._sending = false; | ||
| 14 | + this._deferredGeneration = null; | ||
| 15 | + this._generation = 0; | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +MessageQueue.prototype.enqueue = function (messages) { | ||
| 19 | + if (!Array.isArray(messages) || messages.length === 0) { | ||
| 20 | + throw new TypeError("A non-empty message sequence is required"); | ||
| 21 | + } | ||
| 22 | + this._batches.push(messages.slice()); | ||
| 23 | + this._advance(); | ||
| 24 | +}; | ||
| 25 | + | ||
| 26 | +MessageQueue.prototype.clear = function () { | ||
| 27 | + this._generation += 1; | ||
| 28 | + this._batches = []; | ||
| 29 | + this._active = null; | ||
| 30 | + this._deferredGeneration = null; | ||
| 31 | +}; | ||
| 32 | + | ||
| 33 | +MessageQueue.prototype.isSending = function () { | ||
| 34 | + return this._sending; | ||
| 35 | +}; | ||
| 36 | + | ||
| 37 | +MessageQueue.prototype._scheduleAdvance = function (generation) { | ||
| 38 | + var self = this; | ||
| 39 | + if (this._deferredGeneration !== null) return; | ||
| 40 | + this._deferredGeneration = generation; | ||
| 41 | + try { | ||
| 42 | + this._defer(function () { | ||
| 43 | + if (self._deferredGeneration !== generation) return; | ||
| 44 | + self._deferredGeneration = null; | ||
| 45 | + if (generation !== self._generation) return; | ||
| 46 | + self._advance(); | ||
| 47 | + }); | ||
| 48 | + } catch (ignored) { | ||
| 49 | + this._deferredGeneration = null; | ||
| 50 | + this.clear(); | ||
| 51 | + this._onFailure("APP_MESSAGE_FAILED"); | ||
| 52 | + } | ||
| 53 | +}; | ||
| 54 | + | ||
| 55 | +MessageQueue.prototype._advance = function () { | ||
| 56 | + var self = this; | ||
| 57 | + var message; | ||
| 58 | + var generation; | ||
| 59 | + if (this._sending || this._deferredGeneration !== null) return; | ||
| 60 | + if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null; | ||
| 61 | + if (!this._active) return; | ||
| 62 | + | ||
| 63 | + message = this._active[0]; | ||
| 64 | + this._sending = true; | ||
| 65 | + generation = this._generation; | ||
| 66 | + try { | ||
| 67 | + this._Pebble.sendAppMessage(message, function () { | ||
| 68 | + self._sending = false; | ||
| 69 | + if (generation !== self._generation) { | ||
| 70 | + if (self._active !== null || self._batches.length > 0) { | ||
| 71 | + self._scheduleAdvance(self._generation); | ||
| 72 | + } | ||
| 73 | + return; | ||
| 74 | + } | ||
| 75 | + self._active.shift(); | ||
| 76 | + if (self._active.length === 0) self._active = null; | ||
| 77 | + if (self._active !== null || self._batches.length > 0) { | ||
| 78 | + self._scheduleAdvance(generation); | ||
| 79 | + } | ||
| 80 | + }, function () { | ||
| 81 | + self._sending = false; | ||
| 82 | + if (generation !== self._generation) { | ||
| 83 | + if (self._active !== null || self._batches.length > 0) { | ||
| 84 | + self._scheduleAdvance(self._generation); | ||
| 85 | + } | ||
| 86 | + return; | ||
| 87 | + } | ||
| 88 | + self.clear(); | ||
| 89 | + self._onFailure("APP_MESSAGE_FAILED"); | ||
| 90 | + }); | ||
| 91 | + } catch (ignored) { | ||
| 92 | + this._sending = false; | ||
| 93 | + this.clear(); | ||
| 94 | + this._onFailure("APP_MESSAGE_FAILED"); | ||
| 95 | + } | ||
| 96 | +}; | ||
| 97 | + | ||
| 98 | +module.exports = MessageQueue; | ||
added
packages/companion/test/companion.test.js +503 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/test/fakes.js +186 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/companion/test/protocol.test.js +181 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/config-page/index.html +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="en"> | |
| 3 | + <head> | |
| 4 | + <meta charset="utf-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + <meta name="referrer" content="no-referrer"> | |
| 7 | + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'"> | |
| 8 | + <title>Lapin Futé</title> | |
| 9 | + <link rel="stylesheet" href="./styles/config-page.css"> | |
| 10 | + </head> | |
| 11 | + <body> | |
| 12 | + <main> | |
| 13 | + <h1 id="page-title">Lapin Futé</h1> | |
| 14 | + | |
| 15 | + <section aria-labelledby="key-title"> | |
| 16 | + <h2 id="key-title">API key</h2> | |
| 17 | + <p class="status" id="key-status"></p> | |
| 18 | + <label class="field-label" for="key-input" id="key-label"></label> | |
| 19 | + <div class="key-row"> | |
| 20 | + <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false"> | |
| 21 | + <button type="button" id="key-toggle" aria-pressed="false">Show</button> | |
| 22 | + </div> | |
| 23 | + <p class="error" id="key-error" role="alert" hidden></p> | |
| 24 | + <p class="pending" id="key-pending" hidden></p> | |
| 25 | + <button type="button" id="key-remove" class="danger" hidden>Remove key</button> | |
| 26 | + </section> | |
| 27 | + | |
| 28 | + <section aria-labelledby="favorites-title"> | |
| 29 | + <h2 id="favorites-title">Favorites</h2> | |
| 30 | + <p class="status" id="favorites-empty" hidden></p> | |
| 31 | + <ul id="favorites-list"></ul> | |
| 32 | + </section> | |
| 33 | + | |
| 34 | + <button type="button" id="save" class="primary">Save</button> | |
| 35 | + </main> | |
| 36 | + <noscript><p class="status">This page requires JavaScript.</p></noscript> | |
| 37 | + <script type="module" src="./src/config-page.js"></script> | |
| 38 | + </body> | |
| 39 | +</html> | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +<!doctype html> | ||
| 2 | +<html lang="en"> | ||
| 3 | + <head> | ||
| 4 | + <meta charset="utf-8"> | ||
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | + <meta name="referrer" content="no-referrer"> | ||
| 7 | + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'"> | ||
| 8 | + <title>Lapin Futé</title> | ||
| 9 | + <link rel="stylesheet" href="./styles/config-page.css"> | ||
| 10 | + </head> | ||
| 11 | + <body> | ||
| 12 | + <main> | ||
| 13 | + <h1 id="page-title">Lapin Futé</h1> | ||
| 14 | + | ||
| 15 | + <section aria-labelledby="key-title"> | ||
| 16 | + <h2 id="key-title">API key</h2> | ||
| 17 | + <p class="status" id="key-status"></p> | ||
| 18 | + <label class="field-label" for="key-input" id="key-label"></label> | ||
| 19 | + <div class="key-row"> | ||
| 20 | + <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false"> | ||
| 21 | + <button type="button" id="key-toggle" aria-pressed="false">Show</button> | ||
| 22 | + </div> | ||
| 23 | + <p class="error" id="key-error" role="alert" hidden></p> | ||
| 24 | + <p class="pending" id="key-pending" hidden></p> | ||
| 25 | + <button type="button" id="key-remove" class="danger" hidden>Remove key</button> | ||
| 26 | + </section> | ||
| 27 | + | ||
| 28 | + <section aria-labelledby="favorites-title"> | ||
| 29 | + <h2 id="favorites-title">Favorites</h2> | ||
| 30 | + <p class="status" id="favorites-empty" hidden></p> | ||
| 31 | + <ul id="favorites-list"></ul> | ||
| 32 | + </section> | ||
| 33 | + | ||
| 34 | + <button type="button" id="save" class="primary">Save</button> | ||
| 35 | + </main> | ||
| 36 | + <noscript><p class="status">This page requires JavaScript.</p></noscript> | ||
| 37 | + <script type="module" src="./src/config-page.js"></script> | ||
| 38 | + </body> | ||
| 39 | +</html> | ||
added
packages/config-page/package.json +9 -0 | new file mode 100644 | ||
| @@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@lapin-fute/config-page", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "test": "node --test test/config-page.test.ts" | |
| 8 | + } | |
| 9 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | +{ | ||
| 2 | + "name": "@lapin-fute/config-page", | ||
| 3 | + "version": "0.1.0", | ||
| 4 | + "private": true, | ||
| 5 | + "type": "module", | ||
| 6 | + "scripts": { | ||
| 7 | + "test": "node --test test/config-page.test.ts" | ||
| 8 | + } | ||
| 9 | +} | ||
added
packages/config-page/src/config-core.js +293 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/config-page/src/config-page.js +177 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/config-page/styles/config-page.css +181 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/config-page/test/config-page.test.ts +377 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/contracts/src/index.ts +579 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/contracts/test/contracts.test.ts +221 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/.lock-waf_linux_build +10 -0 | new file mode 100644 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] | |
| 2 | +config_cmd = 'configure' | |
| 3 | +environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} | |
| 4 | +files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript'] | |
| 5 | +hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' | |
| 6 | +launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | |
| 7 | +options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None} | |
| 8 | +out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build' | |
| 9 | +run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | |
| 10 | +top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | |
| new file mode 100644 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure'] | ||
| 2 | +config_cmd = 'configure' | ||
| 3 | +environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'} | ||
| 4 | +files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript'] | ||
| 5 | +hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%' | ||
| 6 | +launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | ||
| 7 | +options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None} | ||
| 8 | +out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build' | ||
| 9 | +run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | ||
| 10 | +top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch' | ||
added
packages/watch/package.json +58 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/c/mdbl.c +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +#include <pebble.h> | |
| 2 | + | |
| 3 | +#define XS_STACK_BYTES (4U * 1024U) | |
| 4 | +#define XS_SLOT_BYTES (32U * 1024U) | |
| 5 | +#define XS_CHUNK_BYTES (20U * 1024U) | |
| 6 | +#define XS_ARENA_TOTAL_BYTES \ | |
| 7 | + (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES) | |
| 8 | +#define XS_ARENA_BUDGET_BYTES (56U * 1024U) | |
| 9 | + | |
| 10 | +#ifdef PBL_DEBUG | |
| 11 | +#define XS_CREATION_FLAGS \ | |
| 12 | + (kModdableCreationFlagDebug | kModdableCreationFlagLogInstrumentation) | |
| 13 | +#else | |
| 14 | +#define XS_CREATION_FLAGS 0U | |
| 15 | +#endif | |
| 16 | + | |
| 17 | +#if (XS_STACK_BYTES == 0U) || (XS_SLOT_BYTES == 0U) || \ | |
| 18 | + (XS_CHUNK_BYTES == 0U) | |
| 19 | +#error "XS stack, slot, and chunk sizes must all be nonzero" | |
| 20 | +#endif | |
| 21 | + | |
| 22 | +#if XS_ARENA_TOTAL_BYTES > XS_ARENA_BUDGET_BYTES | |
| 23 | +#error "XS arenas exceed the 56 KiB RAM budget" | |
| 24 | +#endif | |
| 25 | + | |
| 26 | +int main(void) { | |
| 27 | + Window *window = window_create(); | |
| 28 | + window_stack_push(window, true); | |
| 29 | + | |
| 30 | + ModdableCreationRecord creation = { | |
| 31 | + .recordSize = sizeof(creation), | |
| 32 | + .stack = XS_STACK_BYTES, | |
| 33 | + .slot = XS_SLOT_BYTES, | |
| 34 | + .chunk = XS_CHUNK_BYTES, | |
| 35 | + .flags = XS_CREATION_FLAGS, | |
| 36 | + }; | |
| 37 | + moddable_createMachine(&creation); | |
| 38 | + | |
| 39 | + window_destroy(window); | |
| 40 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +#include <pebble.h> | ||
| 2 | + | ||
| 3 | +#define XS_STACK_BYTES (4U * 1024U) | ||
| 4 | +#define XS_SLOT_BYTES (32U * 1024U) | ||
| 5 | +#define XS_CHUNK_BYTES (20U * 1024U) | ||
| 6 | +#define XS_ARENA_TOTAL_BYTES \ | ||
| 7 | + (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES) | ||
| 8 | +#define XS_ARENA_BUDGET_BYTES (56U * 1024U) | ||
| 9 | + | ||
| 10 | +#ifdef PBL_DEBUG | ||
| 11 | +#define XS_CREATION_FLAGS \ | ||
| 12 | + (kModdableCreationFlagDebug | kModdableCreationFlagLogInstrumentation) | ||
| 13 | +#else | ||
| 14 | +#define XS_CREATION_FLAGS 0U | ||
| 15 | +#endif | ||
| 16 | + | ||
| 17 | +#if (XS_STACK_BYTES == 0U) || (XS_SLOT_BYTES == 0U) || \ | ||
| 18 | + (XS_CHUNK_BYTES == 0U) | ||
| 19 | +#error "XS stack, slot, and chunk sizes must all be nonzero" | ||
| 20 | +#endif | ||
| 21 | + | ||
| 22 | +#if XS_ARENA_TOTAL_BYTES > XS_ARENA_BUDGET_BYTES | ||
| 23 | +#error "XS arenas exceed the 56 KiB RAM budget" | ||
| 24 | +#endif | ||
| 25 | + | ||
| 26 | +int main(void) { | ||
| 27 | + Window *window = window_create(); | ||
| 28 | + window_stack_push(window, true); | ||
| 29 | + | ||
| 30 | + ModdableCreationRecord creation = { | ||
| 31 | + .recordSize = sizeof(creation), | ||
| 32 | + .stack = XS_STACK_BYTES, | ||
| 33 | + .slot = XS_SLOT_BYTES, | ||
| 34 | + .chunk = XS_CHUNK_BYTES, | ||
| 35 | + .flags = XS_CREATION_FLAGS, | ||
| 36 | + }; | ||
| 37 | + moddable_createMachine(&creation); | ||
| 38 | + | ||
| 39 | + window_destroy(window); | ||
| 40 | +} | ||
added
packages/watch/src/embeddedjs/contracts.js +169 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/controller.js +101 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/localization.js +44 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/main.js +62 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/manifest.json +18 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/message-queue.js +96 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/model.js +95 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/package.json +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +{ | |
| 2 | + "type": "module" | |
| 3 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +{ | ||
| 2 | + "type": "module" | ||
| 3 | +} | ||
added
packages/watch/src/embeddedjs/protocol.js +456 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/src/embeddedjs/ui.js +132 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/fixture-boot.test.js +102 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/localization.test.js +43 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/manifest.test.js +174 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/message-queue.test.js +118 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/model.test.js +177 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/test/package.json +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +{ | |
| 2 | + "type": "module" | |
| 3 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +{ | ||
| 2 | + "type": "module" | ||
| 3 | +} | ||
added
packages/watch/test/protocol.test.js +254 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
packages/watch/wscript +54 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/build.mjs +70 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/measure-radio.mjs +48 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/test.mjs +22 -0 Large diff collapsed to keep the page fast — load it when you need it.
added
scripts/verify-watch-build.mjs +42 -0 Large diff collapsed to keep the page fast — load it when you need it.