@mikrojs/native 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CMakeLists.txt +62 -1
  2. package/dist/index.d.ts +17 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
  7. package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
  8. package/dist/runtime/result/native-result.node-shim.js +41 -0
  9. package/dist/runtime/result/native-result.node-shim.js.map +1 -0
  10. package/dist/runtime/result/types.d.ts +55 -0
  11. package/dist/runtime/result/types.d.ts.map +1 -0
  12. package/dist/runtime/result/types.js +2 -0
  13. package/dist/runtime/result/types.js.map +1 -0
  14. package/dist/runtime/schema/core.d.ts +115 -0
  15. package/dist/runtime/schema/core.d.ts.map +1 -0
  16. package/dist/runtime/schema/core.js +259 -0
  17. package/dist/runtime/schema/core.js.map +1 -0
  18. package/dist/runtime/schema/shared.d.ts +54 -0
  19. package/dist/runtime/schema/shared.d.ts.map +1 -0
  20. package/dist/runtime/schema/shared.js +489 -0
  21. package/dist/runtime/schema/shared.js.map +1 -0
  22. package/dist/types.d.ts +7 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/include/mikrojs/cbor_helpers.h +20 -0
  25. package/include/mikrojs/mem.h +11 -0
  26. package/include/mikrojs/mikrojs.h +2 -1
  27. package/include/mikrojs/ota_client.h +342 -0
  28. package/include/mikrojs/ota_config.h +100 -0
  29. package/include/mikrojs/ota_env.h +192 -0
  30. package/include/mikrojs/ota_js_hooks.h +71 -0
  31. package/include/mikrojs/ota_policy.h +131 -0
  32. package/include/mikrojs/ota_slots.h +47 -0
  33. package/include/mikrojs/sys_codec.h +61 -0
  34. package/package.json +7 -5
  35. package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
  36. package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
  37. package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
  38. package/runtime/internal.d.ts +22 -16
  39. package/runtime/kv/shared.ts +11 -5
  40. package/runtime/kv/types.ts +4 -4
  41. package/runtime/ota/client.ts +12 -51
  42. package/runtime/ota/config.ts +18 -0
  43. package/runtime/ota/ota.ts +28 -70
  44. package/runtime/ota/types.ts +220 -2
  45. package/runtime/schema/core.ts +539 -0
  46. package/runtime/schema/schema.ts +36 -314
  47. package/runtime/schema/shared.ts +494 -0
  48. package/runtime/schema/types.ts +84 -12
  49. package/scripts/bundle-runtime.js +33 -0
  50. package/scripts/gen-checkin-fixtures.js +323 -0
  51. package/src/builtins.cpp +7 -8
  52. package/src/fs.cpp +3 -0
  53. package/src/mem.cpp +38 -0
  54. package/src/mik_abort.cpp +8 -1
  55. package/src/mik_cbor.cpp +43 -5
  56. package/src/mik_inspect.cpp +128 -22
  57. package/src/mik_ota_client.cpp +1230 -0
  58. package/src/mik_ota_config.cpp +296 -0
  59. package/src/mik_ota_js_hooks.cpp +190 -0
  60. package/src/mik_ota_policy.cpp +419 -0
  61. package/src/mik_ota_slots.cpp +249 -0
  62. package/src/mik_repl.cpp +9 -3
  63. package/src/mik_result.cpp +3 -1
  64. package/src/mik_sys_codec.cpp +167 -0
  65. package/src/mikrojs.cpp +15 -0
  66. package/src/modules.cpp +32 -13
  67. package/runtime/ota/client-impl.ts +0 -590
  68. package/runtime/ota/policy.ts +0 -299
@@ -0,0 +1,1230 @@
1
+ #include "mikrojs/ota_client.h"
2
+
3
+ #include <nanocbor/nanocbor.h>
4
+
5
+ #include <cmath>
6
+ #include <cstdarg>
7
+ #include <cstdio>
8
+ #include <cstring>
9
+ #include <string>
10
+ #include <vector>
11
+
12
+ #include "mikrojs/cbor_helpers.h"
13
+ #include "mikrojs/ota_slots.h"
14
+ #include "mikrojs/platform.h"
15
+
16
+ namespace mikrojs {
17
+
18
+ const char* mik__ota_decline_reason_to_str(MIKOtaDeclineReason reason) {
19
+ switch (reason) {
20
+ case MIKOtaDeclineReason::kTrialPending:
21
+ return "trial-pending";
22
+ case MIKOtaDeclineReason::kCurrent:
23
+ return "current";
24
+ case MIKOtaDeclineReason::kAbandoned:
25
+ return "abandoned";
26
+ case MIKOtaDeclineReason::kExhausted:
27
+ return "exhausted";
28
+ case MIKOtaDeclineReason::kDownloadFailed:
29
+ return "download-failed";
30
+ case MIKOtaDeclineReason::kInstallFailed:
31
+ return "install-failed";
32
+ }
33
+ return "unknown";
34
+ }
35
+
36
+ const char* mik__ota_check_status_to_str(MIKOtaCheckStatus status) {
37
+ switch (status) {
38
+ case MIKOtaCheckStatus::kStaged:
39
+ return "staged";
40
+ case MIKOtaCheckStatus::kUpToDate:
41
+ return "up-to-date";
42
+ case MIKOtaCheckStatus::kNotStaged:
43
+ return "not-staged";
44
+ case MIKOtaCheckStatus::kFailed:
45
+ return "failed";
46
+ case MIKOtaCheckStatus::kUnauthorized:
47
+ return "unauthorized";
48
+ case MIKOtaCheckStatus::kNotEnrolled:
49
+ return "not-enrolled";
50
+ }
51
+ return "unknown";
52
+ }
53
+
54
+ bool mik__ota_same_origin(const std::string& a, const std::string& b) {
55
+ auto origin = [](const std::string& url) -> std::string {
56
+ size_t sep = url.find("://");
57
+ if (sep == std::string::npos) return "";
58
+ size_t start = sep + 3;
59
+ size_t end = url.find('/', start);
60
+ std::string authority =
61
+ (end == std::string::npos) ? url.substr(start) : url.substr(start, end - start);
62
+ if (authority.empty()) return "";
63
+ std::string out = url.substr(0, start) + authority;
64
+ for (char& c : out) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
65
+ return out;
66
+ };
67
+ std::string left = origin(a);
68
+ return !left.empty() && left == origin(b);
69
+ }
70
+
71
+ bool mik__ota_is_private_http(const std::string& url) {
72
+ if (url.rfind("http://", 0) != 0) return false;
73
+ std::string rest = url.substr(7);
74
+ size_t slash = rest.find('/');
75
+ std::string host_port = (slash == std::string::npos) ? rest : rest.substr(0, slash);
76
+ size_t colon = host_port.find(':');
77
+ std::string host = (colon == std::string::npos) ? host_port : host_port.substr(0, colon);
78
+
79
+ if (host == "localhost") return true;
80
+ if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
81
+
82
+ // Dotted quad only: anything else is a name, and a name is not private.
83
+ int p[4] = {};
84
+ char extra = 0;
85
+ if (sscanf(host.c_str(), "%d.%d.%d.%d%c", &p[0], &p[1], &p[2], &p[3], &extra) != 4) {
86
+ return false;
87
+ }
88
+ for (int part : p) {
89
+ if (part < 0 || part > 255) return false;
90
+ }
91
+ if (p[0] == 10 || p[0] == 127) return true;
92
+ if (p[0] == 192 && p[1] == 168) return true;
93
+ if (p[0] == 169 && p[1] == 254) return true;
94
+ return p[0] == 172 && p[1] >= 16 && p[1] <= 31;
95
+ }
96
+
97
+ namespace {
98
+
99
+ /* console.warn, so a misconfigured registry says so on the same channel the
100
+ * TypeScript client used. */
101
+ void warn_offer(JSContext* ctx, const char* reason) {
102
+ JSValue global = JS_GetGlobalObject(ctx);
103
+ JSValue console = JS_GetPropertyStr(ctx, global, "console");
104
+ JSValue warn = JS_GetPropertyStr(ctx, console, "warn");
105
+ if (JS_IsFunction(ctx, warn)) {
106
+ char buf[256];
107
+ snprintf(buf, sizeof(buf), "ota: ignoring offer (%s)", reason);
108
+ JSValue msg = JS_NewString(ctx, buf);
109
+ JSValue ret = JS_Call(ctx, warn, console, 1, &msg);
110
+ JS_FreeValue(ctx, ret);
111
+ JS_FreeValue(ctx, msg);
112
+ }
113
+ JS_FreeValue(ctx, warn);
114
+ JS_FreeValue(ctx, console);
115
+ JS_FreeValue(ctx, global);
116
+ }
117
+
118
+ } // namespace
119
+
120
+ bool mik__ota_parse_offer_js(JSContext* ctx, JSValueConst raw, bool allow_insecure,
121
+ MIKOtaOffer* out_offer) {
122
+ // null/undefined is the registry's "no update available" signal, not a
123
+ // malformed offer, so it returns quietly.
124
+ if (JS_IsNull(raw) || JS_IsUndefined(raw)) return false;
125
+ if (!JS_IsObject(raw)) {
126
+ warn_offer(ctx, "not an object");
127
+ return false;
128
+ }
129
+
130
+ // No url is "no update", not a malformed offer: a check-in with nothing
131
+ // newer still returns a body when the registry has something else to say —
132
+ // a name to adopt, say. Warning here would log on every device whenever a
133
+ // dashboard rename coincided with "up to date".
134
+ JSValue url_val = JS_GetPropertyStr(ctx, raw, "url");
135
+ if (JS_IsUndefined(url_val)) {
136
+ JS_FreeValue(ctx, url_val);
137
+ return false;
138
+ }
139
+ if (!JS_IsString(url_val)) {
140
+ JS_FreeValue(ctx, url_val);
141
+ warn_offer(ctx, "missing url");
142
+ return false;
143
+ }
144
+ const char* url_cstr = JS_ToCString(ctx, url_val);
145
+ std::string url = url_cstr ? url_cstr : "";
146
+ if (url_cstr) JS_FreeCString(ctx, url_cstr);
147
+ JS_FreeValue(ctx, url_val);
148
+
149
+ JSValue csum_val = JS_GetPropertyStr(ctx, raw, "checksum");
150
+ bool csum_is_str = JS_IsString(csum_val);
151
+ std::string csum;
152
+ if (csum_is_str) {
153
+ const char* c = JS_ToCString(ctx, csum_val);
154
+ if (c) {
155
+ csum = c;
156
+ JS_FreeCString(ctx, c);
157
+ }
158
+ }
159
+ JS_FreeValue(ctx, csum_val);
160
+
161
+ // Must be a positive integer: a negative size fails the first write with
162
+ // TooLarge, and 0 disables the cap on both sides.
163
+ JSValue size_val = JS_GetPropertyStr(ctx, raw, "size");
164
+ int64_t size = -1;
165
+ if (JS_IsNumber(size_val)) {
166
+ double d = 0;
167
+ if (JS_ToFloat64(ctx, &d, size_val) == 0 && d > 0 &&
168
+ d == static_cast<double>(static_cast<int64_t>(d))) {
169
+ size = static_cast<int64_t>(d);
170
+ }
171
+ }
172
+ JS_FreeValue(ctx, size_val);
173
+
174
+ std::string reason;
175
+ if (!mik__ota_parse_offer(url.c_str(), csum_is_str ? csum.c_str() : nullptr, size,
176
+ allow_insecure, out_offer, &reason)) {
177
+ if (!reason.empty()) warn_offer(ctx, reason.c_str());
178
+ return false;
179
+ }
180
+ return true;
181
+ }
182
+
183
+ // ── CBOR ─────────────────────────────────────────────────────────────────────
184
+
185
+ namespace {
186
+
187
+ /* Encode twice: once against a zero-capacity buffer to size it, once for real.
188
+ * The measuring base must be non-null: zero-length appends still reach memcpy,
189
+ * and memcpy(NULL, ..., 0) is undefined behavior. */
190
+ template <typename Fn>
191
+ std::vector<uint8_t> encode_to_vector(Fn&& encode) {
192
+ static uint8_t measure_base;
193
+ nanocbor_encoder_t enc;
194
+ nanocbor_encoder_init(&enc, &measure_base, 0);
195
+ encode(&enc);
196
+ std::vector<uint8_t> out(nanocbor_encoded_len(&enc));
197
+ nanocbor_encoder_init(&enc, out.data(), out.size());
198
+ encode(&enc);
199
+ return out;
200
+ }
201
+
202
+ /* Read a text string into a fixed field. Returns false without consuming when
203
+ * the next item is not a string. */
204
+ bool take_str(nanocbor_value_t* it, char* out, size_t out_len) {
205
+ const uint8_t* ptr = nullptr;
206
+ size_t len = 0;
207
+ if (nanocbor_get_tstr(it, &ptr, &len) < 0) return false;
208
+ snprintf(out, out_len, "%.*s", static_cast<int>(len), reinterpret_cast<const char*>(ptr));
209
+ return true;
210
+ }
211
+
212
+ bool take_string(nanocbor_value_t* it, std::string* out) {
213
+ const uint8_t* ptr = nullptr;
214
+ size_t len = 0;
215
+ if (nanocbor_get_tstr(it, &ptr, &len) < 0) return false;
216
+ out->assign(reinterpret_cast<const char*>(ptr), len);
217
+ return true;
218
+ }
219
+
220
+ /* Read the next map key as a string, or false when the map is malformed. */
221
+ bool take_key(nanocbor_value_t* map, std::string* out) { return take_string(map, out); }
222
+
223
+ void encode_report(nanocbor_encoder_t* enc, const MIKOtaCheckinFacts& f) {
224
+ const MIKDeviceIdentity& identity = *f.identity;
225
+ const MIKOtaRunningBuild& running = *f.running;
226
+ bool has_echo = f.echo_rev && f.echo_rev[0];
227
+
228
+ size_t map_size = 6; // deviceId, firmware, firmwareHash, bytecode, running, name
229
+ if (f.has_free) map_size++;
230
+ if (f.last_install) map_size++;
231
+ if (has_echo) map_size++;
232
+ if (f.config_error) map_size++;
233
+ if (f.last_decline) map_size++;
234
+
235
+ nanocbor_fmt_map(enc, map_size);
236
+
237
+ nanocbor_put_tstr(enc, "deviceId");
238
+ nanocbor_put_tstr(enc, identity.device_id);
239
+ nanocbor_put_tstr(enc, "firmware");
240
+ nanocbor_put_tstr(enc, identity.firmware_version);
241
+ nanocbor_put_tstr(enc, "firmwareHash");
242
+ nanocbor_put_tstr(enc, identity.firmware_hash);
243
+ nanocbor_put_tstr(enc, "bytecode");
244
+ nanocbor_fmt_int(enc, identity.bytecode_version);
245
+
246
+ size_t run_size = 1; // trial
247
+ if (running.checksum[0]) run_size++;
248
+ if (running.version[0]) run_size++;
249
+ nanocbor_put_tstr(enc, "running");
250
+ nanocbor_fmt_map(enc, run_size);
251
+ nanocbor_put_tstr(enc, "trial");
252
+ nanocbor_fmt_bool(enc, running.trial);
253
+ if (running.checksum[0]) {
254
+ nanocbor_put_tstr(enc, "checksum");
255
+ nanocbor_put_tstr(enc, running.checksum);
256
+ }
257
+ if (running.version[0]) {
258
+ nanocbor_put_tstr(enc, "version");
259
+ nanocbor_put_tstr(enc, running.version);
260
+ }
261
+
262
+ // The name pair: [rev] when cleared, [rev, name] otherwise. Sent every time
263
+ // so a lost response settles on the next check-in.
264
+ nanocbor_put_tstr(enc, "name");
265
+ if (f.name && f.name[0]) {
266
+ nanocbor_fmt_array(enc, 2);
267
+ nanocbor_fmt_int(enc, f.name_rev);
268
+ nanocbor_put_tstr(enc, f.name);
269
+ } else {
270
+ nanocbor_fmt_array(enc, 1);
271
+ nanocbor_fmt_int(enc, f.name_rev);
272
+ }
273
+
274
+ if (f.has_free) {
275
+ nanocbor_put_tstr(enc, "free");
276
+ nanocbor_fmt_uint(enc, f.free_bytes);
277
+ }
278
+
279
+ if (f.last_install) {
280
+ nanocbor_put_tstr(enc, "lastInstall");
281
+ nanocbor_fmt_map(enc, f.last_install->detail[0] ? 2 : 1);
282
+ nanocbor_put_tstr(enc, "reason");
283
+ nanocbor_put_tstr(enc, f.last_install->reason);
284
+ if (f.last_install->detail[0]) {
285
+ nanocbor_put_tstr(enc, "detail");
286
+ nanocbor_put_tstr(enc, f.last_install->detail);
287
+ }
288
+ }
289
+
290
+ // Why the last offered build was not taken. Without it a registry cannot
291
+ // tell a device still working through a download from one that has stopped.
292
+ if (f.last_decline) {
293
+ nanocbor_put_tstr(enc, "lastDecline");
294
+ nanocbor_fmt_map(enc, f.last_decline->detail.empty() ? 2 : 3);
295
+ nanocbor_put_tstr(enc, "checksum");
296
+ nanocbor_put_tstr(enc, f.last_decline->checksum);
297
+ nanocbor_put_tstr(enc, "reason");
298
+ nanocbor_put_tstr(enc, mik__ota_decline_reason_to_str(f.last_decline->reason));
299
+ if (!f.last_decline->detail.empty()) {
300
+ nanocbor_put_tstr(enc, "detail");
301
+ nanocbor_put_tstr(enc, f.last_decline->detail.c_str());
302
+ }
303
+ }
304
+
305
+ if (has_echo) {
306
+ nanocbor_put_tstr(enc, "configRev");
307
+ nanocbor_put_tstr(enc, f.echo_rev);
308
+ }
309
+
310
+ if (f.config_error) {
311
+ nanocbor_put_tstr(enc, "configError");
312
+ nanocbor_fmt_map(enc, 2);
313
+ nanocbor_put_tstr(enc, "rev");
314
+ nanocbor_put_tstr(enc, f.config_error->rev);
315
+ nanocbor_put_tstr(enc, "message");
316
+ nanocbor_put_tstr(enc, f.config_error->message);
317
+ }
318
+ }
319
+
320
+ } // namespace
321
+
322
+ std::vector<uint8_t> mik__ota_build_checkin_report(const MIKOtaCheckinFacts& facts) {
323
+ return encode_to_vector(
324
+ [&](nanocbor_encoder_t* enc) { encode_report(enc, facts); });
325
+ }
326
+
327
+ // ── MIKOtaClient ─────────────────────────────────────────────────────────────
328
+
329
+ MIKOtaClient::MIKOtaClient(const MIKOtaEnv* env) : env_(env) {}
330
+ MIKOtaClient::~MIKOtaClient() {
331
+ if (http_handle_ && env_ && env_->http_cancel) {
332
+ env_->http_cancel(env_->opaque, http_handle_);
333
+ }
334
+ }
335
+
336
+ int64_t MIKOtaClient::Now() const {
337
+ return (env_ && env_->monotonic_ms) ? env_->monotonic_ms(env_->opaque) : 0;
338
+ }
339
+
340
+ void MIKOtaClient::Log(int level, const char* fmt, ...) const {
341
+ if (!env_ || !env_->log) return;
342
+ char buf[320];
343
+ va_list args;
344
+ va_start(args, fmt);
345
+ vsnprintf(buf, sizeof(buf), fmt, args);
346
+ va_end(args);
347
+ env_->log(env_->opaque, level, "%s", buf);
348
+ }
349
+
350
+ bool MIKOtaClient::Enrollment(std::string* out_url, std::string* out_bearer) const {
351
+ // Written as a pair over the cable by `mikro ota enroll`; enrollment is the
352
+ // opt-in, and update keys never arrive over the network.
353
+ char url[256] = {};
354
+ char bearer[128] = {};
355
+ if (!env_ || !env_->kv_get_str) return false;
356
+ if (!env_->kv_get_str(env_->opaque, "ota.registry", url, sizeof(url)) || url[0] == '\0') {
357
+ return false;
358
+ }
359
+ if (!env_->kv_get_str(env_->opaque, "ota.updateKey", bearer, sizeof(bearer)) ||
360
+ bearer[0] == '\0') {
361
+ return false;
362
+ }
363
+ *out_url = url;
364
+ *out_bearer = bearer;
365
+ return true;
366
+ }
367
+
368
+ void MIKOtaClient::Check(const MIKOtaCheckOptions& options, MIKOtaCheckSink sink) {
369
+ Round round;
370
+ round.options = options;
371
+ round.sink = std::move(sink);
372
+ if (!Enrollment(&round.registry_url, &round.bearer)) {
373
+ MIKOtaCheckResult result;
374
+ result.status = MIKOtaCheckStatus::kNotEnrolled;
375
+ if (round.sink) round.sink(result);
376
+ return;
377
+ }
378
+ queue_.push_back(std::move(round));
379
+ }
380
+
381
+ /* Floor for the check-in cadence: each round's TLS session leaves heap and
382
+ * socket residue that needs time to drain, and the value may arrive from remote
383
+ * config — the floor is what bounds the damage a mistyped document can do. */
384
+ constexpr uint32_t kMinCheckinIntervalMs = 30000;
385
+
386
+ void MIKOtaClient::Watch(const MIKOtaWatchOptions& options) {
387
+ if (!Enrollment(&watch_registry_url_, &watch_bearer_)) {
388
+ Log(MIK_LOG_INFO, "ota: device not enrolled; run `mikro ota enroll` to enable OTA updates");
389
+ return;
390
+ }
391
+ watch_options_ = options;
392
+ if (watch_options_.checkin_interval_ms < kMinCheckinIntervalMs) {
393
+ watch_options_.checkin_interval_ms = kMinCheckinIntervalMs;
394
+ }
395
+ if (watch_options_.retry_after_failure_ms > watch_options_.checkin_interval_ms) {
396
+ watch_options_.retry_after_failure_ms = watch_options_.checkin_interval_ms;
397
+ }
398
+ watching_ = true;
399
+ watch_stopped_ = false;
400
+ scheduled_delay_ms_ = Jitter(watch_options_.initial_delay_ms);
401
+ wait_started_ms_ = Now();
402
+ deadline_ms_ = wait_started_ms_ + scheduled_delay_ms_;
403
+ pending_wait_ = PendingWait::kInitial;
404
+ }
405
+
406
+ void MIKOtaClient::SetCheckinInterval(uint32_t interval_ms) {
407
+ if (!watching_ || watch_stopped_) return;
408
+ uint32_t floored =
409
+ interval_ms < kMinCheckinIntervalMs ? kMinCheckinIntervalMs : interval_ms;
410
+ if (floored == watch_options_.checkin_interval_ms) return;
411
+ watch_options_.checkin_interval_ms = floored;
412
+ /* The retry interval is capped at the check-in interval, so a cadence drop
413
+ * pulls it down with it. */
414
+ if (watch_options_.retry_after_failure_ms > floored) {
415
+ watch_options_.retry_after_failure_ms = floored;
416
+ }
417
+
418
+ if (pending_wait_ != PendingWait::kInterval && pending_wait_ != PendingWait::kRetry) return;
419
+ int64_t base = pending_wait_ == PendingWait::kInterval
420
+ ? watch_options_.checkin_interval_ms
421
+ : watch_options_.retry_after_failure_ms;
422
+ scheduled_delay_ms_ = Jitter(base);
423
+ /* From when the wait began, not from now: re-timing against `now` on every
424
+ * change would let a config that changes often push the next round away
425
+ * indefinitely. */
426
+ deadline_ms_ = wait_started_ms_ + scheduled_delay_ms_;
427
+ }
428
+
429
+ void MIKOtaClient::StopWatch() {
430
+ watch_stopped_ = true;
431
+ deadline_ms_ = -1;
432
+ scheduled_delay_ms_ = -1;
433
+ pending_wait_ = PendingWait::kNone;
434
+ }
435
+
436
+ int64_t MIKOtaClient::Jitter(int64_t ms) {
437
+ if (!watch_options_.jitter) return ms;
438
+ double fraction =
439
+ (env_ && env_->random_fraction) ? env_->random_fraction(env_->opaque) : 0.5;
440
+ return static_cast<int64_t>(std::llround(static_cast<double>(ms) * (0.9 + fraction * 0.2)));
441
+ }
442
+
443
+ void MIKOtaClient::Poll() {
444
+ while (Step()) {
445
+ }
446
+ }
447
+
448
+ bool MIKOtaClient::Step() {
449
+ switch (state_) {
450
+ case MIKOtaClientState::kIdle: {
451
+ if (!queue_.empty()) {
452
+ StartRound();
453
+ return true;
454
+ }
455
+ if (watching_ && !watch_stopped_ && deadline_ms_ >= 0 && Now() >= deadline_ms_) {
456
+ Round round;
457
+ round.is_watch = true;
458
+ round.options = watch_options_;
459
+ round.registry_url = watch_registry_url_;
460
+ round.bearer = watch_bearer_;
461
+ queue_.push_back(std::move(round));
462
+ deadline_ms_ = -1;
463
+ pending_wait_ = PendingWait::kNone;
464
+ return true;
465
+ }
466
+ return false;
467
+ }
468
+ case MIKOtaClientState::kBeforeCheck: {
469
+ MIKOtaHookState hook = watch_options_.hooks->PollBeforeCheck();
470
+ if (hook == MIKOtaHookState::kPending) return false;
471
+ if (hook == MIKOtaHookState::kFailed) {
472
+ Log(MIK_LOG_WARN, "ota: beforeCheck failed; skipping this check");
473
+ // No teardown: unwinding a partial setup is the hook's own job.
474
+ Finalize(NextRound::kSoon);
475
+ return true;
476
+ }
477
+ teardown_armed_ = true;
478
+ BeginCheckIn();
479
+ return true;
480
+ }
481
+ case MIKOtaClientState::kCheckIn: {
482
+ if (!http_done_) return false;
483
+ OnCheckInSettled();
484
+ return true;
485
+ }
486
+ case MIKOtaClientState::kDownload: {
487
+ // Nothing more can be written, so the rest of the body is a whole
488
+ // firmware image pulled over TLS to be discarded. Cancel from here
489
+ // rather than from the write callback, which is the transport's own
490
+ // stack.
491
+ if (write_failed_ && !http_done_ && http_handle_ && env_->http_cancel) {
492
+ env_->http_cancel(env_->opaque, http_handle_);
493
+ http_handle_ = nullptr;
494
+ http_done_ = true;
495
+ }
496
+ if (!http_done_) return false;
497
+ OnDownloadSettled();
498
+ return true;
499
+ }
500
+ case MIKOtaClientState::kTeardown: {
501
+ MIKOtaHookState hook = watch_options_.hooks->PollTeardown();
502
+ if (hook == MIKOtaHookState::kPending) return false;
503
+ if (hook == MIKOtaHookState::kFailed) Log(MIK_LOG_WARN, "ota: teardown failed");
504
+ Finalize(pending_next_);
505
+ return true;
506
+ }
507
+ }
508
+ return false;
509
+ }
510
+
511
+ void MIKOtaClient::StartRound() {
512
+ active_ = std::move(queue_.front());
513
+ queue_.erase(queue_.begin());
514
+ result_ = MIKOtaCheckResult();
515
+ teardown_armed_ = false;
516
+
517
+ if (active_.is_watch && watch_options_.hooks && watch_options_.hooks->BeginBeforeCheck()) {
518
+ state_ = MIKOtaClientState::kBeforeCheck;
519
+ return;
520
+ }
521
+ BeginCheckIn();
522
+ }
523
+
524
+ void MIKOtaClient::NoteDecline(MIKOtaDeclineReason reason, const std::string& detail) {
525
+ // `current` and `trial-pending` are the policy working as intended, not a
526
+ // build the device failed to take, so there is nothing to report.
527
+ if (reason == MIKOtaDeclineReason::kCurrent || reason == MIKOtaDeclineReason::kTrialPending) {
528
+ return;
529
+ }
530
+ has_last_decline_ = true;
531
+ last_decline_ = MIKOtaDeclineReport();
532
+ snprintf(last_decline_.checksum, sizeof(last_decline_.checksum), "%s", offer_.checksum.c_str());
533
+ last_decline_.reason = reason;
534
+ last_decline_.detail = detail;
535
+ }
536
+
537
+ void MIKOtaClient::ReconcileOnce() {
538
+ if (reconciled_) return;
539
+ reconciled_ = true;
540
+ MIKOtaReconcileOutcome report = mik__ota_policy_reconcile(env_);
541
+ if (report.has_diagnostic) {
542
+ has_last_install_ = true;
543
+ last_install_ = report.diagnostic;
544
+ }
545
+
546
+ bool touches_config = report.installed[0] != '\0' || report.reverted;
547
+ std::vector<uint8_t> before;
548
+ if (touches_config) before = mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT).bytes;
549
+
550
+ // Promote before restore: a trial that crashed before its first check-in
551
+ // reports installed AND reverted on the same boot, and the promote is what
552
+ // writes the `prev` baseline the restore reads back. Restoring first would
553
+ // find `prev` empty and wipe every slot instead.
554
+ if (report.installed[0] != '\0') {
555
+ Log(MIK_LOG_INFO, "ota: installed build %.12s on this boot", report.installed);
556
+ MIKOtaLoadedConfig previous = mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT);
557
+ if (previous.present) {
558
+ mik__ota_store_slot(env_, MIK_OTA_CFG_PREV, previous.cfg);
559
+ } else {
560
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
561
+ }
562
+ MIKOtaLoadedConfig next = mik__ota_load_slot(env_, MIK_OTA_CFG_NEXT);
563
+ if (next.present) {
564
+ mik__ota_store_slot(env_, MIK_OTA_CFG_CURRENT, next.cfg);
565
+ } else {
566
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_CURRENT);
567
+ }
568
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_NEXT);
569
+ // The staged document rides the BUILD trial; a running-release trial or
570
+ // rollback report from the previous build is settled by the install.
571
+ mik__ota_clear_trial(env_);
572
+ mik__ota_clear_config_error(env_);
573
+ }
574
+
575
+ if (report.reverted) {
576
+ Log(MIK_LOG_WARN, "ota: previous update failed its trial and was rolled back");
577
+ // The config pairs with the build: restore the document the previous
578
+ // build ran with, exactly as the build itself was restored.
579
+ MIKOtaLoadedConfig previous = mik__ota_load_slot(env_, MIK_OTA_CFG_PREV);
580
+ if (previous.present) {
581
+ mik__ota_store_slot(env_, MIK_OTA_CFG_CURRENT, previous.cfg);
582
+ } else {
583
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_CURRENT);
584
+ }
585
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
586
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_NEXT);
587
+ mik__ota_clear_trial(env_);
588
+ mik__ota_clear_config_error(env_);
589
+ }
590
+
591
+ if (touches_config && mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT).bytes != before) {
592
+ boot_config_changed_ = true;
593
+ }
594
+ }
595
+
596
+ void MIKOtaClient::BeginCheckIn() {
597
+ allow_insecure_ = mik__ota_is_private_http(active_.registry_url);
598
+ if (allow_insecure_ && !warned_insecure_) {
599
+ // Every boot, not once at enrollment: a device that quietly runs a
600
+ // forgeable update channel should say so for as long as it does.
601
+ warned_insecure_ = true;
602
+ Log(MIK_LOG_WARN,
603
+ "ota: registry is http:// on a private network — updates are NOT authenticated");
604
+ }
605
+ ReconcileOnce();
606
+
607
+ running_ = mik__ota_policy_running(env_);
608
+ MIKDeviceIdentity identity = {};
609
+ if (env_ && env_->identity) env_->identity(env_->opaque, &identity);
610
+
611
+ int name_rev = 0;
612
+ char name[64] = {};
613
+ bool has_name = env_ && env_->get_device_name &&
614
+ env_->get_device_name(env_->opaque, &name_rev, name, sizeof(name));
615
+
616
+ size_t free_bytes = 0;
617
+ bool has_free = env_ && env_->storage_free && env_->storage_free(env_->opaque, &free_bytes);
618
+
619
+ MIKOtaLoadedConfig held = mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT);
620
+ MIKOtaConfigErrorReport config_error = {};
621
+ bool has_config_error = mik__ota_load_config_error(env_, &config_error);
622
+ // After a config rollback the FAILED document's rev is echoed, not the
623
+ // restored one's: echoed-equals-expected is what keeps the registry from
624
+ // re-serving the document that just failed, until an operator changes it.
625
+ const char* echo_rev = has_config_error ? config_error.rev : (held.present ? held.cfg.rev : "");
626
+
627
+ Log(MIK_LOG_DEBUG, "ota: checking %s/api/v1/checkin (running %s v%s, bytecode %d, fw %s)",
628
+ active_.registry_url.c_str(), running_.checksum[0] ? running_.checksum : "none",
629
+ running_.version[0] ? running_.version : "?", identity.bytecode_version,
630
+ identity.firmware_version);
631
+
632
+ MIKOtaCheckinFacts facts;
633
+ facts.identity = &identity;
634
+ facts.running = &running_;
635
+ facts.name_rev = name_rev;
636
+ facts.name = has_name ? name : nullptr;
637
+ facts.has_free = has_free;
638
+ facts.free_bytes = free_bytes;
639
+ facts.last_install = has_last_install_ ? &last_install_ : nullptr;
640
+ facts.echo_rev = echo_rev;
641
+ facts.config_error = has_config_error ? &config_error : nullptr;
642
+ facts.last_decline = has_last_decline_ ? &last_decline_ : nullptr;
643
+ std::vector<uint8_t> body = mik__ota_build_checkin_report(facts);
644
+
645
+ std::string url = active_.registry_url + "/api/v1/checkin";
646
+ std::string auth = "Bearer " + active_.bearer;
647
+ const char* keys[] = {"content-type", "accept", "authorization"};
648
+ const char* values[] = {"application/cbor", "application/cbor", auth.c_str()};
649
+
650
+ MIKOtaHttpRequest req = {};
651
+ req.url = url.c_str();
652
+ req.method = "POST";
653
+ req.header_keys = keys;
654
+ req.header_values = values;
655
+ req.header_count = 3;
656
+ req.body = body.data();
657
+ req.body_len = body.size();
658
+ req.timeout_ms = active_.options.checkin_timeout_ms;
659
+
660
+ http_done_ = false;
661
+ http_status_ = 0;
662
+ http_error_.clear();
663
+ response_body_.clear();
664
+ response_too_large_ = false;
665
+ state_ = MIKOtaClientState::kCheckIn;
666
+
667
+ MIKOtaHttpCallbacks cbs = {HeadersThunk, DataThunk, DoneThunk, this};
668
+ http_handle_ = (env_ && env_->http_request) ? env_->http_request(env_->opaque, &req, &cbs)
669
+ : nullptr;
670
+ if (!http_handle_) {
671
+ // Nothing will call back, so settle the exchange here.
672
+ http_done_ = true;
673
+ http_status_ = 0;
674
+ if (http_error_.empty()) http_error_ = "request could not be started";
675
+ }
676
+ }
677
+
678
+ void MIKOtaClient::HeadersThunk(void* ud, int status) {
679
+ static_cast<MIKOtaClient*>(ud)->http_status_ = status;
680
+ }
681
+
682
+ void MIKOtaClient::DataThunk(void* ud, const uint8_t* data, size_t len) {
683
+ auto* self = static_cast<MIKOtaClient*>(ud);
684
+ bool status_known = self->http_status_ != 0;
685
+ bool status_ok = self->http_status_ >= 200 && self->http_status_ < 300;
686
+ if (status_known && !status_ok) return; // an error body is not worth the heap
687
+
688
+ if (self->state_ == MIKOtaClientState::kCheckIn) {
689
+ if (self->response_body_.size() + len > MIK__OTA_MAX_RESPONSE_BYTES) {
690
+ self->response_too_large_ = true;
691
+ return;
692
+ }
693
+ self->response_body_.insert(self->response_body_.end(), data, data + len);
694
+ return;
695
+ }
696
+
697
+ if (self->state_ != MIKOtaClientState::kDownload || self->write_failed_) return;
698
+ // A server free to ignore Range answers 200 with the whole build. Drop the
699
+ // prefix already on flash rather than failing, which would spend a retry
700
+ // for nothing.
701
+ if (self->download_skip_ > 0) {
702
+ if (len <= self->download_skip_) {
703
+ self->download_skip_ -= len;
704
+ return;
705
+ }
706
+ data += self->download_skip_;
707
+ len -= self->download_skip_;
708
+ self->download_skip_ = 0;
709
+ }
710
+ if (self->apply_.update && !self->apply_.update->write(data, len, &self->write_error_)) {
711
+ self->write_failed_ = true;
712
+ }
713
+ }
714
+
715
+ void MIKOtaClient::DoneThunk(void* ud, int status, const char* error_msg) {
716
+ auto* self = static_cast<MIKOtaClient*>(ud);
717
+ if (status != 0) self->http_status_ = status;
718
+ if (error_msg) self->http_error_ = error_msg;
719
+ self->http_done_ = true;
720
+ self->http_handle_ = nullptr;
721
+ }
722
+
723
+ void MIKOtaClient::OnCheckInSettled() {
724
+ if (http_status_ == 0 || !http_error_.empty()) {
725
+ // A transport error outranks the status: a body cut short mid-transfer
726
+ // is a round that did not complete, not one that answered badly.
727
+ Log(MIK_LOG_ERROR, "ota: checkin failed: %s",
728
+ http_error_.empty() ? "no response" : http_error_.c_str());
729
+ result_.status = MIKOtaCheckStatus::kFailed;
730
+ result_.error.name = "Network";
731
+ result_.error.message = http_error_;
732
+ FinishRound(NextRound::kSoon);
733
+ return;
734
+ }
735
+ if (http_status_ == 401) {
736
+ // The update key no longer authenticates (rotated, device deleted).
737
+ // There is no fallback secret — re-enroll over the cable.
738
+ Log(MIK_LOG_ERROR,
739
+ "ota: registry rejected the update key; re-enroll with `mikro ota enroll --re-enroll`");
740
+ result_.status = MIKOtaCheckStatus::kUnauthorized;
741
+ result_.http_status = 401;
742
+ FinishRound(NextRound::kLater);
743
+ return;
744
+ }
745
+ if (http_status_ == 415) {
746
+ // The wire is CBOR-only by design; there is no JSON fallback to hide a
747
+ // registry that predates it.
748
+ Log(MIK_LOG_ERROR,
749
+ "ota: registry does not accept CBOR check-ins; upgrade the registry to a version that "
750
+ "supports application/cbor");
751
+ result_.status = MIKOtaCheckStatus::kFailed;
752
+ result_.http_status = 415;
753
+ result_.error.name = "Status";
754
+ FinishRound(NextRound::kSoon);
755
+ return;
756
+ }
757
+ if (http_status_ < 200 || http_status_ >= 300) {
758
+ Log(MIK_LOG_ERROR, "ota: checkin returned status %d", http_status_);
759
+ result_.status = MIKOtaCheckStatus::kFailed;
760
+ result_.http_status = http_status_;
761
+ result_.error.name = "Status";
762
+ FinishRound(NextRound::kSoon);
763
+ return;
764
+ }
765
+ if (response_too_large_) {
766
+ Log(MIK_LOG_ERROR, "ota: checkin response exceeds %zu bytes",
767
+ MIK__OTA_MAX_RESPONSE_BYTES);
768
+ result_.status = MIKOtaCheckStatus::kFailed;
769
+ result_.error.name = "TooLarge";
770
+ char buf[64];
771
+ snprintf(buf, sizeof(buf), "check-in response exceeds %zu bytes",
772
+ MIK__OTA_MAX_RESPONSE_BYTES);
773
+ result_.error.message = buf;
774
+ FinishRound(NextRound::kSoon);
775
+ return;
776
+ }
777
+
778
+ nanocbor_value_t val;
779
+ nanocbor_decoder_init(&val, response_body_.data(), response_body_.size());
780
+ nanocbor_value_t map;
781
+ /* A registry with nothing to send (no offer, no config, no name) answers
782
+ * with CBOR null or an empty body. That is a completed round, not a decode
783
+ * failure: the JS client read it that way, and failing it here put every
784
+ * quiet round on the 60s retry cadence instead of the configured interval. */
785
+ bool empty_response = response_body_.empty() || nanocbor_get_null(&val) >= 0;
786
+ if (!empty_response &&
787
+ (nanocbor_get_type(&val) != NANOCBOR_TYPE_MAP || nanocbor_enter_map(&val, &map) < 0)) {
788
+ Log(MIK_LOG_ERROR, "ota: invalid checkin response body");
789
+ result_.status = MIKOtaCheckStatus::kFailed;
790
+ result_.error.name = "DecodeFailed";
791
+ result_.error.message = "check-in response is not a CBOR map";
792
+ FinishRound(NextRound::kSoon);
793
+ return;
794
+ }
795
+
796
+ // The check-in completed: the cached reports are delivered.
797
+ has_last_install_ = false;
798
+ has_last_decline_ = false;
799
+
800
+ // Confirm before applying: a completed check-in is the whole health signal
801
+ // require_confirm waits for, whether or not an offer follows — and resolving
802
+ // the trial now lets an offer published during it stage in this same pass.
803
+ if (running_.trial) {
804
+ Log(MIK_LOG_INFO, "ota: check-in completed — confirming this build as healthy");
805
+ }
806
+ mik__ota_policy_confirm(env_);
807
+
808
+ // A completed check-in is the health signal for BOTH trials: the build's
809
+ // (confirmed above) and a running-release config delivery's. The config
810
+ // trial additionally waits for the app to have READ the document — a
811
+ // check-in completing before the app ever ran with the new values proves
812
+ // nothing about them.
813
+ // A read that FAILED is not a read that found nothing: it fails under the
814
+ // heap pressure this check-in just created, and adopting on it would take a
815
+ // document the app never read.
816
+ MIKOtaConfigTrial config_trial = {};
817
+ MIKOtaKvStatus trial_status = mik__ota_load_trial(env_, &config_trial);
818
+ if (trial_status == MIK_OTA_KV_ABSENT || (trial_status == MIK_OTA_KV_OK && config_trial.read)) {
819
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
820
+ mik__ota_clear_trial(env_);
821
+ }
822
+
823
+ bool has_url_key = false;
824
+ std::string offer_url;
825
+ std::string offer_checksum;
826
+ int64_t offer_size = -1;
827
+
828
+ bool has_name_key = false;
829
+ bool name_valid = false;
830
+ int name_rev = 0;
831
+ std::string name_value;
832
+
833
+ bool has_config_key = false;
834
+ have_response_config_ = false;
835
+ response_config_ = MIKOtaStoredConfig();
836
+ response_config_doc_.clear();
837
+
838
+ while (!empty_response && !nanocbor_at_end(&map)) {
839
+ std::string key;
840
+ if (!take_key(&map, &key)) break;
841
+ if (key == "url") {
842
+ has_url_key = true;
843
+ if (!take_string(&map, &offer_url)) mik__cbor_skip_value(&map);
844
+ } else if (key == "checksum") {
845
+ if (!take_string(&map, &offer_checksum)) mik__cbor_skip_value(&map);
846
+ } else if (key == "size") {
847
+ uint64_t size = 0;
848
+ if (nanocbor_get_uint64(&map, &size) >= 0) {
849
+ offer_size = static_cast<int64_t>(size);
850
+ } else {
851
+ mik__cbor_skip_value(&map);
852
+ }
853
+ } else if (key == "name") {
854
+ has_name_key = true;
855
+ nanocbor_value_t arr;
856
+ if (nanocbor_enter_array(&map, &arr) < 0) {
857
+ mik__cbor_skip_value(&map);
858
+ continue;
859
+ }
860
+ int32_t rev = 0;
861
+ // No `name` field means "no change" and never "clear it"; junk in
862
+ // the pair is treated the same way.
863
+ if (nanocbor_get_int32(&arr, &rev) >= 0 && rev >= 0) {
864
+ name_rev = rev;
865
+ name_valid = true;
866
+ if (!nanocbor_at_end(&arr)) take_string(&arr, &name_value);
867
+ }
868
+ mik__cbor_skip_value(&map);
869
+ } else if (key == "config") {
870
+ has_config_key = true;
871
+ nanocbor_value_t cfg;
872
+ if (nanocbor_enter_map(&map, &cfg) < 0) {
873
+ mik__cbor_skip_value(&map);
874
+ continue;
875
+ }
876
+ while (!nanocbor_at_end(&cfg)) {
877
+ std::string cfg_key;
878
+ if (!take_key(&cfg, &cfg_key)) break;
879
+ if (cfg_key == "version") {
880
+ if (take_str(&cfg, response_config_.version,
881
+ sizeof(response_config_.version)) &&
882
+ response_config_.version[0]) {
883
+ have_response_config_ = true;
884
+ } else {
885
+ mik__cbor_skip_value(&cfg);
886
+ }
887
+ } else if (cfg_key == "rev") {
888
+ if (!take_str(&cfg, response_config_.rev, sizeof(response_config_.rev))) {
889
+ mik__cbor_skip_value(&cfg);
890
+ }
891
+ } else if (cfg_key == "doc") {
892
+ const uint8_t* start = cfg.cur;
893
+ // An absent or null doc is the clear (spec, "Config sync").
894
+ if (nanocbor_get_null(&cfg) >= 0) continue;
895
+ if (mik__cbor_skip_value(&cfg) < 0) break;
896
+ response_config_doc_.assign(start, cfg.cur);
897
+ } else {
898
+ mik__cbor_skip_value(&cfg);
899
+ }
900
+ }
901
+ mik__cbor_skip_value(&map);
902
+ } else {
903
+ mik__cbor_skip_value(&map);
904
+ }
905
+ }
906
+ // An empty rev normalises to absent (omit, never null).
907
+ if (!have_response_config_) {
908
+ response_config_doc_.clear();
909
+ } else if (!response_config_doc_.empty()) {
910
+ response_config_.doc_cbor = response_config_doc_.data();
911
+ response_config_.doc_cbor_len = response_config_doc_.size();
912
+ }
913
+
914
+ if (has_name_key && name_valid) {
915
+ if (env_ && env_->set_device_name) {
916
+ env_->set_device_name(env_->opaque, name_rev,
917
+ name_value.empty() ? nullptr : name_value.c_str());
918
+ }
919
+ Log(MIK_LOG_INFO, "ota: registry renamed this device to %s",
920
+ name_value.empty() ? "(no name)" : name_value.c_str());
921
+ }
922
+
923
+ if (has_config_key && !have_response_config_) {
924
+ // Malformed must not read as absent: a registry speaking a different
925
+ // config dialect would otherwise look exactly like one sending none.
926
+ Log(MIK_LOG_WARN,
927
+ "ota: response carried a config field the client could not read; ignoring it");
928
+ }
929
+
930
+ std::string warn_reason;
931
+ bool offer_valid = mik__ota_parse_offer(offer_url.c_str(), offer_checksum.c_str(), offer_size,
932
+ allow_insecure_, &offer_, &warn_reason);
933
+ // A check-in with nothing newer still returns a body when the registry has
934
+ // something else to say, so a missing url is "no update", not a bad offer.
935
+ if (has_url_key && !offer_valid && !warn_reason.empty()) {
936
+ Log(MIK_LOG_WARN, "ota: ignoring offer (%s)", warn_reason.c_str());
937
+ }
938
+
939
+ if (!has_url_key || !offer_valid) {
940
+ // OR in this boot's promote/restore, delivered once: the config those
941
+ // applied is as new to the app as one this round delivered.
942
+ bool updated = ApplyRunningConfig(have_response_config_ ? &response_config_ : nullptr,
943
+ active_.options.trial_boots);
944
+ updated = updated || boot_config_changed_;
945
+ boot_config_changed_ = false;
946
+ Log(MIK_LOG_DEBUG, "ota: up to date, running the latest build");
947
+ result_.status = MIKOtaCheckStatus::kUpToDate;
948
+ result_.config_updated = updated;
949
+ FinishRound(NextRound::kLater);
950
+ return;
951
+ }
952
+
953
+ // Announce the offer without promising action: the policy may decline it
954
+ // before any download — the pump logs when bytes actually start moving.
955
+ Log(MIK_LOG_INFO, "ota: update available (%.12s, %zu bytes)", offer_.checksum.c_str(),
956
+ offer_.size);
957
+
958
+ MIKOtaApplyOutcome outcome = MIKOtaApplyOutcome::kStaged;
959
+ MIKOtaError error;
960
+ if (!mik__ota_policy_apply_begin(env_, offer_, &apply_, &outcome, &error)) {
961
+ Log(MIK_LOG_ERROR, "ota: update failed (%s: %s)", error.name.c_str(),
962
+ error.message.c_str());
963
+ result_.status = MIKOtaCheckStatus::kNotStaged;
964
+ result_.decline_reason = MIKOtaDeclineReason::kInstallFailed;
965
+ result_.error = error;
966
+ NoteDecline(MIKOtaDeclineReason::kInstallFailed, error.message);
967
+ FinishRound(NextRound::kLater);
968
+ return;
969
+ }
970
+ if (!apply_.update) {
971
+ Log(MIK_LOG_INFO, "ota: offer not staged (%s)", mik__ota_outcome_to_str(outcome));
972
+ result_.status = MIKOtaCheckStatus::kNotStaged;
973
+ switch (outcome) {
974
+ case MIKOtaApplyOutcome::kTrialPending:
975
+ result_.decline_reason = MIKOtaDeclineReason::kTrialPending;
976
+ break;
977
+ case MIKOtaApplyOutcome::kAbandoned:
978
+ result_.decline_reason = MIKOtaDeclineReason::kAbandoned;
979
+ break;
980
+ case MIKOtaApplyOutcome::kExhausted:
981
+ result_.decline_reason = MIKOtaDeclineReason::kExhausted;
982
+ break;
983
+ default:
984
+ result_.decline_reason = MIKOtaDeclineReason::kCurrent;
985
+ break;
986
+ }
987
+ NoteDecline(result_.decline_reason, "");
988
+ FinishRound(NextRound::kLater);
989
+ return;
990
+ }
991
+
992
+ if (!BeginDownload()) OnDownloadSettled();
993
+ }
994
+
995
+ bool MIKOtaClient::BeginDownload() {
996
+ size_t from = apply_.update->resume_offset();
997
+ // Already complete: a finish that failed transiently leaves every byte on
998
+ // flash and the offer pending, so resuming would ask for `bytes=<size>-` and
999
+ // take the 416 as a failed download forever. Go straight to re-verifying.
1000
+ if (from >= offer_.size) {
1001
+ http_done_ = true;
1002
+ http_status_ = 200;
1003
+ http_error_.clear();
1004
+ write_failed_ = false;
1005
+ return false;
1006
+ }
1007
+
1008
+ if (from > 0) {
1009
+ Log(MIK_LOG_INFO, "ota: resuming download from byte %zu of %zu", from, offer_.size);
1010
+ } else {
1011
+ Log(MIK_LOG_INFO, "ota: downloading %zu bytes", offer_.size);
1012
+ }
1013
+
1014
+ std::vector<const char*> keys;
1015
+ std::vector<std::string> values;
1016
+ char range[64] = {};
1017
+ if (from > 0) {
1018
+ snprintf(range, sizeof(range), "bytes=%zu-", from);
1019
+ keys.push_back("range");
1020
+ values.push_back(range);
1021
+ }
1022
+ // Same-origin only. offer.url may legitimately point at another host (a CDN,
1023
+ // an object store), so the update key goes out only when the download is on
1024
+ // the registry's own origin. A build fetched elsewhere is a public artifact
1025
+ // the checksum vouches for.
1026
+ if (mik__ota_same_origin(offer_.url, active_.registry_url)) {
1027
+ keys.push_back("authorization");
1028
+ values.push_back("Bearer " + active_.bearer);
1029
+ }
1030
+ std::vector<const char*> value_ptrs;
1031
+ value_ptrs.reserve(values.size());
1032
+ for (const std::string& value : values) value_ptrs.push_back(value.c_str());
1033
+
1034
+ MIKOtaHttpRequest req = {};
1035
+ req.url = offer_.url.c_str();
1036
+ req.method = "GET";
1037
+ req.header_keys = keys.data();
1038
+ req.header_values = value_ptrs.data();
1039
+ req.header_count = keys.size();
1040
+ req.timeout_ms = active_.options.download_timeout_ms;
1041
+
1042
+ http_done_ = false;
1043
+ http_status_ = 0;
1044
+ http_error_.clear();
1045
+ write_failed_ = false;
1046
+ write_error_ = MIKOtaError();
1047
+ download_skip_ = from;
1048
+ state_ = MIKOtaClientState::kDownload;
1049
+
1050
+ MIKOtaHttpCallbacks cbs = {DownloadHeadersThunk, DataThunk, DoneThunk, this};
1051
+ http_handle_ = (env_ && env_->http_request) ? env_->http_request(env_->opaque, &req, &cbs)
1052
+ : nullptr;
1053
+ if (!http_handle_) {
1054
+ http_done_ = true;
1055
+ http_status_ = 0;
1056
+ if (http_error_.empty()) http_error_ = "request could not be started";
1057
+ }
1058
+ return true;
1059
+ }
1060
+
1061
+ void MIKOtaClient::DownloadHeadersThunk(void* ud, int status) {
1062
+ auto* self = static_cast<MIKOtaClient*>(ud);
1063
+ self->http_status_ = status;
1064
+ // A 206 means the Range was honoured, so no prefix has to be dropped.
1065
+ if (status == 206) self->download_skip_ = 0;
1066
+ }
1067
+
1068
+ void MIKOtaClient::OnDownloadSettled() {
1069
+ bool ok = true;
1070
+ std::string message;
1071
+ if (write_failed_) {
1072
+ ok = false;
1073
+ message = write_error_.message.empty() ? "staging write failed" : write_error_.message;
1074
+ } else if (http_status_ == 0 || !http_error_.empty()) {
1075
+ // A transport error means the transfer did not complete, whatever status
1076
+ // already arrived. Taking a truncated body as a finished download would
1077
+ // hand short bytes to finish(), fail the checksum, and abandon the
1078
+ // build as corrupt — permanently, since nothing ever clears `bad`.
1079
+ ok = false;
1080
+ message = "download failed: " + (http_error_.empty() ? "no response" : http_error_);
1081
+ } else if (http_status_ < 200 || http_status_ >= 300) {
1082
+ ok = false;
1083
+ char buf[64];
1084
+ snprintf(buf, sizeof(buf), "download returned status %d", http_status_);
1085
+ message = buf;
1086
+ }
1087
+
1088
+ MIKOtaInstallOptions install;
1089
+ install.trial_boots = active_.options.trial_boots;
1090
+ install.require_confirm = active_.options.require_confirm;
1091
+ install.install_now = false;
1092
+
1093
+ MIKOtaApplyOutcome outcome = MIKOtaApplyOutcome::kStaged;
1094
+ MIKOtaError error;
1095
+ if (!mik__ota_policy_apply_end(&apply_, ok, message, install, &outcome, &error)) {
1096
+ Log(MIK_LOG_ERROR, "ota: update failed (%s: %s)", error.name.c_str(),
1097
+ error.message.c_str());
1098
+ result_.status = MIKOtaCheckStatus::kNotStaged;
1099
+ result_.decline_reason = error.name == "DownloadFailed"
1100
+ ? MIKOtaDeclineReason::kDownloadFailed
1101
+ : MIKOtaDeclineReason::kInstallFailed;
1102
+ result_.error = error;
1103
+ NoteDecline(result_.decline_reason, error.message);
1104
+ FinishRound(NextRound::kLater);
1105
+ return;
1106
+ }
1107
+
1108
+ // With an offer, the response's config is for the offered release: stage it
1109
+ // with the build, applied together at the trial boot. An absent doc is the
1110
+ // clear, so it stages "the new release holds no document" and the manifest
1111
+ // defaults stand in.
1112
+ if (have_response_config_ && response_config_.doc_cbor && response_config_.doc_cbor_len > 0) {
1113
+ mik__ota_store_slot(env_, MIK_OTA_CFG_NEXT, response_config_);
1114
+ Log(MIK_LOG_INFO, "ota: config staged for %s", response_config_.version);
1115
+ } else {
1116
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_NEXT);
1117
+ }
1118
+
1119
+ Log(MIK_LOG_INFO, "ota: download verified and staged");
1120
+ result_.status = MIKOtaCheckStatus::kStaged;
1121
+ result_.offer = offer_;
1122
+ FinishRound(NextRound::kLater);
1123
+ }
1124
+
1125
+ bool MIKOtaClient::ApplyRunningConfig(const MIKOtaStoredConfig* config, int trial_boots) {
1126
+ if (!config) return false;
1127
+
1128
+ if (!config->doc_cbor || config->doc_cbor_len == 0) {
1129
+ // A rev riding along does not turn a clear into a document.
1130
+ mik__ota_clear_trial(env_);
1131
+ mik__ota_clear_config_error(env_);
1132
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
1133
+ if (!mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT).present) return false;
1134
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_CURRENT);
1135
+ Log(MIK_LOG_INFO, "ota: config cleared by the registry");
1136
+ return true;
1137
+ }
1138
+
1139
+ MIKOtaLoadedConfig previous = mik__ota_load_slot(env_, MIK_OTA_CFG_CURRENT);
1140
+
1141
+ // Without the document being replaced there is no baseline to roll back to,
1142
+ // and clearing the one already stored would strand a bad document with
1143
+ // nowhere to fall back to. Leave everything alone; the rev the device
1144
+ // echoes is unchanged, so the registry sends this again next round.
1145
+ if (previous.failed) return false;
1146
+
1147
+ // A registry is meant to send config only when the rev the device echoed
1148
+ // differs, but one that sends it every round must not cost anything: taking
1149
+ // an identical document as a change would re-write NVS on every round, put
1150
+ // the document back on trial it had already passed, and tell the app its
1151
+ // config changed when nothing did. The rev counts as part of the document —
1152
+ // it is what the device echoes, so a new one has to be stored and echoed
1153
+ // back even when the values are the same.
1154
+ if (previous.present && strcmp(previous.cfg.rev, config->rev) == 0 &&
1155
+ strcmp(previous.cfg.version, config->version) == 0 &&
1156
+ previous.cfg.doc_cbor_len == config->doc_cbor_len &&
1157
+ (config->doc_cbor_len == 0 ||
1158
+ memcmp(previous.cfg.doc_cbor, config->doc_cbor, config->doc_cbor_len) == 0)) {
1159
+ return false;
1160
+ }
1161
+
1162
+ // The old document is kept as the rollback baseline: a schema-valid value
1163
+ // can still be fatal to the app (a GPIO this board does not have), and the
1164
+ // crash it causes can fire before any check-in runs.
1165
+ if (previous.present) {
1166
+ mik__ota_store_slot(env_, MIK_OTA_CFG_PREV, previous.cfg);
1167
+ } else {
1168
+ mik__ota_clear_slot(env_, MIK_OTA_CFG_PREV);
1169
+ }
1170
+ mik__ota_store_slot(env_, MIK_OTA_CFG_CURRENT, *config);
1171
+ MIKOtaConfigTrial trial = {trial_boots, false};
1172
+ mik__ota_store_trial(env_, trial);
1173
+ mik__ota_clear_config_error(env_);
1174
+ Log(MIK_LOG_INFO, "ota: config updated for %s", config->version);
1175
+ return true;
1176
+ }
1177
+
1178
+ void MIKOtaClient::FinishRound(NextRound next) {
1179
+ // Teardown before any restart: the round is over either way, and the hook's
1180
+ // invariant — teardown runs whenever setup succeeded — must not depend on
1181
+ // what the check found.
1182
+ if (teardown_armed_ && watch_options_.hooks && watch_options_.hooks->BeginTeardown()) {
1183
+ pending_next_ = next;
1184
+ state_ = MIKOtaClientState::kTeardown;
1185
+ return;
1186
+ }
1187
+ Finalize(next);
1188
+ }
1189
+
1190
+ void MIKOtaClient::Finalize(NextRound next) {
1191
+ teardown_armed_ = false;
1192
+ state_ = MIKOtaClientState::kIdle;
1193
+
1194
+ if (!active_.is_watch) {
1195
+ MIKOtaCheckSink sink = std::move(active_.sink);
1196
+ active_ = Round();
1197
+ if (sink) sink(result_);
1198
+ return;
1199
+ }
1200
+ active_ = Round();
1201
+
1202
+ /* Before the restart decision: a round that staged a build is still a round
1203
+ * the app is entitled to hear about. */
1204
+ if (watch_options_.on_round) watch_options_.on_round(result_);
1205
+
1206
+ if (result_.status == MIKOtaCheckStatus::kStaged) {
1207
+ if (watch_stopped_) {
1208
+ // stop() won the race: leave the build armed for the next natural
1209
+ // reboot instead of restarting under the caller.
1210
+ Log(MIK_LOG_INFO, "ota: update staged; watcher stopped, restart deferred");
1211
+ return;
1212
+ }
1213
+ Log(MIK_LOG_INFO, "ota: staged; restarting to install");
1214
+ if (env_ && env_->restart) env_->restart(env_->opaque);
1215
+ return;
1216
+ }
1217
+ ScheduleNext(next);
1218
+ }
1219
+
1220
+ void MIKOtaClient::ScheduleNext(NextRound next) {
1221
+ if (!watching_ || watch_stopped_) return;
1222
+ int64_t base = next == NextRound::kLater ? watch_options_.checkin_interval_ms
1223
+ : watch_options_.retry_after_failure_ms;
1224
+ scheduled_delay_ms_ = Jitter(base);
1225
+ wait_started_ms_ = Now();
1226
+ deadline_ms_ = wait_started_ms_ + scheduled_delay_ms_;
1227
+ pending_wait_ = next == NextRound::kLater ? PendingWait::kInterval : PendingWait::kRetry;
1228
+ }
1229
+
1230
+ } // namespace mikrojs