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: persist and synchronize favorites #14

Merged@github-anthodev wants to merge feature/sync-favorites/companion/6 into develop
Merged as 5ff79d1dc5aa.
22 files changed
modified packages/companion/src/configuration.js +362 -33
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/contracts.js +1 -1
@@ -170,7 +170,7 @@ function boundedString(value, maximum) {
170170 var size;
171171 if (typeof value !== "string") return false;
172172 size = utf8Bytes(value);
173- return size >= 1 && size <= maximum;
173+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
174174 }
175175
176176 function isLineColor(value) {
@@ -170,7 +170,7 @@ function boundedString(value, maximum) {
170 var size;170 var size;
171 if (typeof value !== "string") return false;171 if (typeof value !== "string") return false;
172 size = utf8Bytes(value);172 size = utf8Bytes(value);
173- return size >= 1 && size <= maximum;173+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
174 }174 }
175 175
176 function isLineColor(value) {176 function isLineColor(value) {
modified packages/companion/src/index.js +170 -45
@@ -50,7 +50,10 @@ function isFixture(value) {
5050 function Companion(options) {
5151 options = options || {};
5252 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
53- requiredAdapter(options.storage && typeof options.storage.getItem === "function" && typeof options.storage.setItem === "function", "storage adapter is required");
53+ requiredAdapter(options.storage
54+ && typeof options.storage.getItem === "function"
55+ && typeof options.storage.setItem === "function"
56+ && typeof options.storage.removeItem === "function", "storage adapter is required");
5457 requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");
5558 requiredAdapter(options.clock
5659 && typeof options.clock.now === "function", "clock adapter is required");
@@ -68,9 +71,21 @@ function Companion(options) {
6871 this._fixture = options.fixture || null;
6972 this._fixtureEnabled = false;
7073 this._configuration = configuration.loadConfiguration(this._storage);
74+ this._configurationValid = this._configuration !== null;
75+ this._credentialBlocked = this._configurationValid
76+ && this._configuration.keyStatus === contracts.KEY_STATUS.INVALID;
77+ if (!this._configurationValid) this._configuration = configuration.emptyConfiguration();
7178 this._watchFavorites = [];
72- this._keyStatus = contracts.KEY_STATUS.MISSING;
73- this._cache = Object.create(null);
79+ this._results = configuration.emptyResults();
80+ this._metrics = {
81+ requests: 0,
82+ cacheHits: 0,
83+ cacheMisses: 0,
84+ successes: 0,
85+ failures: 0,
86+ totalLatencyMs: 0,
87+ lastLatencyMs: 0
88+ };
7489 this._requestGenerations = Object.create(null);
7590 this._inFlightRequests = Object.create(null);
7691 this._requestGeneration = 0;
@@ -126,9 +141,6 @@ Companion.prototype._refreshWatchState = function () {
126141 var configured = this._configuration.favorites.map(contracts.copyFavorite);
127142 this._fixtureEnabled = this._fixture !== null && configured.length === 0;
128143 this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
129- this._keyStatus = this._fixtureEnabled || this._configuration.apiKey !== null
130- ? contracts.KEY_STATUS.CONFIGURED
131- : contracts.KEY_STATUS.MISSING;
132144 };
133145
134146 Companion.prototype._nextSequenceId = function () {
@@ -138,43 +150,104 @@ Companion.prototype._nextSequenceId = function () {
138150 };
139151
140152 Companion.prototype._sendConfiguration = function () {
141- if (!configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.apiKey, null)) return;
153+ var keyStatus = this._fixtureEnabled
154+ ? contracts.KEY_STATUS.CONFIGURED
155+ : (this._credentialBlocked
156+ ? contracts.KEY_STATUS.INVALID
157+ : this._configuration.keyStatus);
158+ if (!this._configurationValid
159+ || !configuration.areFavoritesSecretFree(
160+ this._watchFavorites,
161+ this._configuration.primApiKey,
162+ null
163+ )) return;
142164 this._queue.enqueue(codec.encodeConfiguration(
143165 this._nextSequenceId(),
144166 this._watchFavorites,
145- this._keyStatus,
167+ keyStatus,
146168 configuration.activeWatchLanguage(this._Pebble)
147169 ));
148170 };
149171
150172 Companion.prototype._onReady = function () {
151- this._configuration = configuration.loadConfiguration(this._storage);
173+ var wasBlocked = this._credentialBlocked;
174+ var loaded = configuration.loadConfiguration(this._storage);
175+ if (loaded === null) {
176+ this._configurationValid = false;
177+ return;
178+ }
179+ if (wasBlocked && loaded.primApiKey !== null) {
180+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
181+ }
182+ this._configuration = loaded;
183+ this._configurationValid = true;
184+ this._credentialBlocked = loaded.keyStatus === contracts.KEY_STATUS.INVALID;
185+ this._results = configuration.loadResults(this._storage, loaded.favorites);
152186 this._refreshWatchState();
153187 this._sendConfiguration();
154188 };
155189
156190 Companion.prototype._onShowConfiguration = function () {
157- var language = configuration.activeWatchLanguage(this._Pebble);
158- var url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
191+ var language;
192+ var url;
193+ if (!this._configurationValid) return;
194+ language = configuration.activeWatchLanguage(this._Pebble);
195+ url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
159196 if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);
160197 };
161198
162199 Companion.prototype._onWebviewClosed = function (event) {
163- var update = configuration.parseCloseFragment(event && event.response);
200+ var update;
164201 var next;
202+ var pending;
203+ var pruned;
204+ if (!this._configurationValid) return;
205+ update = configuration.parseCloseFragment(event && event.response);
165206 if (update === null) return;
166207 next = configuration.applyConfigurationUpdate(this._configuration, update);
167- if (next === null || !configuration.saveConfiguration(this._storage, next)) return;
208+ if (next === null) return;
209+ if (update.apiKeyUpdate.action === "REPLACE") {
210+ if (this._credentialBlocked
211+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
212+ pending = {
213+ schemaVersion: contracts.SCHEMA_VERSION,
214+ favorites: next.favorites.map(contracts.copyFavorite),
215+ primApiKey: next.primApiKey,
216+ keyStatus: contracts.KEY_STATUS.INVALID
217+ };
218+ if (!configuration.saveConfiguration(this._storage, pending)) return;
219+ if (!configuration.clearInvalidKeyStatus(this._storage)
220+ || !configuration.saveConfiguration(this._storage, next)) {
221+ next = pending;
222+ }
223+ } else if (!configuration.clearInvalidKeyStatus(this._storage)
224+ || !configuration.saveConfiguration(this._storage, next)) {
225+ return;
226+ }
227+ } else {
228+ if (!configuration.saveConfiguration(this._storage, next)) return;
229+ if (update.apiKeyUpdate.action === "REMOVE") {
230+ configuration.clearInvalidKeyStatus(this._storage);
231+ }
232+ }
233+ pruned = configuration.pruneResults(this._results, next.favorites);
234+ if (JSON.stringify(pruned) !== JSON.stringify(this._results)) {
235+ configuration.saveResults(this._storage, pruned);
236+ }
168237 this._configuration = next;
169- this._cache = Object.create(null);
238+ this._results = pruned;
170239 this._requestGenerations = Object.create(null);
240+ this._credentialBlocked = next.primApiKey !== null
241+ && next.keyStatus === contracts.KEY_STATUS.INVALID;
171242 this._refreshWatchState();
172243 this._sendConfiguration();
173244 };
174245
175246 Companion.prototype._onAppMessage = function (event) {
176- var request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
177- if (request === null || request.trigger !== contracts.REQUEST_TRIGGER.APP_OPEN) return;
247+ var request;
248+ if (!this._configurationValid) return;
249+ request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
250+ if (request === null) return;
178251 if (this._findFavorite(request.favoriteId) === null) {
179252 this._sendError(request, "INVALID_SERVICE");
180253 return;
@@ -191,20 +264,22 @@ Companion.prototype._findFavorite = function (favoriteId) {
191264 };
192265
193266 Companion.prototype._cacheResult = function (result) {
194- this._cache[result.favoriteId] = {
195- storedAt: this._clock.now(),
196- result: contracts.copyDepartureResult(result)
197- };
267+ var next = configuration.putResult(
268+ this._results,
269+ this._configuration.favorites,
270+ result,
271+ Math.floor(this._clock.now())
272+ );
273+ if (next === null) return false;
274+ if (!configuration.saveResults(this._storage, next)) return false;
275+ this._results = next;
276+ return true;
198277 };
199278
200279 Companion.prototype._cachedResult = function (request) {
201- var cached = this._cache[request.favoriteId];
202- var age;
203- if (!cached) return null;
204- age = this._clock.now() - cached.storedAt;
205- if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) return null;
206- return contracts.copyDepartureResult(cached.result, request.requestId);
280+ return configuration.findResult(this._results, request.favoriteId, request.requestId);
207281 };
282+
208283 Companion.prototype._beginRequest = function (favoriteId) {
209284 this._requestGeneration += 1;
210285 this._requestGenerations[favoriteId] = this._requestGeneration;
@@ -215,27 +290,36 @@ Companion.prototype._isLatestRequest = function (favoriteId, generation) {
215290 return this._requestGenerations[favoriteId] === generation;
216291 };
217292
218-
219293 Companion.prototype._dispatchRequest = function (request) {
220- var generation = this._beginRequest(request.favoriteId);
221294 var cached = this._cachedResult(request);
295+ var cachedMessages;
222296 var fixtureResult;
223297 var favorite;
298+ var generation;
299+ var age;
300+ var refreshRequired;
224301 if (cached !== null) {
225- this._queue.enqueue(codec.encodeResult(cached));
226- return;
302+ this._metrics.cacheHits += 1;
303+ age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt);
304+ refreshRequired = !(age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000);
305+ cachedMessages = codec.encodeResult(cached.result);
306+ if (refreshRequired) cachedMessages.push(codec.encodeRequest(request));
307+ this._queue.enqueue(cachedMessages);
308+ if (!refreshRequired) return;
309+ } else {
310+ this._metrics.cacheMisses += 1;
227311 }
228312 if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
229313 fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
230- this._cacheResult(fixtureResult);
231314 this._queue.enqueue(codec.encodeResult(fixtureResult));
232315 return;
233316 }
234- if (this._configuration.apiKey === null) {
317+ if (this._configuration.primApiKey === null) {
235318 this._sendError(request, "API_KEY_REQUIRED");
236319 return;
237320 }
238- if (this._keyStatus === contracts.KEY_STATUS.INVALID) {
321+ if (this._credentialBlocked
322+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
239323 this._sendError(request, "API_KEY_INVALID");
240324 return;
241325 }
@@ -244,6 +328,7 @@ Companion.prototype._dispatchRequest = function (request) {
244328 this._sendError(request, "INVALID_SERVICE");
245329 return;
246330 }
331+ generation = this._beginRequest(request.favoriteId);
247332 this._fetch(request, favorite, generation);
248333 };
249334
@@ -251,8 +336,8 @@ Companion.prototype._fetch = function (request, favorite, generation) {
251336 var self = this;
252337 var xhr;
253338 var settled = false;
254- var key = this._configuration.apiKey;
255- if (!configuration.isFavoriteSecretFree(favorite, key, null)) return;
339+ var key = null;
340+ var startedAt = this._clock.now();
256341 if (this._backendUrl.indexOf("https://") !== 0) {
257342 this._sendError(request, "SOURCE_UNAVAILABLE");
258343 return;
@@ -260,8 +345,12 @@ Companion.prototype._fetch = function (request, favorite, generation) {
260345
261346 function once(callback) {
262347 return function () {
348+ var latency;
263349 if (settled) return;
264350 settled = true;
351+ latency = Math.max(0, self._clock.now() - startedAt);
352+ self._metrics.lastLatencyMs = latency;
353+ self._metrics.totalLatencyMs += latency;
265354 if (self._inFlightRequests[generation] === xhr) {
266355 delete self._inFlightRequests[generation];
267356 }
@@ -274,17 +363,24 @@ Companion.prototype._fetch = function (request, favorite, generation) {
274363 xhr = new this._XHR();
275364 xhr.open("POST", this._backendUrl, true);
276365 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
277- xhr.setRequestHeader("Authorization", "Bearer " + key);
278366 xhr.setRequestHeader("Content-Type", "application/json");
279367 xhr.setRequestHeader("Accept", "application/json");
280368 xhr.onload = once(function () { self._handleResponse(request, xhr); });
281- xhr.onerror = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
282- xhr.ontimeout = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
283- xhr.onabort = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
284- if (!this._isLatestRequest(request.favoriteId, generation)) {
369+ xhr.onerror = once(function () {
370+ self._metrics.failures += 1;
371+ self._sendError(request, "SOURCE_UNAVAILABLE");
372+ });
373+ xhr.ontimeout = xhr.onerror;
374+ xhr.onabort = xhr.onerror;
375+ if (!this._isLatestRequest(request.favoriteId, generation)) return;
376+ key = this._configuration.primApiKey;
377+ if (!contracts.isPersonalApiKey(key)) {
285378 key = null;
379+ this._sendError(request, "API_KEY_REQUIRED");
286380 return;
287381 }
382+ xhr.setRequestHeader("Authorization", "Bearer " + key);
383+ this._metrics.requests += 1;
288384 this._inFlightRequests[generation] = xhr;
289385 xhr.send(JSON.stringify({
290386 schemaVersion: contracts.SCHEMA_VERSION,
@@ -300,6 +396,7 @@ Companion.prototype._fetch = function (request, favorite, generation) {
300396 }
301397 if (!settled) {
302398 settled = true;
399+ this._metrics.failures += 1;
303400 if (this._isLatestRequest(request.favoriteId, generation)) {
304401 this._sendError(request, "SOURCE_UNAVAILABLE");
305402 }
@@ -326,14 +423,21 @@ Companion.prototype._handleResponse = function (request, xhr) {
326423 if (!contracts.isDepartureResult(body)
327424 || body.requestId !== request.requestId
328425 || body.favoriteId !== request.favoriteId) {
426+ this._metrics.failures += 1;
329427 this._sendError(request, "INVALID_RESPONSE");
330428 return;
331429 }
430+ this._metrics.successes += 1;
332431 this._cacheResult(body);
333432 this._queue.enqueue(codec.encodeResult(body));
334433 return;
335434 }
336435
436+ this._metrics.failures += 1;
437+ if (status === 401 || status === 403) {
438+ this._sendError(request, "API_KEY_INVALID");
439+ return;
440+ }
337441 if (body !== null && contracts.isErrorResult(body)) {
338442 if (body.requestId !== request.requestId
339443 || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {
@@ -390,12 +494,33 @@ Companion.prototype._sendError = function (request, code, retryAfterSeconds) {
390494 };
391495
392496 Companion.prototype._sendErrorResult = function (error) {
497+ var invalid;
393498 this._queue.enqueue([codec.encodeError(error)]);
394- if (error.code === "API_KEY_INVALID") {
395- this._keyStatus = contracts.KEY_STATUS.INVALID;
396- this._cache = Object.create(null);
397- this._sendConfiguration();
398- }
499+ if (error.code !== "API_KEY_INVALID"
500+ || this._configuration.primApiKey === null
501+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return;
502+ invalid = {
503+ schemaVersion: contracts.SCHEMA_VERSION,
504+ favorites: this._configuration.favorites.map(contracts.copyFavorite),
505+ primApiKey: this._configuration.primApiKey,
506+ keyStatus: contracts.KEY_STATUS.INVALID
507+ };
508+ configuration.saveInvalidConfiguration(this._storage, invalid);
509+ this._configuration = invalid;
510+ this._credentialBlocked = true;
511+ this._sendConfiguration();
512+};
513+
514+Companion.prototype.metrics = function () {
515+ return {
516+ requests: this._metrics.requests,
517+ cacheHits: this._metrics.cacheHits,
518+ cacheMisses: this._metrics.cacheMisses,
519+ successes: this._metrics.successes,
520+ failures: this._metrics.failures,
521+ totalLatencyMs: this._metrics.totalLatencyMs,
522+ lastLatencyMs: this._metrics.lastLatencyMs
523+ };
399524 };
400525
401526 function createCompanion(options) {
@@ -50,7 +50,10 @@ function isFixture(value) {
50 function Companion(options) {50 function Companion(options) {
51 options = options || {};51 options = options || {};
52 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");52 requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
53- requiredAdapter(options.storage && typeof options.storage.getItem === "function" && typeof options.storage.setItem === "function", "storage adapter is required");53+ requiredAdapter(options.storage
54+ && typeof options.storage.getItem === "function"
55+ && typeof options.storage.setItem === "function"
56+ && typeof options.storage.removeItem === "function", "storage adapter is required");
54 requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");57 requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");
55 requiredAdapter(options.clock58 requiredAdapter(options.clock
56 && typeof options.clock.now === "function", "clock adapter is required");59 && typeof options.clock.now === "function", "clock adapter is required");
@@ -68,9 +71,21 @@ function Companion(options) {
68 this._fixture = options.fixture || null;71 this._fixture = options.fixture || null;
69 this._fixtureEnabled = false;72 this._fixtureEnabled = false;
70 this._configuration = configuration.loadConfiguration(this._storage);73 this._configuration = configuration.loadConfiguration(this._storage);
74+ this._configurationValid = this._configuration !== null;
75+ this._credentialBlocked = this._configurationValid
76+ && this._configuration.keyStatus === contracts.KEY_STATUS.INVALID;
77+ if (!this._configurationValid) this._configuration = configuration.emptyConfiguration();
71 this._watchFavorites = [];78 this._watchFavorites = [];
72- this._keyStatus = contracts.KEY_STATUS.MISSING;79+ this._results = configuration.emptyResults();
73- this._cache = Object.create(null);80+ this._metrics = {
81+ requests: 0,
82+ cacheHits: 0,
83+ cacheMisses: 0,
84+ successes: 0,
85+ failures: 0,
86+ totalLatencyMs: 0,
87+ lastLatencyMs: 0
88+ };
74 this._requestGenerations = Object.create(null);89 this._requestGenerations = Object.create(null);
75 this._inFlightRequests = Object.create(null);90 this._inFlightRequests = Object.create(null);
76 this._requestGeneration = 0;91 this._requestGeneration = 0;
@@ -126,9 +141,6 @@ Companion.prototype._refreshWatchState = function () {
126 var configured = this._configuration.favorites.map(contracts.copyFavorite);141 var configured = this._configuration.favorites.map(contracts.copyFavorite);
127 this._fixtureEnabled = this._fixture !== null && configured.length === 0;142 this._fixtureEnabled = this._fixture !== null && configured.length === 0;
128 this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;143 this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
129- this._keyStatus = this._fixtureEnabled || this._configuration.apiKey !== null
130- ? contracts.KEY_STATUS.CONFIGURED
131- : contracts.KEY_STATUS.MISSING;
132 };144 };
133 145
134 Companion.prototype._nextSequenceId = function () {146 Companion.prototype._nextSequenceId = function () {
@@ -138,43 +150,104 @@ Companion.prototype._nextSequenceId = function () {
138 };150 };
139 151
140 Companion.prototype._sendConfiguration = function () {152 Companion.prototype._sendConfiguration = function () {
141- if (!configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.apiKey, null)) return;153+ var keyStatus = this._fixtureEnabled
154+ ? contracts.KEY_STATUS.CONFIGURED
155+ : (this._credentialBlocked
156+ ? contracts.KEY_STATUS.INVALID
157+ : this._configuration.keyStatus);
158+ if (!this._configurationValid
159+ || !configuration.areFavoritesSecretFree(
160+ this._watchFavorites,
161+ this._configuration.primApiKey,
162+ null
163+ )) return;
142 this._queue.enqueue(codec.encodeConfiguration(164 this._queue.enqueue(codec.encodeConfiguration(
143 this._nextSequenceId(),165 this._nextSequenceId(),
144 this._watchFavorites,166 this._watchFavorites,
145- this._keyStatus,167+ keyStatus,
146 configuration.activeWatchLanguage(this._Pebble)168 configuration.activeWatchLanguage(this._Pebble)
147 ));169 ));
148 };170 };
149 171
150 Companion.prototype._onReady = function () {172 Companion.prototype._onReady = function () {
151- this._configuration = configuration.loadConfiguration(this._storage);173+ var wasBlocked = this._credentialBlocked;
174+ var loaded = configuration.loadConfiguration(this._storage);
175+ if (loaded === null) {
176+ this._configurationValid = false;
177+ return;
178+ }
179+ if (wasBlocked && loaded.primApiKey !== null) {
180+ loaded.keyStatus = contracts.KEY_STATUS.INVALID;
181+ }
182+ this._configuration = loaded;
183+ this._configurationValid = true;
184+ this._credentialBlocked = loaded.keyStatus === contracts.KEY_STATUS.INVALID;
185+ this._results = configuration.loadResults(this._storage, loaded.favorites);
152 this._refreshWatchState();186 this._refreshWatchState();
153 this._sendConfiguration();187 this._sendConfiguration();
154 };188 };
155 189
156 Companion.prototype._onShowConfiguration = function () {190 Companion.prototype._onShowConfiguration = function () {
157- var language = configuration.activeWatchLanguage(this._Pebble);191+ var language;
158- var url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);192+ var url;
193+ if (!this._configurationValid) return;
194+ language = configuration.activeWatchLanguage(this._Pebble);
195+ url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
159 if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);196 if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);
160 };197 };
161 198
162 Companion.prototype._onWebviewClosed = function (event) {199 Companion.prototype._onWebviewClosed = function (event) {
163- var update = configuration.parseCloseFragment(event && event.response);200+ var update;
164 var next;201 var next;
202+ var pending;
203+ var pruned;
204+ if (!this._configurationValid) return;
205+ update = configuration.parseCloseFragment(event && event.response);
165 if (update === null) return;206 if (update === null) return;
166 next = configuration.applyConfigurationUpdate(this._configuration, update);207 next = configuration.applyConfigurationUpdate(this._configuration, update);
167- if (next === null || !configuration.saveConfiguration(this._storage, next)) return;208+ if (next === null) return;
209+ if (update.apiKeyUpdate.action === "REPLACE") {
210+ if (this._credentialBlocked
211+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
212+ pending = {
213+ schemaVersion: contracts.SCHEMA_VERSION,
214+ favorites: next.favorites.map(contracts.copyFavorite),
215+ primApiKey: next.primApiKey,
216+ keyStatus: contracts.KEY_STATUS.INVALID
217+ };
218+ if (!configuration.saveConfiguration(this._storage, pending)) return;
219+ if (!configuration.clearInvalidKeyStatus(this._storage)
220+ || !configuration.saveConfiguration(this._storage, next)) {
221+ next = pending;
222+ }
223+ } else if (!configuration.clearInvalidKeyStatus(this._storage)
224+ || !configuration.saveConfiguration(this._storage, next)) {
225+ return;
226+ }
227+ } else {
228+ if (!configuration.saveConfiguration(this._storage, next)) return;
229+ if (update.apiKeyUpdate.action === "REMOVE") {
230+ configuration.clearInvalidKeyStatus(this._storage);
231+ }
232+ }
233+ pruned = configuration.pruneResults(this._results, next.favorites);
234+ if (JSON.stringify(pruned) !== JSON.stringify(this._results)) {
235+ configuration.saveResults(this._storage, pruned);
236+ }
168 this._configuration = next;237 this._configuration = next;
169- this._cache = Object.create(null);238+ this._results = pruned;
170 this._requestGenerations = Object.create(null);239 this._requestGenerations = Object.create(null);
240+ this._credentialBlocked = next.primApiKey !== null
241+ && next.keyStatus === contracts.KEY_STATUS.INVALID;
171 this._refreshWatchState();242 this._refreshWatchState();
172 this._sendConfiguration();243 this._sendConfiguration();
173 };244 };
174 245
175 Companion.prototype._onAppMessage = function (event) {246 Companion.prototype._onAppMessage = function (event) {
176- var request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));247+ var request;
177- if (request === null || request.trigger !== contracts.REQUEST_TRIGGER.APP_OPEN) return;248+ if (!this._configurationValid) return;
249+ request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
250+ if (request === null) return;
178 if (this._findFavorite(request.favoriteId) === null) {251 if (this._findFavorite(request.favoriteId) === null) {
179 this._sendError(request, "INVALID_SERVICE");252 this._sendError(request, "INVALID_SERVICE");
180 return;253 return;
@@ -191,20 +264,22 @@ Companion.prototype._findFavorite = function (favoriteId) {
191 };264 };
192 265
193 Companion.prototype._cacheResult = function (result) {266 Companion.prototype._cacheResult = function (result) {
194- this._cache[result.favoriteId] = {267+ var next = configuration.putResult(
195- storedAt: this._clock.now(),268+ this._results,
196- result: contracts.copyDepartureResult(result)269+ this._configuration.favorites,
197- };270+ result,
271+ Math.floor(this._clock.now())
272+ );
273+ if (next === null) return false;
274+ if (!configuration.saveResults(this._storage, next)) return false;
275+ this._results = next;
276+ return true;
198 };277 };
199 278
200 Companion.prototype._cachedResult = function (request) {279 Companion.prototype._cachedResult = function (request) {
201- var cached = this._cache[request.favoriteId];280+ return configuration.findResult(this._results, request.favoriteId, request.requestId);
202- var age;
203- if (!cached) return null;
204- age = this._clock.now() - cached.storedAt;
205- if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) return null;
206- return contracts.copyDepartureResult(cached.result, request.requestId);
207 };281 };
282+
208 Companion.prototype._beginRequest = function (favoriteId) {283 Companion.prototype._beginRequest = function (favoriteId) {
209 this._requestGeneration += 1;284 this._requestGeneration += 1;
210 this._requestGenerations[favoriteId] = this._requestGeneration;285 this._requestGenerations[favoriteId] = this._requestGeneration;
@@ -215,27 +290,36 @@ Companion.prototype._isLatestRequest = function (favoriteId, generation) {
215 return this._requestGenerations[favoriteId] === generation;290 return this._requestGenerations[favoriteId] === generation;
216 };291 };
217 292
218-
219 Companion.prototype._dispatchRequest = function (request) {293 Companion.prototype._dispatchRequest = function (request) {
220- var generation = this._beginRequest(request.favoriteId);
221 var cached = this._cachedResult(request);294 var cached = this._cachedResult(request);
295+ var cachedMessages;
222 var fixtureResult;296 var fixtureResult;
223 var favorite;297 var favorite;
298+ var generation;
299+ var age;
300+ var refreshRequired;
224 if (cached !== null) {301 if (cached !== null) {
225- this._queue.enqueue(codec.encodeResult(cached));302+ this._metrics.cacheHits += 1;
226- return;303+ age = this._clock.now() - Math.min(cached.result.fetchedAt * 1000, cached.storedAt);
304+ refreshRequired = !(age >= 0 && age < contracts.CACHE_FRESH_SECONDS * 1000);
305+ cachedMessages = codec.encodeResult(cached.result);
306+ if (refreshRequired) cachedMessages.push(codec.encodeRequest(request));
307+ this._queue.enqueue(cachedMessages);
308+ if (!refreshRequired) return;
309+ } else {
310+ this._metrics.cacheMisses += 1;
227 }311 }
228 if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {312 if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
229 fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);313 fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
230- this._cacheResult(fixtureResult);
231 this._queue.enqueue(codec.encodeResult(fixtureResult));314 this._queue.enqueue(codec.encodeResult(fixtureResult));
232 return;315 return;
233 }316 }
234- if (this._configuration.apiKey === null) {317+ if (this._configuration.primApiKey === null) {
235 this._sendError(request, "API_KEY_REQUIRED");318 this._sendError(request, "API_KEY_REQUIRED");
236 return;319 return;
237 }320 }
238- if (this._keyStatus === contracts.KEY_STATUS.INVALID) {321+ if (this._credentialBlocked
322+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) {
239 this._sendError(request, "API_KEY_INVALID");323 this._sendError(request, "API_KEY_INVALID");
240 return;324 return;
241 }325 }
@@ -244,6 +328,7 @@ Companion.prototype._dispatchRequest = function (request) {
244 this._sendError(request, "INVALID_SERVICE");328 this._sendError(request, "INVALID_SERVICE");
245 return;329 return;
246 }330 }
331+ generation = this._beginRequest(request.favoriteId);
247 this._fetch(request, favorite, generation);332 this._fetch(request, favorite, generation);
248 };333 };
249 334
@@ -251,8 +336,8 @@ Companion.prototype._fetch = function (request, favorite, generation) {
251 var self = this;336 var self = this;
252 var xhr;337 var xhr;
253 var settled = false;338 var settled = false;
254- var key = this._configuration.apiKey;339+ var key = null;
255- if (!configuration.isFavoriteSecretFree(favorite, key, null)) return;340+ var startedAt = this._clock.now();
256 if (this._backendUrl.indexOf("https://") !== 0) {341 if (this._backendUrl.indexOf("https://") !== 0) {
257 this._sendError(request, "SOURCE_UNAVAILABLE");342 this._sendError(request, "SOURCE_UNAVAILABLE");
258 return;343 return;
@@ -260,8 +345,12 @@ Companion.prototype._fetch = function (request, favorite, generation) {
260 345
261 function once(callback) {346 function once(callback) {
262 return function () {347 return function () {
348+ var latency;
263 if (settled) return;349 if (settled) return;
264 settled = true;350 settled = true;
351+ latency = Math.max(0, self._clock.now() - startedAt);
352+ self._metrics.lastLatencyMs = latency;
353+ self._metrics.totalLatencyMs += latency;
265 if (self._inFlightRequests[generation] === xhr) {354 if (self._inFlightRequests[generation] === xhr) {
266 delete self._inFlightRequests[generation];355 delete self._inFlightRequests[generation];
267 }356 }
@@ -274,17 +363,24 @@ Companion.prototype._fetch = function (request, favorite, generation) {
274 xhr = new this._XHR();363 xhr = new this._XHR();
275 xhr.open("POST", this._backendUrl, true);364 xhr.open("POST", this._backendUrl, true);
276 xhr.timeout = contracts.LIMITS.httpTimeoutMs;365 xhr.timeout = contracts.LIMITS.httpTimeoutMs;
277- xhr.setRequestHeader("Authorization", "Bearer " + key);
278 xhr.setRequestHeader("Content-Type", "application/json");366 xhr.setRequestHeader("Content-Type", "application/json");
279 xhr.setRequestHeader("Accept", "application/json");367 xhr.setRequestHeader("Accept", "application/json");
280 xhr.onload = once(function () { self._handleResponse(request, xhr); });368 xhr.onload = once(function () { self._handleResponse(request, xhr); });
281- xhr.onerror = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });369+ xhr.onerror = once(function () {
282- xhr.ontimeout = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });370+ self._metrics.failures += 1;
283- xhr.onabort = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });371+ self._sendError(request, "SOURCE_UNAVAILABLE");
284- if (!this._isLatestRequest(request.favoriteId, generation)) {372+ });
373+ xhr.ontimeout = xhr.onerror;
374+ xhr.onabort = xhr.onerror;
375+ if (!this._isLatestRequest(request.favoriteId, generation)) return;
376+ key = this._configuration.primApiKey;
377+ if (!contracts.isPersonalApiKey(key)) {
285 key = null;378 key = null;
379+ this._sendError(request, "API_KEY_REQUIRED");
286 return;380 return;
287 }381 }
382+ xhr.setRequestHeader("Authorization", "Bearer " + key);
383+ this._metrics.requests += 1;
288 this._inFlightRequests[generation] = xhr;384 this._inFlightRequests[generation] = xhr;
289 xhr.send(JSON.stringify({385 xhr.send(JSON.stringify({
290 schemaVersion: contracts.SCHEMA_VERSION,386 schemaVersion: contracts.SCHEMA_VERSION,
@@ -300,6 +396,7 @@ Companion.prototype._fetch = function (request, favorite, generation) {
300 }396 }
301 if (!settled) {397 if (!settled) {
302 settled = true;398 settled = true;
399+ this._metrics.failures += 1;
303 if (this._isLatestRequest(request.favoriteId, generation)) {400 if (this._isLatestRequest(request.favoriteId, generation)) {
304 this._sendError(request, "SOURCE_UNAVAILABLE");401 this._sendError(request, "SOURCE_UNAVAILABLE");
305 }402 }
@@ -326,14 +423,21 @@ Companion.prototype._handleResponse = function (request, xhr) {
326 if (!contracts.isDepartureResult(body)423 if (!contracts.isDepartureResult(body)
327 || body.requestId !== request.requestId424 || body.requestId !== request.requestId
328 || body.favoriteId !== request.favoriteId) {425 || body.favoriteId !== request.favoriteId) {
426+ this._metrics.failures += 1;
329 this._sendError(request, "INVALID_RESPONSE");427 this._sendError(request, "INVALID_RESPONSE");
330 return;428 return;
331 }429 }
430+ this._metrics.successes += 1;
332 this._cacheResult(body);431 this._cacheResult(body);
333 this._queue.enqueue(codec.encodeResult(body));432 this._queue.enqueue(codec.encodeResult(body));
334 return;433 return;
335 }434 }
336 435
436+ this._metrics.failures += 1;
437+ if (status === 401 || status === 403) {
438+ this._sendError(request, "API_KEY_INVALID");
439+ return;
440+ }
337 if (body !== null && contracts.isErrorResult(body)) {441 if (body !== null && contracts.isErrorResult(body)) {
338 if (body.requestId !== request.requestId442 if (body.requestId !== request.requestId
339 || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {443 || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {
@@ -390,12 +494,33 @@ Companion.prototype._sendError = function (request, code, retryAfterSeconds) {
390 };494 };
391 495
392 Companion.prototype._sendErrorResult = function (error) {496 Companion.prototype._sendErrorResult = function (error) {
497+ var invalid;
393 this._queue.enqueue([codec.encodeError(error)]);498 this._queue.enqueue([codec.encodeError(error)]);
394- if (error.code === "API_KEY_INVALID") {499+ if (error.code !== "API_KEY_INVALID"
395- this._keyStatus = contracts.KEY_STATUS.INVALID;500+ || this._configuration.primApiKey === null
396- this._cache = Object.create(null);501+ || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return;
397- this._sendConfiguration();502+ invalid = {
398- }503+ schemaVersion: contracts.SCHEMA_VERSION,
504+ favorites: this._configuration.favorites.map(contracts.copyFavorite),
505+ primApiKey: this._configuration.primApiKey,
506+ keyStatus: contracts.KEY_STATUS.INVALID
507+ };
508+ configuration.saveInvalidConfiguration(this._storage, invalid);
509+ this._configuration = invalid;
510+ this._credentialBlocked = true;
511+ this._sendConfiguration();
512+};
513+
514+Companion.prototype.metrics = function () {
515+ return {
516+ requests: this._metrics.requests,
517+ cacheHits: this._metrics.cacheHits,
518+ cacheMisses: this._metrics.cacheMisses,
519+ successes: this._metrics.successes,
520+ failures: this._metrics.failures,
521+ totalLatencyMs: this._metrics.totalLatencyMs,
522+ lastLatencyMs: this._metrics.lastLatencyMs
523+ };
399 };524 };
400 525
401 function createCompanion(options) {526 function createCompanion(options) {
modified packages/companion/test/companion.test.js +383 -20
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/fakes.js +4 -0
@@ -83,6 +83,10 @@ FakeStorage.prototype.setItem = function (key, value) {
8383 this.writes.push({ key: key, value: String(value) });
8484 };
8585
86+FakeStorage.prototype.removeItem = function (key) {
87+ delete this.values[key];
88+};
89+
8690 FakeStorage.prototype.keys = function () {
8791 return Object.keys(this.values);
8892 };
@@ -83,6 +83,10 @@ FakeStorage.prototype.setItem = function (key, value) {
83 this.writes.push({ key: key, value: String(value) });83 this.writes.push({ key: key, value: String(value) });
84 };84 };
85 85
86+FakeStorage.prototype.removeItem = function (key) {
87+ delete this.values[key];
88+};
89+
86 FakeStorage.prototype.keys = function () {90 FakeStorage.prototype.keys = function () {
87 return Object.keys(this.values);91 return Object.keys(this.values);
88 };92 };
modified packages/config-page/index.html +2 -0
@@ -91,6 +91,7 @@
9191 <h3>Privacy and data flow</h3>
9292 <p>This app page sets no cookies and uses no analytics or telemetry.</p>
9393 <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>
94+ <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p>
9495 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
9596 <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>
9697 <h3>Data and credits</h3>
@@ -112,6 +113,7 @@
112113 <h3>Vie privée et circulation des données</h3>
113114 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
114115 <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>
116+ <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p>
115117 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
116118 <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>
117119 <h3>Données et crédits</h3>
@@ -91,6 +91,7 @@
91 <h3>Privacy and data flow</h3>91 <h3>Privacy and data flow</h3>
92 <p>This app page sets no cookies and uses no analytics or telemetry.</p>92 <p>This app page sets no cookies and uses no analytics or telemetry.</p>
93 <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>93 <p>Your saved personal PRIM key stays in your phone’s localStorage. When you request departures, the phone sends the key over HTTPS to the Lapin Futé backend. The backend relays it transiently to PRIM and does not store it.</p>
94+ <p>The UUID-scoped phone record is plaintext and survives app updates and uninstall/reinstall. Uninstalling the watch app erases only its offline favorites and key-status copy; remove the key here or clear the phone’s browser storage to erase the phone record.</p>
94 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>95 <p>When you search for a stop, the search terms are sent to the same-origin Lapin Futé catalog endpoint solely to return matching stops. The app does not persist them; hosting infrastructure may log requested URLs and other connection metadata.</p>
95 <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>96 <p>GitHub Pages hosts this page. GitHub may process connection data and requested URLs under its <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">General Privacy Statement</a>.</p>
96 <h3>Data and credits</h3>97 <h3>Data and credits</h3>
@@ -112,6 +113,7 @@
112 <h3>Vie privée et circulation des données</h3>113 <h3>Vie privée et circulation des données</h3>
113 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>114 <p>Cette page de l’application ne dépose aucun cookie et n’utilise ni mesure d’audience, ni analyse comportementale, ni télémétrie.</p>
114 <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>115 <p>Votre clé PRIM personnelle enregistrée reste dans le localStorage de votre téléphone. Lors d’une demande de départs, le téléphone transmet la clé en HTTPS au serveur Lapin Futé. Le serveur la relaie temporairement vers PRIM sans la conserver.</p>
116+ <p>L’enregistrement en clair du téléphone, associé à l’UUID de l’application, survit aux mises à jour ainsi qu’à une désinstallation suivie d’une réinstallation. Désinstaller l’application de la montre n’efface que sa copie hors ligne des favoris et de l’état de la clé ; supprimez la clé ici ou effacez le stockage du navigateur du téléphone pour supprimer l’enregistrement du téléphone.</p>
115 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>117 <p>Lors d’une recherche d’arrêt, les termes saisis sont transmis au point d’accès catalogue de Lapin Futé sur la même origine, uniquement pour retourner les arrêts correspondants. L’application ne les conserve pas ; l’infrastructure d’hébergement peut journaliser les URL demandées et d’autres métadonnées de connexion.</p>
116 <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>118 <p>Cette page est hébergée par GitHub Pages. GitHub peut traiter des données de connexion et les URL demandées selon sa <a href="https://docs.github.com/fr/site-policy/privacy-policies/github-general-privacy-statement" target="_blank" rel="noopener noreferrer">déclaration de confidentialité générale</a>.</p>
117 <h3>Données et crédits</h3>119 <h3>Données et crédits</h3>
modified packages/config-page/src/config-core.js +7 -4
@@ -46,7 +46,10 @@ export function utf8Bytes(value) {
4646 }
4747
4848 function boundedString(value, maximum) {
49- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
49+ return typeof value === "string"
50+ && utf8Bytes(value) >= 1
51+ && utf8Bytes(value) <= maximum
52+ && !/[\u0000-\u001f\u007f]/u.test(value);
5053 }
5154
5255 export function isLineColor(value) {
@@ -81,7 +84,7 @@ export const COPY = {
8184 keyRemovePending: "The saved key will be removed.",
8285 keyReplacementPending: "A replacement key is ready to save.",
8386 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
84- keyErrorNewline: "The key must not contain line breaks.",
87+ keyErrorNewline: "The key must not contain control characters.",
8588 favoritesTitle: "Favorite departures",
8689 favoritesEmpty: "No favorites yet. Search for a stop to add one.",
8790 favoriteAddTitle: "Add a favorite",
@@ -129,7 +132,7 @@ export const COPY = {
129132 keyRemovePending: "La clé enregistrée sera supprimée.",
130133 keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",
131134 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
132- keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",
135+ keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.",
133136 favoritesTitle: "Départs favoris",
134137 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
135138 favoriteAddTitle: "Ajouter un favori",
@@ -379,7 +382,7 @@ export function reduceConfigState(state, action) {
379382 // ApiKeyUpdate carries the value only for REPLACE.
380383
381384 export function apiKeyError(value) {
382- if (/[\r\n]/u.test(value)) return "keyErrorNewline";
385+ if (/[\u0000-\u001f\u007f]/u.test(value)) return "keyErrorNewline";
383386 if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";
384387 return null;
385388 }
@@ -46,7 +46,10 @@ export function utf8Bytes(value) {
46 }46 }
47 47
48 function boundedString(value, maximum) {48 function boundedString(value, maximum) {
49- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;49+ return typeof value === "string"
50+ && utf8Bytes(value) >= 1
51+ && utf8Bytes(value) <= maximum
52+ && !/[\u0000-\u001f\u007f]/u.test(value);
50 }53 }
51 54
52 export function isLineColor(value) {55 export function isLineColor(value) {
@@ -81,7 +84,7 @@ export const COPY = {
81 keyRemovePending: "The saved key will be removed.",84 keyRemovePending: "The saved key will be removed.",
82 keyReplacementPending: "A replacement key is ready to save.",85 keyReplacementPending: "A replacement key is ready to save.",
83 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",86 keyErrorTooLong: "The key must be at most 512 UTF-8 bytes.",
84- keyErrorNewline: "The key must not contain line breaks.",87+ keyErrorNewline: "The key must not contain control characters.",
85 favoritesTitle: "Favorite departures",88 favoritesTitle: "Favorite departures",
86 favoritesEmpty: "No favorites yet. Search for a stop to add one.",89 favoritesEmpty: "No favorites yet. Search for a stop to add one.",
87 favoriteAddTitle: "Add a favorite",90 favoriteAddTitle: "Add a favorite",
@@ -129,7 +132,7 @@ export const COPY = {
129 keyRemovePending: "La clé enregistrée sera supprimée.",132 keyRemovePending: "La clé enregistrée sera supprimée.",
130 keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",133 keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.",
131 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",134 keyErrorTooLong: "La clé doit contenir au maximum 512 octets UTF-8.",
132- keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",135+ keyErrorNewline: "La clé ne doit pas contenir de caractère de contrôle.",
133 favoritesTitle: "Départs favoris",136 favoritesTitle: "Départs favoris",
134 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",137 favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.",
135 favoriteAddTitle: "Ajouter un favori",138 favoriteAddTitle: "Ajouter un favori",
@@ -379,7 +382,7 @@ export function reduceConfigState(state, action) {
379 // ApiKeyUpdate carries the value only for REPLACE.382 // ApiKeyUpdate carries the value only for REPLACE.
380 383
381 export function apiKeyError(value) {384 export function apiKeyError(value) {
382- if (/[\r\n]/u.test(value)) return "keyErrorNewline";385+ if (/[\u0000-\u001f\u007f]/u.test(value)) return "keyErrorNewline";
383 if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";386 if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";
384 return null;387 return null;
385 }388 }
modified packages/config-page/test/config-page.test.ts +5 -2
@@ -196,7 +196,7 @@ test("the config page consumes the companion-produced opening fragment exactly",
196196 const key = "stored-personal-key";
197197 const url = companionConfiguration.configurationUrl(
198198 "https://config.example.test/index.html",
199- { schemaVersion: 1, favorites: fixtureList, apiKey: key },
199+ { schemaVersion: 1, favorites: fixtureList, primApiKey: key, keyStatus: 1 },
200200 "fr_FR",
201201 );
202202 assert.equal(typeof url, "string");
@@ -286,7 +286,7 @@ test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => {
286286 assert.equal(isPersonalApiKey(replaceUpdate.value), true);
287287 });
288288
289-test("replace validation rejects oversized or line-bearing keys in both locales", () => {
289+test("replace validation rejects oversized or control-bearing keys in both locales", () => {
290290 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);
291291 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");
292292 assert.equal(apiKeyError("é".repeat(256)), null);
@@ -294,6 +294,7 @@ test("replace validation rejects oversized or line-bearing keys in both locales"
294294 assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");
295295 assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");
296296 assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");
297+ assert.equal(apiKeyError("key\u0001header"), "keyErrorNewline");
297298
298299 const tooLong = planConfigResult({
299300 hasKey: false,
@@ -566,6 +567,8 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
566567 "localStorage",
567568 "no analytics or telemetry",
568569 "aucun cookie",
570+ "survives app updates and uninstall/reinstall",
571+ "Désinstaller l’application de la montre",
569572 "Référentiel des lignes",
570573 "conditions d’utilisation IDFM/PRIM",
571574 "search terms are sent",
@@ -196,7 +196,7 @@ test("the config page consumes the companion-produced opening fragment exactly",
196 const key = "stored-personal-key";196 const key = "stored-personal-key";
197 const url = companionConfiguration.configurationUrl(197 const url = companionConfiguration.configurationUrl(
198 "https://config.example.test/index.html",198 "https://config.example.test/index.html",
199- { schemaVersion: 1, favorites: fixtureList, apiKey: key },199+ { schemaVersion: 1, favorites: fixtureList, primApiKey: key, keyStatus: 1 },
200 "fr_FR",200 "fr_FR",
201 );201 );
202 assert.equal(typeof url, "string");202 assert.equal(typeof url, "string");
@@ -286,7 +286,7 @@ test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => {
286 assert.equal(isPersonalApiKey(replaceUpdate.value), true);286 assert.equal(isPersonalApiKey(replaceUpdate.value), true);
287 });287 });
288 288
289-test("replace validation rejects oversized or line-bearing keys in both locales", () => {289+test("replace validation rejects oversized or control-bearing keys in both locales", () => {
290 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);290 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);
291 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");291 assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");
292 assert.equal(apiKeyError("é".repeat(256)), null);292 assert.equal(apiKeyError("é".repeat(256)), null);
@@ -294,6 +294,7 @@ test("replace validation rejects oversized or line-bearing keys in both locales"
294 assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");294 assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");
295 assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");295 assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");
296 assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");296 assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");
297+ assert.equal(apiKeyError("key\u0001header"), "keyErrorNewline");
297 298
298 const tooLong = planConfigResult({299 const tooLong = planConfigResult({
299 hasKey: false,300 hasKey: false,
@@ -566,6 +567,8 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
566 "localStorage",567 "localStorage",
567 "no analytics or telemetry",568 "no analytics or telemetry",
568 "aucun cookie",569 "aucun cookie",
570+ "survives app updates and uninstall/reinstall",
571+ "Désinstaller l’application de la montre",
569 "Référentiel des lignes",572 "Référentiel des lignes",
570 "conditions d’utilisation IDFM/PRIM",573 "conditions d’utilisation IDFM/PRIM",
571 "search terms are sent",574 "search terms are sent",
modified packages/contracts/src/index.ts +4 -1
@@ -226,7 +226,10 @@ export function utf8Bytes(value: string): number {
226226 }
227227
228228 function boundedString(value: unknown, maximum: number): value is string {
229- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
229+ return typeof value === "string"
230+ && utf8Bytes(value) >= 1
231+ && utf8Bytes(value) <= maximum
232+ && !/[\u0000-\u001f\u007f]/u.test(value);
230233 }
231234
232235 export function isLineColor(value: unknown): value is string {
@@ -226,7 +226,10 @@ export function utf8Bytes(value: string): number {
226 }226 }
227 227
228 function boundedString(value: unknown, maximum: number): value is string {228 function boundedString(value: unknown, maximum: number): value is string {
229- return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;229+ return typeof value === "string"
230+ && utf8Bytes(value) >= 1
231+ && utf8Bytes(value) <= maximum
232+ && !/[\u0000-\u001f\u007f]/u.test(value);
230 }233 }
231 234
232 export function isLineColor(value: unknown): value is string {235 export function isLineColor(value: unknown): value is string {
modified packages/contracts/test/contracts.test.ts +2 -0
@@ -95,6 +95,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
9595 assert.equal(isFavorite({ ...favorite, unknown: true }), false);
9696 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
9797 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
98+ assert.equal(isFavorite({ ...favorite, stopLabel: "unsafe\u0001label" }), false);
9899 assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);
99100 assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);
100101 assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);
@@ -137,6 +138,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
137138 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);
138139 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);
139140 assert.equal(isPersonalApiKey("key\nheader"), false);
141+ assert.equal(isPersonalApiKey("key\u0001header"), false);
140142 });
141143
142144 test("symbolic payload aliases retain the frozen explicit numeric order", () => {
@@ -95,6 +95,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
95 assert.equal(isFavorite({ ...favorite, unknown: true }), false);95 assert.equal(isFavorite({ ...favorite, unknown: true }), false);
96 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);96 assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
97 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);97 assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
98+ assert.equal(isFavorite({ ...favorite, stopLabel: "unsafe\u0001label" }), false);
98 assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);99 assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false);
99 assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);100 assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false);
100 assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);101 assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false);
@@ -137,6 +138,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules
137 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);138 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);
138 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);139 assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);
139 assert.equal(isPersonalApiKey("key\nheader"), false);140 assert.equal(isPersonalApiKey("key\nheader"), false);
141+ assert.equal(isPersonalApiKey("key\u0001header"), false);
140 });142 });
141 143
142 test("symbolic payload aliases retain the frozen explicit numeric order", () => {144 test("symbolic payload aliases retain the frozen explicit numeric order", () => {
modified packages/watch/src/embeddedjs/contracts.js +1 -1
@@ -145,7 +145,7 @@ export function utf8Bytes(value) {
145145 export function boundedString(value, maximum) {
146146 if (typeof value !== "string") return false;
147147 const size = utf8Bytes(value);
148- return size >= 1 && size <= maximum;
148+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
149149 }
150150
151151 export function uint32(value) {
@@ -145,7 +145,7 @@ export function utf8Bytes(value) {
145 export function boundedString(value, maximum) {145 export function boundedString(value, maximum) {
146 if (typeof value !== "string") return false;146 if (typeof value !== "string") return false;
147 const size = utf8Bytes(value);147 const size = utf8Bytes(value);
148- return size >= 1 && size <= maximum;148+ return size >= 1 && size <= maximum && !/[\u0000-\u001f\u007f]/u.test(value);
149 }149 }
150 150
151 export function uint32(value) {151 export function uint32(value) {
modified packages/watch/src/embeddedjs/controller.js +35 -3
@@ -1,5 +1,6 @@
11 import { QUEUE_STATE } from "./message-queue.js";
22 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3+import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
34
45 function eventName(received) {
56 if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";
@@ -24,9 +25,12 @@ export function createController(options) {
2425 const clock = options.clock;
2526 const queue = options.queue;
2627 const model = options.model;
28+ const storage = options.storage || null;
2729 const view = options.view;
2830 const receiver = new ProtocolReceiver();
31+ let committedConfiguration = null;
2932 let sequence = 0;
33+ let restoredRequestPending = false;
3034 let started = false;
3135 let closed = false;
3236
@@ -60,7 +64,16 @@ export function createController(options) {
6064 start() {
6165 if (closed || started) return controller;
6266 started = true;
63- render();
67+ const configuration = storage ? loadWatchConfiguration(storage) : null;
68+ if (configuration) {
69+ committedConfiguration = configuration;
70+ receiver.restoreConfiguration(configuration);
71+ model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");
72+ restoredRequestPending = true;
73+ render();
74+ } else {
75+ render();
76+ }
6477 return controller;
6578 },
6679
@@ -68,8 +81,21 @@ export function createController(options) {
6881 if (closed) return RECEIVE_RESULT.REJECTED;
6982 const received = receiver.receive(message);
7083 const event = eventName(received);
84+ if (event === "CONFIG_COMMITTED") restoredRequestPending = false;
7185 if (event !== null) {
72- const effect = model.commitProtocol(receiver.snapshot(), event);
86+ let snapshot = receiver.snapshot();
87+ if (event === "CONFIG_COMMITTED" && storage) {
88+ if (!saveWatchConfiguration(storage, snapshot.configuration)) {
89+ receiver.restoreConfiguration(committedConfiguration);
90+ snapshot = receiver.snapshot();
91+ const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92+ render();
93+ if (effect.requestFixture) requestFixture();
94+ return RECEIVE_RESULT.REJECTED;
95+ }
96+ committedConfiguration = snapshot.configuration;
97+ }
98+ const effect = model.commitProtocol(snapshot, event);
7399 render();
74100 if (effect.requestFixture) requestFixture();
75101 }
@@ -77,7 +103,13 @@ export function createController(options) {
77103 },
78104
79105 onQueueState(state) {
80- if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
106+ if (closed || !state) return;
107+ if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {
108+ restoredRequestPending = false;
109+ requestFixture();
110+ return;
111+ }
112+ if (state.type !== QUEUE_STATE.FAILED) return;
81113 receiver.cancelExpectedResponse();
82114 model.markSendFailure();
83115 render();
@@ -1,5 +1,6 @@
1 import { QUEUE_STATE } from "./message-queue.js";1 import { QUEUE_STATE } from "./message-queue.js";
2 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";2 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3+import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
3 4
4 function eventName(received) {5 function eventName(received) {
5 if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";6 if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";
@@ -24,9 +25,12 @@ export function createController(options) {
24 const clock = options.clock;25 const clock = options.clock;
25 const queue = options.queue;26 const queue = options.queue;
26 const model = options.model;27 const model = options.model;
28+ const storage = options.storage || null;
27 const view = options.view;29 const view = options.view;
28 const receiver = new ProtocolReceiver();30 const receiver = new ProtocolReceiver();
31+ let committedConfiguration = null;
29 let sequence = 0;32 let sequence = 0;
33+ let restoredRequestPending = false;
30 let started = false;34 let started = false;
31 let closed = false;35 let closed = false;
32 36
@@ -60,7 +64,16 @@ export function createController(options) {
60 start() {64 start() {
61 if (closed || started) return controller;65 if (closed || started) return controller;
62 started = true;66 started = true;
63- render();67+ const configuration = storage ? loadWatchConfiguration(storage) : null;
68+ if (configuration) {
69+ committedConfiguration = configuration;
70+ receiver.restoreConfiguration(configuration);
71+ model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");
72+ restoredRequestPending = true;
73+ render();
74+ } else {
75+ render();
76+ }
64 return controller;77 return controller;
65 },78 },
66 79
@@ -68,8 +81,21 @@ export function createController(options) {
68 if (closed) return RECEIVE_RESULT.REJECTED;81 if (closed) return RECEIVE_RESULT.REJECTED;
69 const received = receiver.receive(message);82 const received = receiver.receive(message);
70 const event = eventName(received);83 const event = eventName(received);
84+ if (event === "CONFIG_COMMITTED") restoredRequestPending = false;
71 if (event !== null) {85 if (event !== null) {
72- const effect = model.commitProtocol(receiver.snapshot(), event);86+ let snapshot = receiver.snapshot();
87+ if (event === "CONFIG_COMMITTED" && storage) {
88+ if (!saveWatchConfiguration(storage, snapshot.configuration)) {
89+ receiver.restoreConfiguration(committedConfiguration);
90+ snapshot = receiver.snapshot();
91+ const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92+ render();
93+ if (effect.requestFixture) requestFixture();
94+ return RECEIVE_RESULT.REJECTED;
95+ }
96+ committedConfiguration = snapshot.configuration;
97+ }
98+ const effect = model.commitProtocol(snapshot, event);
73 render();99 render();
74 if (effect.requestFixture) requestFixture();100 if (effect.requestFixture) requestFixture();
75 }101 }
@@ -77,7 +103,13 @@ export function createController(options) {
77 },103 },
78 104
79 onQueueState(state) {105 onQueueState(state) {
80- if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;106+ if (closed || !state) return;
107+ if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {
108+ restoredRequestPending = false;
109+ requestFixture();
110+ return;
111+ }
112+ if (state.type !== QUEUE_STATE.FAILED) return;
81 receiver.cancelExpectedResponse();113 receiver.cancelExpectedResponse();
82 model.markSendFailure();114 model.markSendFailure();
83 render();115 render();
modified packages/watch/src/embeddedjs/main.js +1 -0
@@ -45,6 +45,7 @@ controller = createController({
4545 clock: { now: Date.now },
4646 queue,
4747 model: new WatchModel(),
48+ storage: localStorage,
4849 view
4950 });
5051 controller.start();
@@ -45,6 +45,7 @@ controller = createController({
45 clock: { now: Date.now },45 clock: { now: Date.now },
46 queue,46 queue,
47 model: new WatchModel(),47 model: new WatchModel(),
48+ storage: localStorage,
48 view49 view
49 });50 });
50 controller.start();51 controller.start();
modified packages/watch/src/embeddedjs/manifest.json +1 -0
@@ -11,6 +11,7 @@
1111 "./protocol",
1212 "./message-queue",
1313 "./localization",
14+ "./storage",
1415 "./ui",
1516 "./contracts"
1617 ]
@@ -11,6 +11,7 @@
11 "./protocol",11 "./protocol",
12 "./message-queue",12 "./message-queue",
13 "./localization",13 "./localization",
14+ "./storage",
14 "./ui",15 "./ui",
15 "./contracts"16 "./contracts"
16 ]17 ]
modified packages/watch/src/embeddedjs/protocol.js +38 -3
@@ -262,25 +262,37 @@ function copyError(error) {
262262 }
263263
264264 export class ProtocolReceiver {
265- constructor() {
265+ constructor(initialConfiguration = null) {
266266 this.configuration = null;
267267 this.result = null;
268268 this.error = null;
269269 this.configStage = null;
270270 this.resultStage = null;
271271 this.expected = null;
272+ this.refreshCandidate = null;
273+ if (initialConfiguration) this.restoreConfiguration(initialConfiguration);
274+ }
275+
276+ restoreConfiguration(configuration) {
277+ this.configuration = copyConfiguration(configuration);
278+ this.result = null;
279+ this.error = null;
280+ this.discardStaging();
281+ this.cancelExpectedResponse();
272282 }
273283
274284 expectResponse(requestId, favoriteId) {
275285 if (!boundedString(requestId, LIMITS.idUtf8Bytes)
276286 || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
277- this.expected = { requestId, favoriteId };
287+ this.expected = { requestId, favoriteId, refresh: false };
288+ this.refreshCandidate = null;
278289 this.resultStage = null;
279290 return true;
280291 }
281292
282293 cancelExpectedResponse() {
283294 this.expected = null;
295+ this.refreshCandidate = null;
284296 this.resultStage = null;
285297 }
286298
@@ -366,6 +378,24 @@ export class ProtocolReceiver {
366378 return RECEIVE_RESULT.CONFIG_COMMITTED;
367379 }
368380
381+ if (type === MESSAGE_TYPE.REQUEST) {
382+ const candidate = this.refreshCandidate;
383+ if (this.expected
384+ || !candidate
385+ || candidate.requestId !== requestId
386+ || candidate.favoriteId !== decoded.FAVORITE_ID) {
387+ return RECEIVE_RESULT.REJECTED;
388+ }
389+ this.expected = {
390+ requestId,
391+ favoriteId: decoded.FAVORITE_ID,
392+ refresh: true
393+ };
394+ this.refreshCandidate = null;
395+ this.resultStage = null;
396+ return RECEIVE_RESULT.STAGED;
397+ }
398+
369399 if (type === MESSAGE_TYPE.RESULT_BEGIN) {
370400 if (!this.expected
371401 || this.expected.requestId !== requestId
@@ -423,9 +453,14 @@ export class ProtocolReceiver {
423453 this.resultStage = null;
424454 return RECEIVE_RESULT.REJECTED;
425455 }
456+ const refresh = this.expected.refresh;
426457 this.result = copyResult(stage);
427458 this.error = null;
428- this.cancelExpectedResponse();
459+ this.expected = null;
460+ this.resultStage = null;
461+ this.refreshCandidate = refresh
462+ ? null
463+ : { requestId, favoriteId: decoded.FAVORITE_ID };
429464 return RECEIVE_RESULT.RESULT_COMMITTED;
430465 }
431466
@@ -262,25 +262,37 @@ function copyError(error) {
262 }262 }
263 263
264 export class ProtocolReceiver {264 export class ProtocolReceiver {
265- constructor() {265+ constructor(initialConfiguration = null) {
266 this.configuration = null;266 this.configuration = null;
267 this.result = null;267 this.result = null;
268 this.error = null;268 this.error = null;
269 this.configStage = null;269 this.configStage = null;
270 this.resultStage = null;270 this.resultStage = null;
271 this.expected = null;271 this.expected = null;
272+ this.refreshCandidate = null;
273+ if (initialConfiguration) this.restoreConfiguration(initialConfiguration);
274+ }
275+
276+ restoreConfiguration(configuration) {
277+ this.configuration = copyConfiguration(configuration);
278+ this.result = null;
279+ this.error = null;
280+ this.discardStaging();
281+ this.cancelExpectedResponse();
272 }282 }
273 283
274 expectResponse(requestId, favoriteId) {284 expectResponse(requestId, favoriteId) {
275 if (!boundedString(requestId, LIMITS.idUtf8Bytes)285 if (!boundedString(requestId, LIMITS.idUtf8Bytes)
276 || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;286 || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
277- this.expected = { requestId, favoriteId };287+ this.expected = { requestId, favoriteId, refresh: false };
288+ this.refreshCandidate = null;
278 this.resultStage = null;289 this.resultStage = null;
279 return true;290 return true;
280 }291 }
281 292
282 cancelExpectedResponse() {293 cancelExpectedResponse() {
283 this.expected = null;294 this.expected = null;
295+ this.refreshCandidate = null;
284 this.resultStage = null;296 this.resultStage = null;
285 }297 }
286 298
@@ -366,6 +378,24 @@ export class ProtocolReceiver {
366 return RECEIVE_RESULT.CONFIG_COMMITTED;378 return RECEIVE_RESULT.CONFIG_COMMITTED;
367 }379 }
368 380
381+ if (type === MESSAGE_TYPE.REQUEST) {
382+ const candidate = this.refreshCandidate;
383+ if (this.expected
384+ || !candidate
385+ || candidate.requestId !== requestId
386+ || candidate.favoriteId !== decoded.FAVORITE_ID) {
387+ return RECEIVE_RESULT.REJECTED;
388+ }
389+ this.expected = {
390+ requestId,
391+ favoriteId: decoded.FAVORITE_ID,
392+ refresh: true
393+ };
394+ this.refreshCandidate = null;
395+ this.resultStage = null;
396+ return RECEIVE_RESULT.STAGED;
397+ }
398+
369 if (type === MESSAGE_TYPE.RESULT_BEGIN) {399 if (type === MESSAGE_TYPE.RESULT_BEGIN) {
370 if (!this.expected400 if (!this.expected
371 || this.expected.requestId !== requestId401 || this.expected.requestId !== requestId
@@ -423,9 +453,14 @@ export class ProtocolReceiver {
423 this.resultStage = null;453 this.resultStage = null;
424 return RECEIVE_RESULT.REJECTED;454 return RECEIVE_RESULT.REJECTED;
425 }455 }
456+ const refresh = this.expected.refresh;
426 this.result = copyResult(stage);457 this.result = copyResult(stage);
427 this.error = null;458 this.error = null;
428- this.cancelExpectedResponse();459+ this.expected = null;
460+ this.resultStage = null;
461+ this.refreshCandidate = refresh
462+ ? null
463+ : { requestId, favoriteId: decoded.FAVORITE_ID };
429 return RECEIVE_RESULT.RESULT_COMMITTED;464 return RECEIVE_RESULT.RESULT_COMMITTED;
430 }465 }
431 466
added packages/watch/src/embeddedjs/storage.js +251 -0
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/controller-storage.test.js +199 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/manifest.test.js +1 -0
@@ -33,6 +33,7 @@ const expectedModules = [
3333 "./protocol",
3434 "./message-queue",
3535 "./localization",
36+ "./storage",
3637 "./ui",
3738 "./contracts"
3839 ];
@@ -33,6 +33,7 @@ const expectedModules = [
33 "./protocol",33 "./protocol",
34 "./message-queue",34 "./message-queue",
35 "./localization",35 "./localization",
36+ "./storage",
36 "./ui",37 "./ui",
37 "./contracts"38 "./contracts"
38 ];39 ];
modified packages/watch/test/protocol.test.js +53 -0
@@ -217,6 +217,30 @@ test("result stages atomically only for the expected request and favorite", () =
217217 });
218218 });
219219
220+test("matching request handshake permits one cached result replacement", () => {
221+ const receiver = new ProtocolReceiver();
222+ const request = encodeRequest({
223+ requestId: "request-1",
224+ favoriteId: "home",
225+ trigger: REQUEST_TRIGGER.APP_OPEN
226+ });
227+ const freshBegin = resultBegin();
228+ freshBegin.set("FETCHED_AT", 1_788_000_060);
229+
230+ assert.equal(receiver.expectResponse("request-1", "home"), true);
231+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
232+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
233+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
234+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_000);
235+
236+ assert.equal(receiver.receive(request), RECEIVE_RESULT.STAGED);
237+ assert.equal(receiver.receive(freshBegin), RECEIVE_RESULT.STAGED);
238+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
239+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
240+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_060);
241+ assert.equal(receiver.receive(request), RECEIVE_RESULT.REJECTED);
242+});
243+
220244 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
221245 const receiver = new ProtocolReceiver();
222246 commitConfiguration(receiver);
@@ -252,3 +276,32 @@ test("matching error commits without destroying the prior complete result", () =
252276 retryAfterSeconds: 30
253277 });
254278 });
279+
280+test("receiver seeds, restores, and idempotently repeats complete configuration", () => {
281+ const initial = {
282+ keyStatus: KEY_STATUS.CONFIGURED,
283+ language: "fr",
284+ favorites: [{
285+ id: "home",
286+ serviceId: "opaque:service:1",
287+ displayName: "Maison",
288+ stopLabel: "Châtelet",
289+ lineLabel: "Métro 1",
290+ destinationLabel: "La Défense",
291+ sortOrder: 0
292+ }]
293+ };
294+ const receiver = new ProtocolReceiver(initial);
295+ initial.favorites[0].stopLabel = "mutated after construction";
296+ assert.equal(receiver.snapshot().configuration.favorites[0].stopLabel, "Châtelet");
297+
298+ commitConfiguration(receiver);
299+ const committed = receiver.snapshot();
300+ commitConfiguration(receiver);
301+ assert.deepEqual(receiver.snapshot(), committed);
302+
303+ assert.equal(receiver.receive(configBegin("interrupted", 1, "en")), RECEIVE_RESULT.STAGED);
304+ receiver.restoreConfiguration(committed.configuration);
305+ assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED);
306+ assert.deepEqual(receiver.snapshot(), committed);
307+});
@@ -217,6 +217,30 @@ test("result stages atomically only for the expected request and favorite", () =
217 });217 });
218 });218 });
219 219
220+test("matching request handshake permits one cached result replacement", () => {
221+ const receiver = new ProtocolReceiver();
222+ const request = encodeRequest({
223+ requestId: "request-1",
224+ favoriteId: "home",
225+ trigger: REQUEST_TRIGGER.APP_OPEN
226+ });
227+ const freshBegin = resultBegin();
228+ freshBegin.set("FETCHED_AT", 1_788_000_060);
229+
230+ assert.equal(receiver.expectResponse("request-1", "home"), true);
231+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
232+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
233+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
234+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_000);
235+
236+ assert.equal(receiver.receive(request), RECEIVE_RESULT.STAGED);
237+ assert.equal(receiver.receive(freshBegin), RECEIVE_RESULT.STAGED);
238+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
239+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
240+ assert.equal(receiver.snapshot().result.fetchedAt, 1_788_000_060);
241+ assert.equal(receiver.receive(request), RECEIVE_RESULT.REJECTED);
242+});
243+
220 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {244 test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
221 const receiver = new ProtocolReceiver();245 const receiver = new ProtocolReceiver();
222 commitConfiguration(receiver);246 commitConfiguration(receiver);
@@ -252,3 +276,32 @@ test("matching error commits without destroying the prior complete result", () =
252 retryAfterSeconds: 30276 retryAfterSeconds: 30
253 });277 });
254 });278 });
279+
280+test("receiver seeds, restores, and idempotently repeats complete configuration", () => {
281+ const initial = {
282+ keyStatus: KEY_STATUS.CONFIGURED,
283+ language: "fr",
284+ favorites: [{
285+ id: "home",
286+ serviceId: "opaque:service:1",
287+ displayName: "Maison",
288+ stopLabel: "Châtelet",
289+ lineLabel: "Métro 1",
290+ destinationLabel: "La Défense",
291+ sortOrder: 0
292+ }]
293+ };
294+ const receiver = new ProtocolReceiver(initial);
295+ initial.favorites[0].stopLabel = "mutated after construction";
296+ assert.equal(receiver.snapshot().configuration.favorites[0].stopLabel, "Châtelet");
297+
298+ commitConfiguration(receiver);
299+ const committed = receiver.snapshot();
300+ commitConfiguration(receiver);
301+ assert.deepEqual(receiver.snapshot(), committed);
302+
303+ assert.equal(receiver.receive(configBegin("interrupted", 1, "en")), RECEIVE_RESULT.STAGED);
304+ receiver.restoreConfiguration(committed.configuration);
305+ assert.equal(receiver.receive(configCommit("interrupted")), RECEIVE_RESULT.REJECTED);
306+ assert.deepEqual(receiver.snapshot(), committed);
307+});
added packages/watch/test/storage.test.js +124 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/measure-radio.mjs +287 -15
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/test.mjs +2 -0
@@ -19,9 +19,11 @@ run(process.execPath, [
1919 "packages/companion/test/protocol.test.js",
2020 "packages/companion/test/companion.test.js",
2121 "packages/watch/test/manifest.test.js",
22+ "packages/watch/test/controller-storage.test.js",
2223 "packages/watch/test/protocol.test.js",
2324 "packages/watch/test/message-queue.test.js",
2425 "packages/watch/test/model.test.js",
26+ "packages/watch/test/storage.test.js",
2527 "packages/watch/test/localization.test.js",
2628 "packages/watch/test/fixture-boot.test.js",
2729 ]);
@@ -19,9 +19,11 @@ run(process.execPath, [
19 "packages/companion/test/protocol.test.js",19 "packages/companion/test/protocol.test.js",
20 "packages/companion/test/companion.test.js",20 "packages/companion/test/companion.test.js",
21 "packages/watch/test/manifest.test.js",21 "packages/watch/test/manifest.test.js",
22+ "packages/watch/test/controller-storage.test.js",
22 "packages/watch/test/protocol.test.js",23 "packages/watch/test/protocol.test.js",
23 "packages/watch/test/message-queue.test.js",24 "packages/watch/test/message-queue.test.js",
24 "packages/watch/test/model.test.js",25 "packages/watch/test/model.test.js",
26+ "packages/watch/test/storage.test.js",
25 "packages/watch/test/localization.test.js",27 "packages/watch/test/localization.test.js",
26 "packages/watch/test/fixture-boot.test.js",28 "packages/watch/test/fixture-boot.test.js",
27 ]);29 ]);