@mikrojs/native 0.18.0 → 0.18.2-next.20260826235606

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