@mikrojs/firmware 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 (30) hide show
  1. package/components/mikrojs/CMakeLists.txt +12 -1
  2. package/components/mikrojs/include/mik_http_internal.h +47 -0
  3. package/components/mikrojs/include/mik_ota_native.h +19 -0
  4. package/components/mikrojs/mik_ble.cpp +1 -1
  5. package/components/mikrojs/mik_config.cpp +28 -5
  6. package/components/mikrojs/mik_http.cpp +201 -9
  7. package/components/mikrojs/mik_http_server.cpp +1 -2
  8. package/components/mikrojs/mik_i2s.cpp +1 -1
  9. package/components/mikrojs/mik_nvs_kv.cpp +28 -5
  10. package/components/mikrojs/mik_ota.cpp +305 -106
  11. package/components/mikrojs/mik_ota_client_module.cpp +923 -0
  12. package/components/mikrojs/mik_ota_env.cpp +370 -0
  13. package/components/mikrojs/mik_pwm.cpp +1 -2
  14. package/components/mikrojs/mik_sntp.cpp +1 -1
  15. package/components/mikrojs/mik_uart.cpp +1 -1
  16. package/components/mikrojs/mik_wifi.cpp +1 -1
  17. package/components/mikrojs/test/http_test.cpp +104 -0
  18. package/components/mikrojs/test/ota_host/run.sh +7 -2
  19. package/package.json +3 -3
  20. package/prebuilds/esp32/bootloader/bootloader.bin +0 -0
  21. package/prebuilds/esp32/mikrojs.bin +0 -0
  22. package/prebuilds/esp32c3/bootloader/bootloader.bin +0 -0
  23. package/prebuilds/esp32c3/mikrojs.bin +0 -0
  24. package/prebuilds/esp32c5/bootloader/bootloader.bin +0 -0
  25. package/prebuilds/esp32c5/mikrojs.bin +0 -0
  26. package/prebuilds/esp32c6/bootloader/bootloader.bin +0 -0
  27. package/prebuilds/esp32c6/mikrojs.bin +0 -0
  28. package/prebuilds/esp32s3/bootloader/bootloader.bin +0 -0
  29. package/prebuilds/esp32s3/mikrojs.bin +0 -0
  30. package/sdkconfig.defaults +58 -0
@@ -0,0 +1,923 @@
1
+ /**
2
+ * native:mikro/ota_client — the JS surface of the native OTA client.
3
+ *
4
+ * Thin by design: the state machine lives in the portable library
5
+ * (mikrojs/ota_client.h) where host tests drive it. This file does three things
6
+ * the machine cannot do for itself — marshal options and results across the JS
7
+ * boundary, settle a promise when a round finishes, and re-enter JS for the
8
+ * app's `beforeCheck` hook, which is the one place JS runs inside a round.
9
+ */
10
+
11
+ #include <memory>
12
+ #include <vector>
13
+
14
+ #include "esp_log.h"
15
+ #include "mik_http_internal.h"
16
+ #include "mik_ota_native.h"
17
+ #include "mikrojs/cbor_helpers.h"
18
+ #include "mikrojs/ota_client.h"
19
+ #include "mikrojs/ota_config.h"
20
+ #include "mikrojs/ota_js_hooks.h"
21
+ #include "mikrojs/ota_policy.h"
22
+ #include "mikrojs/ota_slots.h"
23
+ #include "mikrojs/private.h"
24
+ #include "mikrojs/utils.h"
25
+
26
+ using mikrojs::MIKOtaCheckOptions;
27
+ using mikrojs::MIKOtaCheckResult;
28
+ using mikrojs::MIKOtaCheckStatus;
29
+ using mikrojs::MIKOtaClient;
30
+ using mikrojs::MIKOtaConfigReader;
31
+ using mikrojs::MIKOtaConfigWrite;
32
+ using mikrojs::MIKOtaJsHooks;
33
+ using mikrojs::MIKOtaApplyOutcome;
34
+ using mikrojs::MIKOtaApplySession;
35
+ using mikrojs::MIKOtaError;
36
+ using mikrojs::MIKOtaInstallOptions;
37
+ using mikrojs::MIKOtaOffer;
38
+ using mikrojs::MIKOtaHookState;
39
+ using mikrojs::MIKOtaRoundHooks;
40
+ using mikrojs::MIKOtaWatchOptions;
41
+
42
+ #define MIK_OTA_CLIENT_TAG "native:mikro/ota_client"
43
+
44
+ namespace {
45
+
46
+ /* One in-flight check() and the promise waiting on it. */
47
+ struct PendingCheck {
48
+ MIKPromise promise;
49
+ bool settled = false;
50
+ };
51
+
52
+ /* One apply attempt, held open while the app's download callback runs. Staging
53
+ * is a single native session, so there is at most one. */
54
+ struct PendingApply {
55
+ MIKOtaApplySession session;
56
+ MIKPromise promise;
57
+ MIKOtaInstallOptions options;
58
+ bool active = false;
59
+ };
60
+
61
+ struct MIKOtaClientState {
62
+ JSContext* ctx = nullptr;
63
+ const MIKOtaEnv* env = nullptr;
64
+ PendingApply apply;
65
+ std::unique_ptr<MIKOtaConfigReader> config;
66
+ std::unique_ptr<MIKOtaClient> client;
67
+ std::unique_ptr<MIKOtaJsHooks> hooks;
68
+ /* The watcher's onConfig, held for the life of the watch. */
69
+ JSValue on_config = JS_UNDEFINED;
70
+ std::vector<std::unique_ptr<PendingCheck>> pending;
71
+ bool watching = false;
72
+ };
73
+
74
+ int mik__ota_client_slot = -1;
75
+
76
+ inline MIKOtaClientState*& mik__ota_client_st(MIKRuntime* rt) {
77
+ return reinterpret_cast<MIKOtaClientState*&>(rt->module_data[mik__ota_client_slot]);
78
+ }
79
+
80
+ // ── result marshalling ───────────────────────────────────────────────────────
81
+
82
+ void set_error(JSContext* ctx, JSValue target, const mikrojs::MIKOtaError& error,
83
+ int http_status) {
84
+ JSValue obj = JS_NewObject(ctx);
85
+ JS_SetPropertyStr(ctx, obj, "name",
86
+ JS_NewString(ctx, error.name.empty() ? "Network" : error.name.c_str()));
87
+ if (!error.kind.empty()) {
88
+ JS_SetPropertyStr(ctx, obj, "kind", JS_NewString(ctx, error.kind.c_str()));
89
+ }
90
+ if (!error.message.empty()) {
91
+ JS_SetPropertyStr(ctx, obj, "message", JS_NewString(ctx, error.message.c_str()));
92
+ }
93
+ if (error.name == "Status" && http_status != 0) {
94
+ JS_SetPropertyStr(ctx, obj, "status", JS_NewInt32(ctx, http_status));
95
+ }
96
+ JS_SetPropertyStr(ctx, target, "error", obj);
97
+ }
98
+
99
+ JSValue result_to_js(JSContext* ctx, const MIKOtaCheckResult& result) {
100
+ JSValue obj = JS_NewObject(ctx);
101
+ JS_SetPropertyStr(ctx, obj, "status",
102
+ JS_NewString(ctx, mikrojs::mik__ota_check_status_to_str(result.status)));
103
+ switch (result.status) {
104
+ case MIKOtaCheckStatus::kStaged: {
105
+ JSValue offer = JS_NewObject(ctx);
106
+ JS_SetPropertyStr(ctx, offer, "url", JS_NewString(ctx, result.offer.url.c_str()));
107
+ JS_SetPropertyStr(ctx, offer, "checksum",
108
+ JS_NewString(ctx, result.offer.checksum.c_str()));
109
+ JS_SetPropertyStr(ctx, offer, "size", JS_NewInt64(ctx, (int64_t)result.offer.size));
110
+ JS_SetPropertyStr(ctx, obj, "offer", offer);
111
+ break;
112
+ }
113
+ case MIKOtaCheckStatus::kUpToDate:
114
+ /* Omitted rather than false when nothing changed, matching the JS
115
+ * client: the key's presence is the signal. */
116
+ if (result.config_updated) {
117
+ JS_SetPropertyStr(ctx, obj, "configUpdated", JS_TRUE);
118
+ }
119
+ break;
120
+ case MIKOtaCheckStatus::kNotStaged:
121
+ JS_SetPropertyStr(
122
+ ctx, obj, "reason",
123
+ JS_NewString(ctx, mikrojs::mik__ota_decline_reason_to_str(result.decline_reason)));
124
+ if (!result.error.name.empty()) set_error(ctx, obj, result.error, result.http_status);
125
+ break;
126
+ case MIKOtaCheckStatus::kFailed:
127
+ set_error(ctx, obj, result.error, result.http_status);
128
+ break;
129
+ case MIKOtaCheckStatus::kUnauthorized:
130
+ case MIKOtaCheckStatus::kNotEnrolled:
131
+ break;
132
+ }
133
+ return obj;
134
+ }
135
+
136
+ // ── option marshalling ───────────────────────────────────────────────────────
137
+
138
+ uint32_t opt_u32(JSContext* ctx, JSValue options, const char* key, uint32_t fallback) {
139
+ JSValue value = JS_GetPropertyStr(ctx, options, key);
140
+ uint32_t out = fallback;
141
+ if (JS_IsNumber(value)) {
142
+ int64_t raw = 0;
143
+ if (JS_ToInt64(ctx, &raw, value) == 0 && raw >= 0) out = (uint32_t)raw;
144
+ }
145
+ JS_FreeValue(ctx, value);
146
+ return out;
147
+ }
148
+
149
+ bool opt_bool(JSContext* ctx, JSValue options, const char* key, bool fallback) {
150
+ JSValue value = JS_GetPropertyStr(ctx, options, key);
151
+ bool out = JS_IsUndefined(value) ? fallback : JS_ToBool(ctx, value);
152
+ JS_FreeValue(ctx, value);
153
+ return out;
154
+ }
155
+
156
+ void read_check_options(JSContext* ctx, JSValue options, MIKOtaCheckOptions* out) {
157
+ if (!JS_IsObject(options)) return;
158
+ out->checkin_timeout_ms = opt_u32(ctx, options, "checkinTimeoutMs", out->checkin_timeout_ms);
159
+ out->download_timeout_ms = opt_u32(ctx, options, "downloadTimeoutMs", out->download_timeout_ms);
160
+ out->require_confirm = opt_bool(ctx, options, "requireConfirm", out->require_confirm);
161
+ out->trial_boots = (int)opt_u32(ctx, options, "trialBoots", (uint32_t)out->trial_boots);
162
+ }
163
+
164
+ // ── JS functions ─────────────────────────────────────────────────────────────
165
+
166
+ JSValue mik__ota_client_check(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
167
+ (void)this_val;
168
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
169
+ CHECK_NOT_NULL(mik_rt);
170
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
171
+ CHECK_NOT_NULL(state);
172
+
173
+ MIKOtaCheckOptions options;
174
+ if (argc > 0) read_check_options(ctx, argv[0], &options);
175
+
176
+ auto pending = std::make_unique<PendingCheck>();
177
+ JSValue promise = MIK_InitPromise(ctx, &pending->promise);
178
+ if (JS_IsException(promise)) return JS_EXCEPTION;
179
+
180
+ PendingCheck* slot = pending.get();
181
+ state->pending.push_back(std::move(pending));
182
+
183
+ state->client->Check(options, [state, slot](const MIKOtaCheckResult& result) {
184
+ JSValue value = result_to_js(state->ctx, result);
185
+ MIK_ResolvePromise(state->ctx, &slot->promise, 1, &value);
186
+ slot->settled = true;
187
+ });
188
+
189
+ /* A not-enrolled device settles inline, before Check() returns. Reap it here
190
+ * so the vector does not grow with every call on such a device. */
191
+ for (size_t i = state->pending.size(); i > 0; i--) {
192
+ if (state->pending[i - 1]->settled) state->pending.erase(state->pending.begin() + (i - 1));
193
+ }
194
+ return promise;
195
+ }
196
+
197
+ /* config() -> the effective config, or undefined. */
198
+ JSValue mik__ota_client_config(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
199
+ (void)this_val;
200
+ (void)argc;
201
+ (void)argv;
202
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
203
+ CHECK_NOT_NULL(mik_rt);
204
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
205
+ CHECK_NOT_NULL(state);
206
+ return state->config->Read(ctx);
207
+ }
208
+
209
+ JSValue mik__ota_client_stop(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
210
+ (void)this_val;
211
+ (void)argc;
212
+ (void)argv;
213
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
214
+ CHECK_NOT_NULL(mik_rt);
215
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
216
+ if (state && state->client) state->client->StopWatch();
217
+ return JS_UNDEFINED;
218
+ }
219
+
220
+ JSValue mik__ota_client_set_interval(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
221
+ (void)this_val;
222
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
223
+ CHECK_NOT_NULL(mik_rt);
224
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
225
+ if (!state || !state->client) return JS_UNDEFINED;
226
+ int64_t interval = 0;
227
+ if (argc < 1 || JS_ToInt64(ctx, &interval, argv[0]) < 0 || interval < 0) {
228
+ return JS_ThrowTypeError(ctx, "setCheckinInterval(ms) needs a non-negative number");
229
+ }
230
+ state->client->SetCheckinInterval(static_cast<uint32_t>(interval));
231
+ return JS_UNDEFINED;
232
+ }
233
+
234
+ JSValue mik__ota_client_watch(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
235
+ (void)this_val;
236
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
237
+ CHECK_NOT_NULL(mik_rt);
238
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
239
+ CHECK_NOT_NULL(state);
240
+
241
+ /* One watcher per runtime, and never a swap mid-round: replacing the hooks
242
+ * would pull them out from under the round that is still running them, and
243
+ * stop() only takes effect once that round finishes. */
244
+ if (state->client && state->client->watching()) {
245
+ return JS_ThrowTypeError(
246
+ ctx, "ota.watch() is already running; call stop() on its watcher first");
247
+ }
248
+ if (state->client && state->client->state() != mikrojs::MIKOtaClientState::kIdle) {
249
+ return JS_ThrowTypeError(
250
+ ctx, "ota.watch(): the previous round is still finishing; try again shortly");
251
+ }
252
+
253
+ MIKOtaWatchOptions options;
254
+ if (argc > 0 && JS_IsObject(argv[0])) {
255
+ read_check_options(ctx, argv[0], &options);
256
+ options.checkin_interval_ms =
257
+ opt_u32(ctx, argv[0], "checkinIntervalMs", options.checkin_interval_ms);
258
+ options.initial_delay_ms =
259
+ opt_u32(ctx, argv[0], "initialDelayMs", options.initial_delay_ms);
260
+ options.retry_after_failure_ms =
261
+ opt_u32(ctx, argv[0], "retryAfterFailureMs", options.retry_after_failure_ms);
262
+ options.jitter = opt_bool(ctx, argv[0], "jitter", options.jitter);
263
+
264
+ JSValue before_check = JS_GetPropertyStr(ctx, argv[0], "beforeCheck");
265
+ if (JS_IsFunction(ctx, before_check)) {
266
+ state->hooks = std::make_unique<MIKOtaJsHooks>(ctx, before_check);
267
+ options.hooks = state->hooks.get();
268
+ } else {
269
+ JS_FreeValue(ctx, before_check);
270
+ }
271
+
272
+ JS_FreeValue(ctx, state->on_config);
273
+ state->on_config = JS_GetPropertyStr(ctx, argv[0], "onConfig");
274
+ if (JS_IsFunction(ctx, state->on_config)) {
275
+ /* Only when a round actually changed the stored document. A staged
276
+ * release's config applies at its trial boot, not now, so it is not
277
+ * a change the running app can read yet. */
278
+ options.on_round = [state](const MIKOtaCheckResult& result) {
279
+ if (!result.config_updated) return;
280
+ JSValue config = state->config->Read(state->ctx);
281
+ if (JS_IsException(config)) {
282
+ /* Nothing to hand over: a build with no readable manifest
283
+ * and no stored document has no config to report. */
284
+ JS_FreeValue(state->ctx, JS_GetException(state->ctx));
285
+ return;
286
+ }
287
+ JSValue ignored =
288
+ JS_Call(state->ctx, state->on_config, JS_UNDEFINED, 1, &config);
289
+ if (JS_IsException(ignored)) mik_dump_error(state->ctx);
290
+ JS_FreeValue(state->ctx, ignored);
291
+ JS_FreeValue(state->ctx, config);
292
+ };
293
+ } else {
294
+ JS_FreeValue(ctx, state->on_config);
295
+ state->on_config = JS_UNDEFINED;
296
+ }
297
+ }
298
+
299
+ state->client->Watch(options);
300
+ state->watching = true;
301
+
302
+ JSValue handle = JS_NewObject(ctx);
303
+ JS_SetPropertyStr(ctx, handle, "stop",
304
+ JS_NewCFunction(ctx, mik__ota_client_stop, "stop", 0));
305
+ JS_SetPropertyStr(
306
+ ctx, handle, "setCheckinInterval",
307
+ JS_NewCFunction(ctx, mik__ota_client_set_interval, "setCheckinInterval", 1));
308
+ return handle;
309
+ }
310
+
311
+ // ── the policy surface (mikro/ota) ──────────────────────────────────────────
312
+ // Everything below marshals for functions that already exist in the portable
313
+ // policy. The only one with any machinery of its own is applyOffer, which has to
314
+ // hand the app's download callback a staging session and wait for it.
315
+
316
+ JSValue error_to_js(JSContext* ctx, const MIKOtaError& error) {
317
+ JSValue obj = JS_NewObject(ctx);
318
+ JS_SetPropertyStr(ctx, obj, "name",
319
+ JS_NewString(ctx, error.name.empty() ? "InstallFailed" : error.name.c_str()));
320
+ if (!error.kind.empty()) {
321
+ JS_SetPropertyStr(ctx, obj, "kind", JS_NewString(ctx, error.kind.c_str()));
322
+ }
323
+ JS_SetPropertyStr(ctx, obj, "message", JS_NewString(ctx, error.message.c_str()));
324
+ return obj;
325
+ }
326
+
327
+ MIKOtaClientState* state_of(JSContext* ctx) {
328
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
329
+ if (!mik_rt) return nullptr;
330
+ return mik__ota_client_st(mik_rt);
331
+ }
332
+
333
+ JSValue kv_string_or_undefined(JSContext* ctx, const char* key) {
334
+ MIKOtaClientState* state = state_of(ctx);
335
+ if (!state || !state->env || !state->env->kv_get_str) return JS_UNDEFINED;
336
+ char buf[256] = {};
337
+ if (!state->env->kv_get_str(state->env->opaque, key, buf, sizeof(buf)) || buf[0] == '\0') {
338
+ return JS_UNDEFINED;
339
+ }
340
+ return JS_NewString(ctx, buf);
341
+ }
342
+
343
+ /* reconcile() -> { installed?, reverted, lastInstall? } */
344
+ JSValue ota_reconcile(JSContext* ctx, JSValue, int, JSValue*) {
345
+ MIKOtaClientState* state = state_of(ctx);
346
+ CHECK_NOT_NULL(state);
347
+ MIKOtaReconcileOutcome outcome = mikrojs::mik__ota_policy_reconcile(state->env);
348
+
349
+ JSValue obj = JS_NewObject(ctx);
350
+ if (outcome.installed[0]) {
351
+ JS_SetPropertyStr(ctx, obj, "installed", JS_NewString(ctx, outcome.installed));
352
+ }
353
+ JS_SetPropertyStr(ctx, obj, "reverted", JS_NewBool(ctx, outcome.reverted));
354
+ if (outcome.has_diagnostic) {
355
+ JSValue diag = JS_NewObject(ctx);
356
+ JS_SetPropertyStr(ctx, diag, "reason", JS_NewString(ctx, outcome.diagnostic.reason));
357
+ if (outcome.diagnostic.detail[0]) {
358
+ JS_SetPropertyStr(ctx, diag, "detail", JS_NewString(ctx, outcome.diagnostic.detail));
359
+ }
360
+ JS_SetPropertyStr(ctx, obj, "lastInstall", diag);
361
+ }
362
+ return obj;
363
+ }
364
+
365
+ /* running() -> { checksum?, version?, trial } */
366
+ JSValue ota_running(JSContext* ctx, JSValue, int, JSValue*) {
367
+ MIKOtaClientState* state = state_of(ctx);
368
+ CHECK_NOT_NULL(state);
369
+ MIKOtaRunningBuild running = mikrojs::mik__ota_policy_running(state->env);
370
+
371
+ JSValue obj = JS_NewObject(ctx);
372
+ if (running.checksum[0]) {
373
+ JS_SetPropertyStr(ctx, obj, "checksum", JS_NewString(ctx, running.checksum));
374
+ }
375
+ if (running.version[0]) {
376
+ JS_SetPropertyStr(ctx, obj, "version", JS_NewString(ctx, running.version));
377
+ }
378
+ JS_SetPropertyStr(ctx, obj, "trial", JS_NewBool(ctx, running.trial));
379
+ return obj;
380
+ }
381
+
382
+ JSValue ota_confirm(JSContext* ctx, JSValue, int, JSValue*) {
383
+ MIKOtaClientState* state = state_of(ctx);
384
+ CHECK_NOT_NULL(state);
385
+ mikrojs::mik__ota_policy_confirm(state->env);
386
+ return JS_UNDEFINED;
387
+ }
388
+
389
+ JSValue ota_revert(JSContext* ctx, JSValue, int, JSValue*) {
390
+ MIKOtaClientState* state = state_of(ctx);
391
+ CHECK_NOT_NULL(state);
392
+ MIKOtaError error;
393
+ if (!mikrojs::mik__ota_policy_revert(state->env, &error)) {
394
+ return mik__result_err_obj(ctx, error_to_js(ctx, error));
395
+ }
396
+ return mik__result_ok_void(ctx);
397
+ }
398
+
399
+ JSValue ota_bearer(JSContext* ctx, JSValue, int, JSValue*) {
400
+ return kv_string_or_undefined(ctx, "ota.updateKey");
401
+ }
402
+
403
+ JSValue ota_registry(JSContext* ctx, JSValue, int, JSValue*) {
404
+ return kv_string_or_undefined(ctx, "ota.registry");
405
+ }
406
+
407
+ JSValue offer_to_js(JSContext* ctx, const MIKOtaOffer& offer) {
408
+ JSValue obj = JS_NewObject(ctx);
409
+ JS_SetPropertyStr(ctx, obj, "url", JS_NewString(ctx, offer.url.c_str()));
410
+ JS_SetPropertyStr(ctx, obj, "checksum", JS_NewString(ctx, offer.checksum.c_str()));
411
+ JS_SetPropertyStr(ctx, obj, "size", JS_NewInt64(ctx, static_cast<int64_t>(offer.size)));
412
+ return obj;
413
+ }
414
+
415
+ /* parseOffer(raw, {allowInsecure}) -> Offer | undefined */
416
+ JSValue ota_parse_offer(JSContext* ctx, JSValue, int argc, JSValue* argv) {
417
+ bool allow_insecure = false;
418
+ if (argc > 1 && JS_IsObject(argv[1])) {
419
+ allow_insecure = opt_bool(ctx, argv[1], "allowInsecure", false);
420
+ }
421
+ MIKOtaOffer offer;
422
+ if (!mikrojs::mik__ota_parse_offer_js(ctx, argc > 0 ? argv[0] : JS_UNDEFINED, allow_insecure,
423
+ &offer)) {
424
+ return JS_UNDEFINED;
425
+ }
426
+ return offer_to_js(ctx, offer);
427
+ }
428
+
429
+ // ── applyOffer ──────────────────────────────────────────────────────────────
430
+
431
+ /* The staging session, as the download callback sees it. Recovered from the
432
+ * module state rather than carried on the object: staging is a single native
433
+ * session, so there is only ever one to find. */
434
+ JSValue update_write(JSContext* ctx, JSValue, int argc, JSValue* argv) {
435
+ MIKOtaClientState* state = state_of(ctx);
436
+ if (!state || !state->apply.active || !state->apply.session.update) {
437
+ return mik__result_err_named(ctx, "StagingFailed", "no staging session");
438
+ }
439
+ size_t len = 0;
440
+ const uint8_t* data = argc > 0 ? JS_GetUint8Array(ctx, &len, argv[0]) : nullptr;
441
+ if (!data) {
442
+ JS_FreeValue(ctx, JS_GetException(ctx));
443
+ return mik__result_err_named(ctx, "StagingFull", "bytes not a Uint8Array");
444
+ }
445
+ MIKOtaError error;
446
+ if (!state->apply.session.update->write(data, len, &error)) {
447
+ return mik__result_err_obj(ctx, error_to_js(ctx, error));
448
+ }
449
+ return mik__result_ok_void(ctx);
450
+ }
451
+
452
+ JSValue update_finish(JSContext* ctx, JSValue, int argc, JSValue* argv) {
453
+ MIKOtaClientState* state = state_of(ctx);
454
+ if (!state || !state->apply.active || !state->apply.session.update) {
455
+ return mik__result_err_named(ctx, "StagingFailed", "no staging session");
456
+ }
457
+ MIKOtaInstallOptions options = state->apply.options;
458
+ if (argc > 0 && JS_IsObject(argv[0])) {
459
+ options.trial_boots =
460
+ (int)opt_u32(ctx, argv[0], "trialBoots", (uint32_t)options.trial_boots);
461
+ options.require_confirm = opt_bool(ctx, argv[0], "requireConfirm", options.require_confirm);
462
+ }
463
+ MIKOtaError error;
464
+ if (!state->apply.session.update->finish(options, &error)) {
465
+ return mik__result_err_obj(ctx, error_to_js(ctx, error));
466
+ }
467
+ return mik__result_ok_void(ctx);
468
+ }
469
+
470
+ JSValue update_abort(JSContext* ctx, JSValue, int, JSValue*) {
471
+ MIKOtaClientState* state = state_of(ctx);
472
+ if (state && state->apply.active && state->apply.session.update) {
473
+ state->apply.session.update->abort();
474
+ }
475
+ return JS_UNDEFINED;
476
+ }
477
+
478
+ JSValue make_update_object(JSContext* ctx, size_t resume_offset) {
479
+ JSValue update = JS_NewObject(ctx);
480
+ JS_SetPropertyStr(ctx, update, "resumeOffset",
481
+ JS_NewInt64(ctx, static_cast<int64_t>(resume_offset)));
482
+ JS_SetPropertyStr(ctx, update, "write", JS_NewCFunction(ctx, update_write, "write", 1));
483
+ JS_SetPropertyStr(ctx, update, "finish", JS_NewCFunction(ctx, update_finish, "finish", 1));
484
+ JS_SetPropertyStr(ctx, update, "abort", JS_NewCFunction(ctx, update_abort, "abort", 0));
485
+ return update;
486
+ }
487
+
488
+ /* Close the attempt and settle applyOffer's promise. */
489
+ void settle_apply(JSContext* ctx, bool downloaded, const std::string& message) {
490
+ MIKOtaClientState* state = state_of(ctx);
491
+ if (!state || !state->apply.active) return;
492
+
493
+ MIKOtaApplyOutcome outcome = MIKOtaApplyOutcome::kStaged;
494
+ MIKOtaError error;
495
+ bool ok = mikrojs::mik__ota_policy_apply_end(&state->apply.session, downloaded, message,
496
+ state->apply.options, &outcome, &error);
497
+ JSValue result = ok ? mik__result_ok(ctx, JS_NewString(ctx, mikrojs::mik__ota_outcome_to_str(
498
+ outcome)))
499
+ : mik__result_err_obj(ctx, error_to_js(ctx, error));
500
+ state->apply.active = false;
501
+ MIK_ResolvePromise(ctx, &state->apply.promise, 1, &result);
502
+ }
503
+
504
+ JSValue download_fulfilled(JSContext* ctx, JSValue, int argc, JSValue* argv) {
505
+ /* The callback's contract is Result<void, {message}>: a rejected download is
506
+ * transient, so the message is all that survives into DownloadFailed. */
507
+ bool downloaded = false;
508
+ std::string message = "download failed";
509
+ if (argc > 0 && JS_IsObject(argv[0])) {
510
+ JSValue ok = JS_GetPropertyStr(ctx, argv[0], "ok");
511
+ downloaded = JS_ToBool(ctx, ok);
512
+ JS_FreeValue(ctx, ok);
513
+ if (!downloaded) {
514
+ JSValue error = JS_GetPropertyStr(ctx, argv[0], "error");
515
+ JSValue msg = JS_GetPropertyStr(ctx, error, "message");
516
+ const char* text = JS_ToCString(ctx, msg);
517
+ if (text) {
518
+ message = text;
519
+ JS_FreeCString(ctx, text);
520
+ }
521
+ JS_FreeValue(ctx, msg);
522
+ JS_FreeValue(ctx, error);
523
+ }
524
+ }
525
+ settle_apply(ctx, downloaded, message);
526
+ return JS_UNDEFINED;
527
+ }
528
+
529
+ JSValue download_rejected(JSContext* ctx, JSValue, int argc, JSValue* argv) {
530
+ std::string message = "download threw";
531
+ if (argc > 0) {
532
+ const char* text = JS_ToCString(ctx, argv[0]);
533
+ if (text) {
534
+ message = text;
535
+ JS_FreeCString(ctx, text);
536
+ }
537
+ }
538
+ settle_apply(ctx, false, message);
539
+ return JS_UNDEFINED;
540
+ }
541
+
542
+ /* applyOffer(offer, download, options?) -> Promise<Result<ApplyOutcome, OtaError>> */
543
+ JSValue ota_apply_offer(JSContext* ctx, JSValue, int argc, JSValue* argv) {
544
+ MIKOtaClientState* state = state_of(ctx);
545
+ CHECK_NOT_NULL(state);
546
+ if (argc < 2 || !JS_IsFunction(ctx, argv[1])) {
547
+ return JS_ThrowTypeError(ctx, "applyOffer(offer, download) needs a download function");
548
+ }
549
+ if (state->apply.active) {
550
+ JSValue busy = mik__result_err_named(ctx, "StagingFailed", "an update is already staging");
551
+ return MIK_NewResolvedPromise(ctx, 1, &busy);
552
+ }
553
+
554
+ MIKOtaOffer offer;
555
+ if (!mikrojs::mik__ota_parse_offer_js(ctx, argv[0], /*allow_insecure=*/true, &offer)) {
556
+ JSValue bad = mik__result_err_named(ctx, "StagingFailed", "offer is not usable");
557
+ return MIK_NewResolvedPromise(ctx, 1, &bad);
558
+ }
559
+
560
+ MIKOtaInstallOptions options;
561
+ if (argc > 2 && JS_IsObject(argv[2])) {
562
+ options.trial_boots =
563
+ (int)opt_u32(ctx, argv[2], "trialBoots", (uint32_t)options.trial_boots);
564
+ options.require_confirm = opt_bool(ctx, argv[2], "requireConfirm", options.require_confirm);
565
+ JSValue install = JS_GetPropertyStr(ctx, argv[2], "install");
566
+ const char* mode = JS_IsString(install) ? JS_ToCString(ctx, install) : nullptr;
567
+ options.install_now = mode && strcmp(mode, "now") == 0;
568
+ if (mode) JS_FreeCString(ctx, mode);
569
+ JS_FreeValue(ctx, install);
570
+ }
571
+
572
+ MIKOtaApplyOutcome outcome = MIKOtaApplyOutcome::kStaged;
573
+ MIKOtaError error;
574
+ if (!mikrojs::mik__ota_policy_apply_begin(state->env, offer, &state->apply.session, &outcome,
575
+ &error)) {
576
+ JSValue failed = mik__result_err_obj(ctx, error_to_js(ctx, error));
577
+ return MIK_NewResolvedPromise(ctx, 1, &failed);
578
+ }
579
+ if (!state->apply.session.update) {
580
+ /* Declined before staging: trial pending, current, abandoned, exhausted. */
581
+ JSValue declined =
582
+ mik__result_ok(ctx, JS_NewString(ctx, mikrojs::mik__ota_outcome_to_str(outcome)));
583
+ return MIK_NewResolvedPromise(ctx, 1, &declined);
584
+ }
585
+
586
+ state->apply.options = options;
587
+ state->apply.active = true;
588
+ JSValue promise = MIK_InitPromise(ctx, &state->apply.promise);
589
+ if (JS_IsException(promise)) {
590
+ state->apply.active = false;
591
+ state->apply.session.update.reset();
592
+ return JS_EXCEPTION;
593
+ }
594
+
595
+ JSValue update = make_update_object(ctx, state->apply.session.update->resume_offset());
596
+ JSValue returned = JS_Call(ctx, argv[1], JS_UNDEFINED, 1, &update);
597
+ JS_FreeValue(ctx, update);
598
+
599
+ if (JS_IsException(returned)) {
600
+ JSValue exc = JS_GetException(ctx);
601
+ const char* text = JS_ToCString(ctx, exc);
602
+ std::string message = text ? text : "download threw";
603
+ if (text) JS_FreeCString(ctx, text);
604
+ JS_FreeValue(ctx, exc);
605
+ JS_FreeValue(ctx, returned);
606
+ settle_apply(ctx, false, message);
607
+ return promise;
608
+ }
609
+
610
+ /* The callback may hand back a plain Result rather than a promise — or
611
+ * nothing, which is not a Result but must not throw on the way to being
612
+ * reported as a failed download. */
613
+ JSValue then = JS_IsObject(returned) ? JS_GetPropertyStr(ctx, returned, "then") : JS_UNDEFINED;
614
+ if (!JS_IsFunction(ctx, then)) {
615
+ JS_FreeValue(ctx, then);
616
+ JSValue args[1] = {returned};
617
+ JSValue ignored = download_fulfilled(ctx, JS_UNDEFINED, 1, args);
618
+ JS_FreeValue(ctx, ignored);
619
+ JS_FreeValue(ctx, returned);
620
+ return promise;
621
+ }
622
+ JSValue on_ok = JS_NewCFunction(ctx, download_fulfilled, "onFulfilled", 1);
623
+ JSValue on_err = JS_NewCFunction(ctx, download_rejected, "onRejected", 1);
624
+ JSValue chain_args[2] = {on_ok, on_err};
625
+ JSValue chained = JS_Call(ctx, then, returned, 2, chain_args);
626
+ JS_FreeValue(ctx, chained);
627
+ JS_FreeValue(ctx, on_ok);
628
+ JS_FreeValue(ctx, on_err);
629
+ JS_FreeValue(ctx, then);
630
+ JS_FreeValue(ctx, returned);
631
+ return promise;
632
+ }
633
+
634
+ // ── config delivery ─────────────────────────────────────────────────────────
635
+ //
636
+ // The write side of config sync, for a client that received a document over its
637
+ // own transport. Where the slot policy lives is src/mik_ota_config.cpp, shared
638
+ // with the built-in client, so the two cannot disagree about the trial or the
639
+ // rollback baseline. What is here is the marshalling.
640
+
641
+ /* Copy a string field into a fixed buffer. False when it is the wrong type or
642
+ * too long to hold: a truncated rev never matches the one the registry issued,
643
+ * so the device would be served the same document forever. */
644
+ bool take_config_field(JSContext* ctx, JSValue obj, const char* key, char* out, size_t out_len,
645
+ bool* out_present) {
646
+ *out_present = false;
647
+ JSValue value = JS_GetPropertyStr(ctx, obj, key);
648
+ if (JS_IsUndefined(value) || JS_IsNull(value)) {
649
+ JS_FreeValue(ctx, value);
650
+ return true;
651
+ }
652
+ if (!JS_IsString(value)) {
653
+ JS_FreeValue(ctx, value);
654
+ return false;
655
+ }
656
+ const char* text = JS_ToCString(ctx, value);
657
+ JS_FreeValue(ctx, value);
658
+ if (!text) return false;
659
+ bool fits = strlen(text) < out_len;
660
+ if (fits) {
661
+ snprintf(out, out_len, "%s", text);
662
+ *out_present = text[0] != '\0';
663
+ }
664
+ JS_FreeCString(ctx, text);
665
+ return fits;
666
+ }
667
+
668
+ /* Validate a raw value into the fields a stored config carries. `*out_doc` is
669
+ * the document to store, or JS_UNDEFINED for the clear; the caller owns it. */
670
+ bool config_from_js(JSContext* ctx, JSValue raw, MIKOtaStoredConfig* out, JSValue* out_doc) {
671
+ *out_doc = JS_UNDEFINED;
672
+ if (!JS_IsObject(raw) || JS_IsArray(raw) || JS_IsFunction(ctx, raw)) return false;
673
+
674
+ /* The stamp is what says which release the document was computed for, and
675
+ * every read is decided by it. A document without one cannot be placed. */
676
+ bool has_version = false;
677
+ if (!take_config_field(ctx, raw, "version", out->version, sizeof(out->version),
678
+ &has_version)) {
679
+ return false;
680
+ }
681
+ if (!has_version) return false;
682
+
683
+ bool has_rev = false;
684
+ if (!take_config_field(ctx, raw, "rev", out->rev, sizeof(out->rev), &has_rev)) return false;
685
+
686
+ JSValue doc = JS_GetPropertyStr(ctx, raw, "doc");
687
+ if (JS_IsUndefined(doc) || JS_IsNull(doc)) {
688
+ /* An absent document is the clear, not a malformed config. */
689
+ JS_FreeValue(ctx, doc);
690
+ return true;
691
+ }
692
+ /* The document is spread over the manifest defaults, top level only.
693
+ * Anything that is not a plain object has no keys to spread, so storing one
694
+ * would read back as an empty overlay rather than as the error it is. */
695
+ if (!JS_IsObject(doc) || JS_IsArray(doc) || JS_IsFunction(ctx, doc)) {
696
+ JS_FreeValue(ctx, doc);
697
+ return false;
698
+ }
699
+ *out_doc = doc;
700
+ return true;
701
+ }
702
+
703
+ /* Encode a document to the CBOR the slots store. False for a value CBOR cannot
704
+ * carry (a function, a cycle, past the depth limit). */
705
+ bool encode_config_doc(JSContext* ctx, JSValue doc, std::vector<uint8_t>* out) {
706
+ /* Pass 1 sizes it against a zero-capacity buffer; the base pointer must be
707
+ * non-null because a zero-length append still reaches memcpy. */
708
+ static uint8_t measure_base;
709
+ nanocbor_encoder_t enc;
710
+ nanocbor_encoder_init(&enc, &measure_base, 0);
711
+ bool ok = mik__cbor_encode_value(ctx, &enc, doc, 0) >= 0;
712
+ size_t needed = nanocbor_encoded_len(&enc);
713
+ if (ok && needed > 0) {
714
+ out->resize(needed);
715
+ nanocbor_encoder_init(&enc, out->data(), needed);
716
+ ok = mik__cbor_encode_value(ctx, &enc, doc, 0) >= 0;
717
+ } else {
718
+ ok = false;
719
+ }
720
+ /* Property enumeration can throw on the way out, and an exception left on
721
+ * the context would surface at whatever ran next. */
722
+ if (JS_HasException(ctx)) JS_FreeValue(ctx, JS_GetException(ctx));
723
+ return ok;
724
+ }
725
+
726
+ /* parseConfig(raw) -> StoredConfig | undefined */
727
+ JSValue ota_parse_config(JSContext* ctx, JSValue, int argc, JSValue* argv) {
728
+ MIKOtaStoredConfig cfg = {};
729
+ JSValue doc = JS_UNDEFINED;
730
+ if (argc < 1 || !config_from_js(ctx, argv[0], &cfg, &doc)) {
731
+ JS_FreeValue(ctx, doc);
732
+ return JS_UNDEFINED;
733
+ }
734
+ JSValue obj = JS_NewObject(ctx);
735
+ if (cfg.rev[0]) JS_SetPropertyStr(ctx, obj, "rev", JS_NewString(ctx, cfg.rev));
736
+ JS_SetPropertyStr(ctx, obj, "version", JS_NewString(ctx, cfg.version));
737
+ if (JS_IsUndefined(doc)) return obj;
738
+ /* The document travels on as it arrived: whether it survives CBOR is
739
+ * settled by applyConfig, which is where the bytes are actually made. */
740
+ JS_SetPropertyStr(ctx, obj, "doc", doc);
741
+ return obj;
742
+ }
743
+
744
+ /* applyConfig(cfg, {trialBoots}) -> ConfigWrite */
745
+ JSValue ota_apply_config(JSContext* ctx, JSValue, int argc, JSValue* argv) {
746
+ MIKOtaClientState* state = state_of(ctx);
747
+ CHECK_NOT_NULL(state);
748
+
749
+ MIKOtaStoredConfig cfg = {};
750
+ JSValue doc = JS_UNDEFINED;
751
+ if (argc < 1 || !config_from_js(ctx, argv[0], &cfg, &doc)) {
752
+ JS_FreeValue(ctx, doc);
753
+ return JS_NewString(ctx, "invalid");
754
+ }
755
+
756
+ /* Held until the write is done: the stored config's span points into it. */
757
+ std::vector<uint8_t> bytes;
758
+ if (!JS_IsUndefined(doc)) {
759
+ bool encoded = encode_config_doc(ctx, doc, &bytes);
760
+ JS_FreeValue(ctx, doc);
761
+ if (!encoded) return JS_NewString(ctx, "invalid");
762
+ cfg.doc_cbor = bytes.data();
763
+ cfg.doc_cbor_len = bytes.size();
764
+ }
765
+
766
+ MIKOtaCheckOptions defaults;
767
+ int trial_boots = defaults.trial_boots;
768
+ if (argc > 1 && JS_IsObject(argv[1])) {
769
+ trial_boots = (int)opt_u32(ctx, argv[1], "trialBoots", (uint32_t)trial_boots);
770
+ }
771
+ /* A budget of zero would roll the document back on the first read that
772
+ * serves it, which is never what an app meant by delivering one. */
773
+ if (trial_boots < 1) trial_boots = 1;
774
+
775
+ MIKOtaConfigWrite write = mikrojs::mik__ota_deliver_config(state->env, &cfg, trial_boots);
776
+ return JS_NewString(ctx, mikrojs::mik__ota_config_write_to_str(write));
777
+ }
778
+
779
+ /* configState() -> {rev?, error?} */
780
+ JSValue ota_config_state(JSContext* ctx, JSValue, int, JSValue*) {
781
+ MIKOtaClientState* state = state_of(ctx);
782
+ CHECK_NOT_NULL(state);
783
+
784
+ JSValue obj = JS_NewObject(ctx);
785
+ MIKOtaConfigErrorReport report = {};
786
+ bool has_error = mikrojs::mik__ota_load_config_error(state->env, &report);
787
+ if (has_error) {
788
+ JSValue error = JS_NewObject(ctx);
789
+ JS_SetPropertyStr(ctx, error, "rev", JS_NewString(ctx, report.rev));
790
+ JS_SetPropertyStr(ctx, error, "message", JS_NewString(ctx, report.message));
791
+ JS_SetPropertyStr(ctx, obj, "error", error);
792
+ }
793
+ /* After a rollback the FAILED document's rev is the one to echo, not the
794
+ * restored one's: echoed-equals-held is what stops the registry serving the
795
+ * document that just failed, until an operator changes it. */
796
+ mikrojs::MIKOtaLoadedConfig held = mikrojs::mik__ota_load_slot(state->env, MIK_OTA_CFG_CURRENT);
797
+ const char* rev = has_error ? report.rev : (held.present ? held.cfg.rev : "");
798
+ if (rev[0]) JS_SetPropertyStr(ctx, obj, "rev", JS_NewString(ctx, rev));
799
+ return obj;
800
+ }
801
+
802
+ int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
803
+ JS_SetModuleExport(ctx, m, "check", JS_NewCFunction(ctx, mik__ota_client_check, "check", 1));
804
+ JS_SetModuleExport(ctx, m, "watch", JS_NewCFunction(ctx, mik__ota_client_watch, "watch", 1));
805
+ JS_SetModuleExport(ctx, m, "config", JS_NewCFunction(ctx, mik__ota_client_config, "config", 0));
806
+ JS_SetModuleExport(ctx, m, "reconcile", JS_NewCFunction(ctx, ota_reconcile, "reconcile", 0));
807
+ JS_SetModuleExport(ctx, m, "running", JS_NewCFunction(ctx, ota_running, "running", 0));
808
+ JS_SetModuleExport(ctx, m, "confirm", JS_NewCFunction(ctx, ota_confirm, "confirm", 0));
809
+ JS_SetModuleExport(ctx, m, "revert", JS_NewCFunction(ctx, ota_revert, "revert", 0));
810
+ JS_SetModuleExport(ctx, m, "bearer", JS_NewCFunction(ctx, ota_bearer, "bearer", 0));
811
+ JS_SetModuleExport(ctx, m, "registry", JS_NewCFunction(ctx, ota_registry, "registry", 0));
812
+ JS_SetModuleExport(ctx, m, "parseOffer",
813
+ JS_NewCFunction(ctx, ota_parse_offer, "parseOffer", 2));
814
+ JS_SetModuleExport(ctx, m, "applyOffer",
815
+ JS_NewCFunction(ctx, ota_apply_offer, "applyOffer", 3));
816
+ JS_SetModuleExport(ctx, m, "parseConfig",
817
+ JS_NewCFunction(ctx, ota_parse_config, "parseConfig", 1));
818
+ JS_SetModuleExport(ctx, m, "applyConfig",
819
+ JS_NewCFunction(ctx, ota_apply_config, "applyConfig", 2));
820
+ JS_SetModuleExport(ctx, m, "configState",
821
+ JS_NewCFunction(ctx, ota_config_state, "configState", 0));
822
+ return 0;
823
+ }
824
+
825
+ } // namespace
826
+
827
+ JSModuleDef* mik__ota_client_init(JSContext* ctx) {
828
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
829
+ CHECK_NOT_NULL(mik_rt);
830
+ if (mik__ota_client_slot < 0) mik__ota_client_slot = MIK_ReserveModuleSlot();
831
+
832
+ /* The bytecode version needs a context to derive (JS_WriteObject's first
833
+ * byte), so it is read here and handed to the env, which has none. */
834
+ size_t bc_len = 0;
835
+ uint8_t* bc_buf = JS_WriteObject(ctx, &bc_len, JS_NULL, JS_WRITE_OBJ_BYTECODE);
836
+ int bytecode_version = (bc_buf && bc_len > 0) ? bc_buf[0] : 0;
837
+ if (bc_buf) js_free(ctx, bc_buf);
838
+
839
+ auto* state = new MIKOtaClientState();
840
+ state->ctx = ctx;
841
+ /* The client drives HTTP from C and never imports native:mikro/http, so
842
+ * nothing else would bring the transport up. */
843
+ mik__http_ensure_native(ctx);
844
+
845
+ const MIKOtaEnv* env = mik__ota_env_for(mik_rt, bytecode_version);
846
+ state->env = env;
847
+ state->config = std::make_unique<MIKOtaConfigReader>(env);
848
+ state->client = std::make_unique<MIKOtaClient>(env);
849
+ mik__ota_client_st(mik_rt) = state;
850
+
851
+ JSModuleDef* m = JS_NewCModule(ctx, "native:mikro/ota_client", mik__ota_client_module_init);
852
+ if (!m) {
853
+ /* The loop consumer is only registered when init returns a module. */
854
+ delete state;
855
+ mik__ota_client_st(mik_rt) = nullptr;
856
+ return nullptr;
857
+ }
858
+ JS_AddModuleExport(ctx, m, "check");
859
+ JS_AddModuleExport(ctx, m, "watch");
860
+ JS_AddModuleExport(ctx, m, "config");
861
+ JS_AddModuleExport(ctx, m, "reconcile");
862
+ JS_AddModuleExport(ctx, m, "running");
863
+ JS_AddModuleExport(ctx, m, "confirm");
864
+ JS_AddModuleExport(ctx, m, "revert");
865
+ JS_AddModuleExport(ctx, m, "bearer");
866
+ JS_AddModuleExport(ctx, m, "registry");
867
+ JS_AddModuleExport(ctx, m, "parseOffer");
868
+ JS_AddModuleExport(ctx, m, "applyOffer");
869
+ JS_AddModuleExport(ctx, m, "parseConfig");
870
+ JS_AddModuleExport(ctx, m, "applyConfig");
871
+ JS_AddModuleExport(ctx, m, "configState");
872
+ return m;
873
+ }
874
+
875
+ void mik__ota_client_consume(JSContext* ctx) {
876
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
877
+ CHECK_NOT_NULL(mik_rt);
878
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
879
+ if (!state || !state->client) return;
880
+
881
+ state->client->Poll();
882
+
883
+ /* Drop settled checks. Done after Poll rather than inside the sink so the
884
+ * vector is never mutated while the machine is walking it. */
885
+ for (size_t i = state->pending.size(); i > 0; i--) {
886
+ if (state->pending[i - 1]->settled) state->pending.erase(state->pending.begin() + (i - 1));
887
+ }
888
+ }
889
+
890
+ void mik__ota_client_destroy(JSContext* ctx) {
891
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
892
+ CHECK_NOT_NULL(mik_rt);
893
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
894
+ if (!state) return;
895
+
896
+ if (state->client) state->client->StopWatch();
897
+ /* Anything still waiting will never settle: free the promise values so
898
+ * teardown does not leave JSValues behind. */
899
+ for (auto& pending : state->pending) {
900
+ if (!pending->settled) MIK_FreePromise(ctx, &pending->promise);
901
+ }
902
+ state->pending.clear();
903
+ /* The client before the hooks: a round parked in beforeCheck or teardown
904
+ * still holds the hook pointer, and the client's teardown is what lets it
905
+ * go. */
906
+ if (state->apply.active) {
907
+ /* Nothing will settle it now; free the promise's values rather than
908
+ * leaving them behind. */
909
+ MIK_FreePromise(ctx, &state->apply.promise);
910
+ state->apply.active = false;
911
+ }
912
+ state->client.reset();
913
+ state->hooks.reset();
914
+ JS_FreeValue(ctx, state->on_config);
915
+ state->on_config = JS_UNDEFINED;
916
+ /* Before the runtime: the reader holds the last document it served. */
917
+ state->config.reset();
918
+ delete state;
919
+ mik__ota_client_st(mik_rt) = nullptr;
920
+ }
921
+
922
+ MIK_REGISTER_MODULE(ota_client, "native:mikro/ota_client", mik__ota_client_init,
923
+ mik__ota_client_consume, mik__ota_client_destroy)