anthodev/lapin-futepublic Fork
⑂ develop
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: establish Alloy app foundation #10

Merged@github-anthodev wants to merge feature/application-contracts/foundation/2 into develop
Merged 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.