feat: persist and synchronize favorites #14
Merged@github-anthodev wants to merge
feature/sync-favorites/companion/6 into developMerged 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) { | ||
| 170 | 170 | var size; |
| 171 | 171 | if (typeof value !== "string") return false; |
| 172 | 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 | 176 | 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) { | ||
| 50 | 50 | function Companion(options) { |
| 51 | 51 | options = options || {}; |
| 52 | 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 | 57 | requiredAdapter(typeof options.XHR === "function", "XHR adapter is required"); |
| 55 | 58 | requiredAdapter(options.clock |
| 56 | 59 | && typeof options.clock.now === "function", "clock adapter is required"); |
| @@ -68,9 +71,21 @@ function Companion(options) { | ||
| 68 | 71 | this._fixture = options.fixture || null; |
| 69 | 72 | this._fixtureEnabled = false; |
| 70 | 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 | 78 | 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 | + }; | |
| 74 | 89 | this._requestGenerations = Object.create(null); |
| 75 | 90 | this._inFlightRequests = Object.create(null); |
| 76 | 91 | this._requestGeneration = 0; |
| @@ -126,9 +141,6 @@ Companion.prototype._refreshWatchState = function () { | ||
| 126 | 141 | var configured = this._configuration.favorites.map(contracts.copyFavorite); |
| 127 | 142 | this._fixtureEnabled = this._fixture !== null && configured.length === 0; |
| 128 | 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 | 146 | Companion.prototype._nextSequenceId = function () { |
| @@ -138,43 +150,104 @@ Companion.prototype._nextSequenceId = function () { | ||
| 138 | 150 | }; |
| 139 | 151 | |
| 140 | 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 | 164 | this._queue.enqueue(codec.encodeConfiguration( |
| 143 | 165 | this._nextSequenceId(), |
| 144 | 166 | this._watchFavorites, |
| 145 | - this._keyStatus, | |
| 167 | + keyStatus, | |
| 146 | 168 | configuration.activeWatchLanguage(this._Pebble) |
| 147 | 169 | )); |
| 148 | 170 | }; |
| 149 | 171 | |
| 150 | 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 | 186 | this._refreshWatchState(); |
| 153 | 187 | this._sendConfiguration(); |
| 154 | 188 | }; |
| 155 | 189 | |
| 156 | 190 | 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); | |
| 159 | 196 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); |
| 160 | 197 | }; |
| 161 | 198 | |
| 162 | 199 | Companion.prototype._onWebviewClosed = function (event) { |
| 163 | - var update = configuration.parseCloseFragment(event && event.response); | |
| 200 | + var update; | |
| 164 | 201 | var next; |
| 202 | + var pending; | |
| 203 | + var pruned; | |
| 204 | + if (!this._configurationValid) return; | |
| 205 | + update = configuration.parseCloseFragment(event && event.response); | |
| 165 | 206 | if (update === null) return; |
| 166 | 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 | 237 | this._configuration = next; |
| 169 | - this._cache = Object.create(null); | |
| 238 | + this._results = pruned; | |
| 170 | 239 | this._requestGenerations = Object.create(null); |
| 240 | + this._credentialBlocked = next.primApiKey !== null | |
| 241 | + && next.keyStatus === contracts.KEY_STATUS.INVALID; | |
| 171 | 242 | this._refreshWatchState(); |
| 172 | 243 | this._sendConfiguration(); |
| 173 | 244 | }; |
| 174 | 245 | |
| 175 | 246 | 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; | |
| 178 | 251 | if (this._findFavorite(request.favoriteId) === null) { |
| 179 | 252 | this._sendError(request, "INVALID_SERVICE"); |
| 180 | 253 | return; |
| @@ -191,20 +264,22 @@ Companion.prototype._findFavorite = function (favoriteId) { | ||
| 191 | 264 | }; |
| 192 | 265 | |
| 193 | 266 | 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; | |
| 198 | 277 | }; |
| 199 | 278 | |
| 200 | 279 | 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); | |
| 207 | 281 | }; |
| 282 | + | |
| 208 | 283 | Companion.prototype._beginRequest = function (favoriteId) { |
| 209 | 284 | this._requestGeneration += 1; |
| 210 | 285 | this._requestGenerations[favoriteId] = this._requestGeneration; |
| @@ -215,27 +290,36 @@ Companion.prototype._isLatestRequest = function (favoriteId, generation) { | ||
| 215 | 290 | return this._requestGenerations[favoriteId] === generation; |
| 216 | 291 | }; |
| 217 | 292 | |
| 218 | - | |
| 219 | 293 | Companion.prototype._dispatchRequest = function (request) { |
| 220 | - var generation = this._beginRequest(request.favoriteId); | |
| 221 | 294 | var cached = this._cachedResult(request); |
| 295 | + var cachedMessages; | |
| 222 | 296 | var fixtureResult; |
| 223 | 297 | var favorite; |
| 298 | + var generation; | |
| 299 | + var age; | |
| 300 | + var refreshRequired; | |
| 224 | 301 | 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; | |
| 227 | 311 | } |
| 228 | 312 | if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) { |
| 229 | 313 | fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId); |
| 230 | - this._cacheResult(fixtureResult); | |
| 231 | 314 | this._queue.enqueue(codec.encodeResult(fixtureResult)); |
| 232 | 315 | return; |
| 233 | 316 | } |
| 234 | - if (this._configuration.apiKey === null) { | |
| 317 | + if (this._configuration.primApiKey === null) { | |
| 235 | 318 | this._sendError(request, "API_KEY_REQUIRED"); |
| 236 | 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 | 323 | this._sendError(request, "API_KEY_INVALID"); |
| 240 | 324 | return; |
| 241 | 325 | } |
| @@ -244,6 +328,7 @@ Companion.prototype._dispatchRequest = function (request) { | ||
| 244 | 328 | this._sendError(request, "INVALID_SERVICE"); |
| 245 | 329 | return; |
| 246 | 330 | } |
| 331 | + generation = this._beginRequest(request.favoriteId); | |
| 247 | 332 | this._fetch(request, favorite, generation); |
| 248 | 333 | }; |
| 249 | 334 | |
| @@ -251,8 +336,8 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 251 | 336 | var self = this; |
| 252 | 337 | var xhr; |
| 253 | 338 | 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(); | |
| 256 | 341 | if (this._backendUrl.indexOf("https://") !== 0) { |
| 257 | 342 | this._sendError(request, "SOURCE_UNAVAILABLE"); |
| 258 | 343 | return; |
| @@ -260,8 +345,12 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 260 | 345 | |
| 261 | 346 | function once(callback) { |
| 262 | 347 | return function () { |
| 348 | + var latency; | |
| 263 | 349 | if (settled) return; |
| 264 | 350 | settled = true; |
| 351 | + latency = Math.max(0, self._clock.now() - startedAt); | |
| 352 | + self._metrics.lastLatencyMs = latency; | |
| 353 | + self._metrics.totalLatencyMs += latency; | |
| 265 | 354 | if (self._inFlightRequests[generation] === xhr) { |
| 266 | 355 | delete self._inFlightRequests[generation]; |
| 267 | 356 | } |
| @@ -274,17 +363,24 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 274 | 363 | xhr = new this._XHR(); |
| 275 | 364 | xhr.open("POST", this._backendUrl, true); |
| 276 | 365 | xhr.timeout = contracts.LIMITS.httpTimeoutMs; |
| 277 | - xhr.setRequestHeader("Authorization", "Bearer " + key); | |
| 278 | 366 | xhr.setRequestHeader("Content-Type", "application/json"); |
| 279 | 367 | xhr.setRequestHeader("Accept", "application/json"); |
| 280 | 368 | 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)) { | |
| 285 | 378 | key = null; |
| 379 | + this._sendError(request, "API_KEY_REQUIRED"); | |
| 286 | 380 | return; |
| 287 | 381 | } |
| 382 | + xhr.setRequestHeader("Authorization", "Bearer " + key); | |
| 383 | + this._metrics.requests += 1; | |
| 288 | 384 | this._inFlightRequests[generation] = xhr; |
| 289 | 385 | xhr.send(JSON.stringify({ |
| 290 | 386 | schemaVersion: contracts.SCHEMA_VERSION, |
| @@ -300,6 +396,7 @@ Companion.prototype._fetch = function (request, favorite, generation) { | ||
| 300 | 396 | } |
| 301 | 397 | if (!settled) { |
| 302 | 398 | settled = true; |
| 399 | + this._metrics.failures += 1; | |
| 303 | 400 | if (this._isLatestRequest(request.favoriteId, generation)) { |
| 304 | 401 | this._sendError(request, "SOURCE_UNAVAILABLE"); |
| 305 | 402 | } |
| @@ -326,14 +423,21 @@ Companion.prototype._handleResponse = function (request, xhr) { | ||
| 326 | 423 | if (!contracts.isDepartureResult(body) |
| 327 | 424 | || body.requestId !== request.requestId |
| 328 | 425 | || body.favoriteId !== request.favoriteId) { |
| 426 | + this._metrics.failures += 1; | |
| 329 | 427 | this._sendError(request, "INVALID_RESPONSE"); |
| 330 | 428 | return; |
| 331 | 429 | } |
| 430 | + this._metrics.successes += 1; | |
| 332 | 431 | this._cacheResult(body); |
| 333 | 432 | this._queue.enqueue(codec.encodeResult(body)); |
| 334 | 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 | 441 | if (body !== null && contracts.isErrorResult(body)) { |
| 338 | 442 | if (body.requestId !== request.requestId |
| 339 | 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 | 496 | Companion.prototype._sendErrorResult = function (error) { |
| 497 | + var invalid; | |
| 393 | 498 | 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 | + }; | |
| 399 | 524 | }; |
| 400 | 525 | |
| 401 | 526 | 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.clock | 58 | 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.requestId | 424 | || 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.requestId | 442 | 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) { | ||
| 83 | 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 | 90 | FakeStorage.prototype.keys = function () { |
| 87 | 91 | return Object.keys(this.values); |
| 88 | 92 | }; |
| @@ -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 @@ | ||
| 91 | 91 | <h3>Privacy and data flow</h3> |
| 92 | 92 | <p>This app page sets no cookies and uses no analytics or telemetry.</p> |
| 93 | 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 | 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 | 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 | 97 | <h3>Data and credits</h3> |
| @@ -112,6 +113,7 @@ | ||
| 112 | 113 | <h3>Vie privée et circulation des données</h3> |
| 113 | 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 | 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 | 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 | 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 | 119 | <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) { | ||
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 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 | 55 | export function isLineColor(value) { |
| @@ -81,7 +84,7 @@ export const COPY = { | ||
| 81 | 84 | keyRemovePending: "The saved key will be removed.", |
| 82 | 85 | keyReplacementPending: "A replacement key is ready to save.", |
| 83 | 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 | 88 | favoritesTitle: "Favorite departures", |
| 86 | 89 | favoritesEmpty: "No favorites yet. Search for a stop to add one.", |
| 87 | 90 | favoriteAddTitle: "Add a favorite", |
| @@ -129,7 +132,7 @@ export const COPY = { | ||
| 129 | 132 | keyRemovePending: "La clé enregistrée sera supprimée.", |
| 130 | 133 | keyReplacementPending: "Une nouvelle clé est prête à être enregistrée.", |
| 131 | 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 | 136 | favoritesTitle: "Départs favoris", |
| 134 | 137 | favoritesEmpty: "Aucun favori. Recherchez un arrêt pour en ajouter un.", |
| 135 | 138 | favoriteAddTitle: "Ajouter un favori", |
| @@ -379,7 +382,7 @@ export function reduceConfigState(state, action) { | ||
| 379 | 382 | // ApiKeyUpdate carries the value only for REPLACE. |
| 380 | 383 | |
| 381 | 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 | 386 | if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong"; |
| 384 | 387 | return null; |
| 385 | 388 | } |
| @@ -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", | ||
| 196 | 196 | const key = "stored-personal-key"; |
| 197 | 197 | const url = companionConfiguration.configurationUrl( |
| 198 | 198 | "https://config.example.test/index.html", |
| 199 | - { schemaVersion: 1, favorites: fixtureList, apiKey: key }, | |
| 199 | + { schemaVersion: 1, favorites: fixtureList, primApiKey: key, keyStatus: 1 }, | |
| 200 | 200 | "fr_FR", |
| 201 | 201 | ); |
| 202 | 202 | assert.equal(typeof url, "string"); |
| @@ -286,7 +286,7 @@ test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => { | ||
| 286 | 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 | 290 | assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null); |
| 291 | 291 | assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong"); |
| 292 | 292 | assert.equal(apiKeyError("é".repeat(256)), null); |
| @@ -294,6 +294,7 @@ test("replace validation rejects oversized or line-bearing keys in both locales" | ||
| 294 | 294 | assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong"); |
| 295 | 295 | assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline"); |
| 296 | 296 | assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline"); |
| 297 | + assert.equal(apiKeyError("key\u0001header"), "keyErrorNewline"); | |
| 297 | 298 | |
| 298 | 299 | const tooLong = planConfigResult({ |
| 299 | 300 | hasKey: false, |
| @@ -566,6 +567,8 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and | ||
| 566 | 567 | "localStorage", |
| 567 | 568 | "no analytics or telemetry", |
| 568 | 569 | "aucun cookie", |
| 570 | + "survives app updates and uninstall/reinstall", | |
| 571 | + "Désinstaller l’application de la montre", | |
| 569 | 572 | "Référentiel des lignes", |
| 570 | 573 | "conditions d’utilisation IDFM/PRIM", |
| 571 | 574 | "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 { | ||
| 226 | 226 | } |
| 227 | 227 | |
| 228 | 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 | 235 | 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 | ||
| 95 | 95 | assert.equal(isFavorite({ ...favorite, unknown: true }), false); |
| 96 | 96 | assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false); |
| 97 | 97 | assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false); |
| 98 | + assert.equal(isFavorite({ ...favorite, stopLabel: "unsafe\u0001label" }), false); | |
| 98 | 99 | assert.equal(isFavorite({ ...favorite, lineMode: "metro" }), false); |
| 99 | 100 | assert.equal(isFavorite({ ...favorite, lineColor: "#FFBE00" }), false); |
| 100 | 101 | assert.equal(isFavorite({ ...favorite, lineTextColor: "000000" }), false); |
| @@ -137,6 +138,7 @@ test("domain validators enforce exact fields, versions, bounds, and secret rules | ||
| 137 | 138 | assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true); |
| 138 | 139 | assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false); |
| 139 | 140 | assert.equal(isPersonalApiKey("key\nheader"), false); |
| 141 | + assert.equal(isPersonalApiKey("key\u0001header"), false); | |
| 140 | 142 | }); |
| 141 | 143 | |
| 142 | 144 | 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) { | ||
| 145 | 145 | export function boundedString(value, maximum) { |
| 146 | 146 | if (typeof value !== "string") return false; |
| 147 | 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 | 151 | 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 @@ | ||
| 1 | 1 | import { QUEUE_STATE } from "./message-queue.js"; |
| 2 | 2 | import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js"; |
| 3 | +import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js"; | |
| 3 | 4 | |
| 4 | 5 | function eventName(received) { |
| 5 | 6 | if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED"; |
| @@ -24,9 +25,12 @@ export function createController(options) { | ||
| 24 | 25 | const clock = options.clock; |
| 25 | 26 | const queue = options.queue; |
| 26 | 27 | const model = options.model; |
| 28 | + const storage = options.storage || null; | |
| 27 | 29 | const view = options.view; |
| 28 | 30 | const receiver = new ProtocolReceiver(); |
| 31 | + let committedConfiguration = null; | |
| 29 | 32 | let sequence = 0; |
| 33 | + let restoredRequestPending = false; | |
| 30 | 34 | let started = false; |
| 31 | 35 | let closed = false; |
| 32 | 36 | |
| @@ -60,7 +64,16 @@ export function createController(options) { | ||
| 60 | 64 | start() { |
| 61 | 65 | if (closed || started) return controller; |
| 62 | 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 | 77 | return controller; |
| 65 | 78 | }, |
| 66 | 79 | |
| @@ -68,8 +81,21 @@ export function createController(options) { | ||
| 68 | 81 | if (closed) return RECEIVE_RESULT.REJECTED; |
| 69 | 82 | const received = receiver.receive(message); |
| 70 | 83 | const event = eventName(received); |
| 84 | + if (event === "CONFIG_COMMITTED") restoredRequestPending = false; | |
| 71 | 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 | 99 | render(); |
| 74 | 100 | if (effect.requestFixture) requestFixture(); |
| 75 | 101 | } |
| @@ -77,7 +103,13 @@ export function createController(options) { | ||
| 77 | 103 | }, |
| 78 | 104 | |
| 79 | 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 | 113 | receiver.cancelExpectedResponse(); |
| 82 | 114 | model.markSendFailure(); |
| 83 | 115 | 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({ | ||
| 45 | 45 | clock: { now: Date.now }, |
| 46 | 46 | queue, |
| 47 | 47 | model: new WatchModel(), |
| 48 | + storage: localStorage, | |
| 48 | 49 | view |
| 49 | 50 | }); |
| 50 | 51 | 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 | view | 49 | view |
| 49 | }); | 50 | }); |
| 50 | controller.start(); | 51 | controller.start(); |
modified
packages/watch/src/embeddedjs/manifest.json +1 -0 | @@ -11,6 +11,7 @@ | ||
| 11 | 11 | "./protocol", |
| 12 | 12 | "./message-queue", |
| 13 | 13 | "./localization", |
| 14 | + "./storage", | |
| 14 | 15 | "./ui", |
| 15 | 16 | "./contracts" |
| 16 | 17 | ] |
| @@ -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) { | ||
| 262 | 262 | } |
| 263 | 263 | |
| 264 | 264 | export class ProtocolReceiver { |
| 265 | - constructor() { | |
| 265 | + constructor(initialConfiguration = null) { | |
| 266 | 266 | this.configuration = null; |
| 267 | 267 | this.result = null; |
| 268 | 268 | this.error = null; |
| 269 | 269 | this.configStage = null; |
| 270 | 270 | this.resultStage = null; |
| 271 | 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 | 284 | expectResponse(requestId, favoriteId) { |
| 275 | 285 | if (!boundedString(requestId, LIMITS.idUtf8Bytes) |
| 276 | 286 | || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false; |
| 277 | - this.expected = { requestId, favoriteId }; | |
| 287 | + this.expected = { requestId, favoriteId, refresh: false }; | |
| 288 | + this.refreshCandidate = null; | |
| 278 | 289 | this.resultStage = null; |
| 279 | 290 | return true; |
| 280 | 291 | } |
| 281 | 292 | |
| 282 | 293 | cancelExpectedResponse() { |
| 283 | 294 | this.expected = null; |
| 295 | + this.refreshCandidate = null; | |
| 284 | 296 | this.resultStage = null; |
| 285 | 297 | } |
| 286 | 298 | |
| @@ -366,6 +378,24 @@ export class ProtocolReceiver { | ||
| 366 | 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 | 399 | if (type === MESSAGE_TYPE.RESULT_BEGIN) { |
| 370 | 400 | if (!this.expected |
| 371 | 401 | || this.expected.requestId !== requestId |
| @@ -423,9 +453,14 @@ export class ProtocolReceiver { | ||
| 423 | 453 | this.resultStage = null; |
| 424 | 454 | return RECEIVE_RESULT.REJECTED; |
| 425 | 455 | } |
| 456 | + const refresh = this.expected.refresh; | |
| 426 | 457 | this.result = copyResult(stage); |
| 427 | 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 | 464 | return RECEIVE_RESULT.RESULT_COMMITTED; |
| 430 | 465 | } |
| 431 | 466 | |
| @@ -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.expected | 400 | if (!this.expected |
| 371 | || this.expected.requestId !== requestId | 401 | || 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 = [ | ||
| 33 | 33 | "./protocol", |
| 34 | 34 | "./message-queue", |
| 35 | 35 | "./localization", |
| 36 | + "./storage", | |
| 36 | 37 | "./ui", |
| 37 | 38 | "./contracts" |
| 38 | 39 | ]; |
| @@ -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", () = | ||
| 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 | 244 | test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => { |
| 221 | 245 | const receiver = new ProtocolReceiver(); |
| 222 | 246 | commitConfiguration(receiver); |
| @@ -252,3 +276,32 @@ test("matching error commits without destroying the prior complete result", () = | ||
| 252 | 276 | 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 | +}); | |
| @@ -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: 30 | 276 | 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, [ | ||
| 19 | 19 | "packages/companion/test/protocol.test.js", |
| 20 | 20 | "packages/companion/test/companion.test.js", |
| 21 | 21 | "packages/watch/test/manifest.test.js", |
| 22 | + "packages/watch/test/controller-storage.test.js", | |
| 22 | 23 | "packages/watch/test/protocol.test.js", |
| 23 | 24 | "packages/watch/test/message-queue.test.js", |
| 24 | 25 | "packages/watch/test/model.test.js", |
| 26 | + "packages/watch/test/storage.test.js", | |
| 25 | 27 | "packages/watch/test/localization.test.js", |
| 26 | 28 | "packages/watch/test/fixture-boot.test.js", |
| 27 | 29 | ]); |
| @@ -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 | ]); |