@mikrojs/firmware 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.
@@ -0,0 +1,743 @@
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/ota_client.h"
18
+ #include "mikrojs/ota_config.h"
19
+ #include "mikrojs/ota_js_hooks.h"
20
+ #include "mikrojs/ota_policy.h"
21
+ #include "mikrojs/private.h"
22
+ #include "mikrojs/utils.h"
23
+
24
+ using mikrojs::MIKOtaCheckOptions;
25
+ using mikrojs::MIKOtaCheckResult;
26
+ using mikrojs::MIKOtaCheckStatus;
27
+ using mikrojs::MIKOtaClient;
28
+ using mikrojs::MIKOtaConfigReader;
29
+ using mikrojs::MIKOtaJsHooks;
30
+ using mikrojs::MIKOtaApplyOutcome;
31
+ using mikrojs::MIKOtaApplySession;
32
+ using mikrojs::MIKOtaError;
33
+ using mikrojs::MIKOtaInstallOptions;
34
+ using mikrojs::MIKOtaOffer;
35
+ using mikrojs::MIKOtaHookState;
36
+ using mikrojs::MIKOtaRoundHooks;
37
+ using mikrojs::MIKOtaWatchOptions;
38
+
39
+ #define MIK_OTA_CLIENT_TAG "native:mikro/ota_client"
40
+
41
+ namespace {
42
+
43
+ /* One in-flight check() and the promise waiting on it. */
44
+ struct PendingCheck {
45
+ MIKPromise promise;
46
+ bool settled = false;
47
+ };
48
+
49
+ /* One apply attempt, held open while the app's download callback runs. Staging
50
+ * is a single native session, so there is at most one. */
51
+ struct PendingApply {
52
+ MIKOtaApplySession session;
53
+ MIKPromise promise;
54
+ MIKOtaInstallOptions options;
55
+ bool active = false;
56
+ };
57
+
58
+ struct MIKOtaClientState {
59
+ JSContext* ctx = nullptr;
60
+ const MIKOtaEnv* env = nullptr;
61
+ PendingApply apply;
62
+ std::unique_ptr<MIKOtaConfigReader> config;
63
+ std::unique_ptr<MIKOtaClient> client;
64
+ std::unique_ptr<MIKOtaJsHooks> hooks;
65
+ /* The watcher's onConfig, held for the life of the watch. */
66
+ JSValue on_config = JS_UNDEFINED;
67
+ std::vector<std::unique_ptr<PendingCheck>> pending;
68
+ bool watching = false;
69
+ };
70
+
71
+ int mik__ota_client_slot = -1;
72
+
73
+ inline MIKOtaClientState*& mik__ota_client_st(MIKRuntime* rt) {
74
+ return reinterpret_cast<MIKOtaClientState*&>(rt->module_data[mik__ota_client_slot]);
75
+ }
76
+
77
+ // ── result marshalling ───────────────────────────────────────────────────────
78
+
79
+ void set_error(JSContext* ctx, JSValue target, const mikrojs::MIKOtaError& error,
80
+ int http_status) {
81
+ JSValue obj = JS_NewObject(ctx);
82
+ JS_SetPropertyStr(ctx, obj, "name",
83
+ JS_NewString(ctx, error.name.empty() ? "Network" : error.name.c_str()));
84
+ if (!error.kind.empty()) {
85
+ JS_SetPropertyStr(ctx, obj, "kind", JS_NewString(ctx, error.kind.c_str()));
86
+ }
87
+ if (!error.message.empty()) {
88
+ JS_SetPropertyStr(ctx, obj, "message", JS_NewString(ctx, error.message.c_str()));
89
+ }
90
+ if (error.name == "Status" && http_status != 0) {
91
+ JS_SetPropertyStr(ctx, obj, "status", JS_NewInt32(ctx, http_status));
92
+ }
93
+ JS_SetPropertyStr(ctx, target, "error", obj);
94
+ }
95
+
96
+ JSValue result_to_js(JSContext* ctx, const MIKOtaCheckResult& result) {
97
+ JSValue obj = JS_NewObject(ctx);
98
+ JS_SetPropertyStr(ctx, obj, "status",
99
+ JS_NewString(ctx, mikrojs::mik__ota_check_status_to_str(result.status)));
100
+ switch (result.status) {
101
+ case MIKOtaCheckStatus::kStaged: {
102
+ JSValue offer = JS_NewObject(ctx);
103
+ JS_SetPropertyStr(ctx, offer, "url", JS_NewString(ctx, result.offer.url.c_str()));
104
+ JS_SetPropertyStr(ctx, offer, "checksum",
105
+ JS_NewString(ctx, result.offer.checksum.c_str()));
106
+ JS_SetPropertyStr(ctx, offer, "size", JS_NewInt64(ctx, (int64_t)result.offer.size));
107
+ JS_SetPropertyStr(ctx, obj, "offer", offer);
108
+ break;
109
+ }
110
+ case MIKOtaCheckStatus::kUpToDate:
111
+ /* Omitted rather than false when nothing changed, matching the JS
112
+ * client: the key's presence is the signal. */
113
+ if (result.config_updated) {
114
+ JS_SetPropertyStr(ctx, obj, "configUpdated", JS_TRUE);
115
+ }
116
+ break;
117
+ case MIKOtaCheckStatus::kNotStaged:
118
+ JS_SetPropertyStr(
119
+ ctx, obj, "reason",
120
+ JS_NewString(ctx, mikrojs::mik__ota_decline_reason_to_str(result.decline_reason)));
121
+ if (!result.error.name.empty()) set_error(ctx, obj, result.error, result.http_status);
122
+ break;
123
+ case MIKOtaCheckStatus::kFailed:
124
+ set_error(ctx, obj, result.error, result.http_status);
125
+ break;
126
+ case MIKOtaCheckStatus::kUnauthorized:
127
+ case MIKOtaCheckStatus::kNotEnrolled:
128
+ break;
129
+ }
130
+ return obj;
131
+ }
132
+
133
+ // ── option marshalling ───────────────────────────────────────────────────────
134
+
135
+ uint32_t opt_u32(JSContext* ctx, JSValue options, const char* key, uint32_t fallback) {
136
+ JSValue value = JS_GetPropertyStr(ctx, options, key);
137
+ uint32_t out = fallback;
138
+ if (JS_IsNumber(value)) {
139
+ int64_t raw = 0;
140
+ if (JS_ToInt64(ctx, &raw, value) == 0 && raw >= 0) out = (uint32_t)raw;
141
+ }
142
+ JS_FreeValue(ctx, value);
143
+ return out;
144
+ }
145
+
146
+ bool opt_bool(JSContext* ctx, JSValue options, const char* key, bool fallback) {
147
+ JSValue value = JS_GetPropertyStr(ctx, options, key);
148
+ bool out = JS_IsUndefined(value) ? fallback : JS_ToBool(ctx, value);
149
+ JS_FreeValue(ctx, value);
150
+ return out;
151
+ }
152
+
153
+ void read_check_options(JSContext* ctx, JSValue options, MIKOtaCheckOptions* out) {
154
+ if (!JS_IsObject(options)) return;
155
+ out->checkin_timeout_ms = opt_u32(ctx, options, "checkinTimeoutMs", out->checkin_timeout_ms);
156
+ out->download_timeout_ms = opt_u32(ctx, options, "downloadTimeoutMs", out->download_timeout_ms);
157
+ out->require_confirm = opt_bool(ctx, options, "requireConfirm", out->require_confirm);
158
+ out->trial_boots = (int)opt_u32(ctx, options, "trialBoots", (uint32_t)out->trial_boots);
159
+ }
160
+
161
+ // ── JS functions ─────────────────────────────────────────────────────────────
162
+
163
+ JSValue mik__ota_client_check(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
164
+ (void)this_val;
165
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
166
+ CHECK_NOT_NULL(mik_rt);
167
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
168
+ CHECK_NOT_NULL(state);
169
+
170
+ MIKOtaCheckOptions options;
171
+ if (argc > 0) read_check_options(ctx, argv[0], &options);
172
+
173
+ auto pending = std::make_unique<PendingCheck>();
174
+ JSValue promise = MIK_InitPromise(ctx, &pending->promise);
175
+ if (JS_IsException(promise)) return JS_EXCEPTION;
176
+
177
+ PendingCheck* slot = pending.get();
178
+ state->pending.push_back(std::move(pending));
179
+
180
+ state->client->Check(options, [state, slot](const MIKOtaCheckResult& result) {
181
+ JSValue value = result_to_js(state->ctx, result);
182
+ MIK_ResolvePromise(state->ctx, &slot->promise, 1, &value);
183
+ slot->settled = true;
184
+ });
185
+
186
+ /* A not-enrolled device settles inline, before Check() returns. Reap it here
187
+ * so the vector does not grow with every call on such a device. */
188
+ for (size_t i = state->pending.size(); i > 0; i--) {
189
+ if (state->pending[i - 1]->settled) state->pending.erase(state->pending.begin() + (i - 1));
190
+ }
191
+ return promise;
192
+ }
193
+
194
+ /* config() -> the effective config, or undefined. */
195
+ JSValue mik__ota_client_config(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
196
+ (void)this_val;
197
+ (void)argc;
198
+ (void)argv;
199
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
200
+ CHECK_NOT_NULL(mik_rt);
201
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
202
+ CHECK_NOT_NULL(state);
203
+ return state->config->Read(ctx);
204
+ }
205
+
206
+ JSValue mik__ota_client_stop(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
207
+ (void)this_val;
208
+ (void)argc;
209
+ (void)argv;
210
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
211
+ CHECK_NOT_NULL(mik_rt);
212
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
213
+ if (state && state->client) state->client->StopWatch();
214
+ return JS_UNDEFINED;
215
+ }
216
+
217
+ JSValue mik__ota_client_set_interval(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
218
+ (void)this_val;
219
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
220
+ CHECK_NOT_NULL(mik_rt);
221
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
222
+ if (!state || !state->client) return JS_UNDEFINED;
223
+ int64_t interval = 0;
224
+ if (argc < 1 || JS_ToInt64(ctx, &interval, argv[0]) < 0 || interval < 0) {
225
+ return JS_ThrowTypeError(ctx, "setCheckinInterval(ms) needs a non-negative number");
226
+ }
227
+ state->client->SetCheckinInterval(static_cast<uint32_t>(interval));
228
+ return JS_UNDEFINED;
229
+ }
230
+
231
+ JSValue mik__ota_client_watch(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
232
+ (void)this_val;
233
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
234
+ CHECK_NOT_NULL(mik_rt);
235
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
236
+ CHECK_NOT_NULL(state);
237
+
238
+ /* One watcher per runtime, and never a swap mid-round: replacing the hooks
239
+ * would pull them out from under the round that is still running them, and
240
+ * stop() only takes effect once that round finishes. */
241
+ if (state->client && state->client->watching()) {
242
+ return JS_ThrowTypeError(
243
+ ctx, "ota.watch() is already running; call stop() on its watcher first");
244
+ }
245
+ if (state->client && state->client->state() != mikrojs::MIKOtaClientState::kIdle) {
246
+ return JS_ThrowTypeError(
247
+ ctx, "ota.watch(): the previous round is still finishing; try again shortly");
248
+ }
249
+
250
+ MIKOtaWatchOptions options;
251
+ if (argc > 0 && JS_IsObject(argv[0])) {
252
+ read_check_options(ctx, argv[0], &options);
253
+ options.checkin_interval_ms =
254
+ opt_u32(ctx, argv[0], "checkinIntervalMs", options.checkin_interval_ms);
255
+ options.initial_delay_ms =
256
+ opt_u32(ctx, argv[0], "initialDelayMs", options.initial_delay_ms);
257
+ options.retry_after_failure_ms =
258
+ opt_u32(ctx, argv[0], "retryAfterFailureMs", options.retry_after_failure_ms);
259
+ options.jitter = opt_bool(ctx, argv[0], "jitter", options.jitter);
260
+
261
+ JSValue before_check = JS_GetPropertyStr(ctx, argv[0], "beforeCheck");
262
+ if (JS_IsFunction(ctx, before_check)) {
263
+ state->hooks = std::make_unique<MIKOtaJsHooks>(ctx, before_check);
264
+ options.hooks = state->hooks.get();
265
+ } else {
266
+ JS_FreeValue(ctx, before_check);
267
+ }
268
+
269
+ JS_FreeValue(ctx, state->on_config);
270
+ state->on_config = JS_GetPropertyStr(ctx, argv[0], "onConfig");
271
+ if (JS_IsFunction(ctx, state->on_config)) {
272
+ /* Only when a round actually changed the stored document. A staged
273
+ * release's config applies at its trial boot, not now, so it is not
274
+ * a change the running app can read yet. */
275
+ options.on_round = [state](const MIKOtaCheckResult& result) {
276
+ if (!result.config_updated) return;
277
+ JSValue config = state->config->Read(state->ctx);
278
+ if (JS_IsException(config)) {
279
+ /* Nothing to hand over: a build with no readable manifest
280
+ * and no stored document has no config to report. */
281
+ JS_FreeValue(state->ctx, JS_GetException(state->ctx));
282
+ return;
283
+ }
284
+ JSValue ignored =
285
+ JS_Call(state->ctx, state->on_config, JS_UNDEFINED, 1, &config);
286
+ if (JS_IsException(ignored)) mik_dump_error(state->ctx);
287
+ JS_FreeValue(state->ctx, ignored);
288
+ JS_FreeValue(state->ctx, config);
289
+ };
290
+ } else {
291
+ JS_FreeValue(ctx, state->on_config);
292
+ state->on_config = JS_UNDEFINED;
293
+ }
294
+ }
295
+
296
+ state->client->Watch(options);
297
+ state->watching = true;
298
+
299
+ JSValue handle = JS_NewObject(ctx);
300
+ JS_SetPropertyStr(ctx, handle, "stop",
301
+ JS_NewCFunction(ctx, mik__ota_client_stop, "stop", 0));
302
+ JS_SetPropertyStr(
303
+ ctx, handle, "setCheckinInterval",
304
+ JS_NewCFunction(ctx, mik__ota_client_set_interval, "setCheckinInterval", 1));
305
+ return handle;
306
+ }
307
+
308
+ // ── the policy surface (mikro/ota) ──────────────────────────────────────────
309
+ // Everything below marshals for functions that already exist in the portable
310
+ // policy. The only one with any machinery of its own is applyOffer, which has to
311
+ // hand the app's download callback a staging session and wait for it.
312
+
313
+ JSValue error_to_js(JSContext* ctx, const MIKOtaError& error) {
314
+ JSValue obj = JS_NewObject(ctx);
315
+ JS_SetPropertyStr(ctx, obj, "name",
316
+ JS_NewString(ctx, error.name.empty() ? "InstallFailed" : error.name.c_str()));
317
+ if (!error.kind.empty()) {
318
+ JS_SetPropertyStr(ctx, obj, "kind", JS_NewString(ctx, error.kind.c_str()));
319
+ }
320
+ JS_SetPropertyStr(ctx, obj, "message", JS_NewString(ctx, error.message.c_str()));
321
+ return obj;
322
+ }
323
+
324
+ MIKOtaClientState* state_of(JSContext* ctx) {
325
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
326
+ if (!mik_rt) return nullptr;
327
+ return mik__ota_client_st(mik_rt);
328
+ }
329
+
330
+ JSValue kv_string_or_undefined(JSContext* ctx, const char* key) {
331
+ MIKOtaClientState* state = state_of(ctx);
332
+ if (!state || !state->env || !state->env->kv_get_str) return JS_UNDEFINED;
333
+ char buf[256] = {};
334
+ if (!state->env->kv_get_str(state->env->opaque, key, buf, sizeof(buf)) || buf[0] == '\0') {
335
+ return JS_UNDEFINED;
336
+ }
337
+ return JS_NewString(ctx, buf);
338
+ }
339
+
340
+ /* reconcile() -> { installed?, reverted, lastInstall? } */
341
+ JSValue ota_reconcile(JSContext* ctx, JSValue, int, JSValue*) {
342
+ MIKOtaClientState* state = state_of(ctx);
343
+ CHECK_NOT_NULL(state);
344
+ MIKOtaReconcileOutcome outcome = mikrojs::mik__ota_policy_reconcile(state->env);
345
+
346
+ JSValue obj = JS_NewObject(ctx);
347
+ if (outcome.installed[0]) {
348
+ JS_SetPropertyStr(ctx, obj, "installed", JS_NewString(ctx, outcome.installed));
349
+ }
350
+ JS_SetPropertyStr(ctx, obj, "reverted", JS_NewBool(ctx, outcome.reverted));
351
+ if (outcome.has_diagnostic) {
352
+ JSValue diag = JS_NewObject(ctx);
353
+ JS_SetPropertyStr(ctx, diag, "reason", JS_NewString(ctx, outcome.diagnostic.reason));
354
+ if (outcome.diagnostic.detail[0]) {
355
+ JS_SetPropertyStr(ctx, diag, "detail", JS_NewString(ctx, outcome.diagnostic.detail));
356
+ }
357
+ JS_SetPropertyStr(ctx, obj, "lastInstall", diag);
358
+ }
359
+ return obj;
360
+ }
361
+
362
+ /* running() -> { checksum?, version?, trial } */
363
+ JSValue ota_running(JSContext* ctx, JSValue, int, JSValue*) {
364
+ MIKOtaClientState* state = state_of(ctx);
365
+ CHECK_NOT_NULL(state);
366
+ MIKOtaRunningBuild running = mikrojs::mik__ota_policy_running(state->env);
367
+
368
+ JSValue obj = JS_NewObject(ctx);
369
+ if (running.checksum[0]) {
370
+ JS_SetPropertyStr(ctx, obj, "checksum", JS_NewString(ctx, running.checksum));
371
+ }
372
+ if (running.version[0]) {
373
+ JS_SetPropertyStr(ctx, obj, "version", JS_NewString(ctx, running.version));
374
+ }
375
+ JS_SetPropertyStr(ctx, obj, "trial", JS_NewBool(ctx, running.trial));
376
+ return obj;
377
+ }
378
+
379
+ JSValue ota_confirm(JSContext* ctx, JSValue, int, JSValue*) {
380
+ MIKOtaClientState* state = state_of(ctx);
381
+ CHECK_NOT_NULL(state);
382
+ mikrojs::mik__ota_policy_confirm(state->env);
383
+ return JS_UNDEFINED;
384
+ }
385
+
386
+ JSValue ota_revert(JSContext* ctx, JSValue, int, JSValue*) {
387
+ MIKOtaClientState* state = state_of(ctx);
388
+ CHECK_NOT_NULL(state);
389
+ MIKOtaError error;
390
+ if (!mikrojs::mik__ota_policy_revert(state->env, &error)) {
391
+ return mik__result_err_obj(ctx, error_to_js(ctx, error));
392
+ }
393
+ return mik__result_ok_void(ctx);
394
+ }
395
+
396
+ JSValue ota_bearer(JSContext* ctx, JSValue, int, JSValue*) {
397
+ return kv_string_or_undefined(ctx, "ota.updateKey");
398
+ }
399
+
400
+ JSValue ota_registry(JSContext* ctx, JSValue, int, JSValue*) {
401
+ return kv_string_or_undefined(ctx, "ota.registry");
402
+ }
403
+
404
+ JSValue offer_to_js(JSContext* ctx, const MIKOtaOffer& offer) {
405
+ JSValue obj = JS_NewObject(ctx);
406
+ JS_SetPropertyStr(ctx, obj, "url", JS_NewString(ctx, offer.url.c_str()));
407
+ JS_SetPropertyStr(ctx, obj, "checksum", JS_NewString(ctx, offer.checksum.c_str()));
408
+ JS_SetPropertyStr(ctx, obj, "size", JS_NewInt64(ctx, static_cast<int64_t>(offer.size)));
409
+ return obj;
410
+ }
411
+
412
+ /* parseOffer(raw, {allowInsecure}) -> Offer | undefined */
413
+ JSValue ota_parse_offer(JSContext* ctx, JSValue, int argc, JSValue* argv) {
414
+ bool allow_insecure = false;
415
+ if (argc > 1 && JS_IsObject(argv[1])) {
416
+ allow_insecure = opt_bool(ctx, argv[1], "allowInsecure", false);
417
+ }
418
+ MIKOtaOffer offer;
419
+ if (!mikrojs::mik__ota_parse_offer_js(ctx, argc > 0 ? argv[0] : JS_UNDEFINED, allow_insecure,
420
+ &offer)) {
421
+ return JS_UNDEFINED;
422
+ }
423
+ return offer_to_js(ctx, offer);
424
+ }
425
+
426
+ // ── applyOffer ──────────────────────────────────────────────────────────────
427
+
428
+ /* The staging session, as the download callback sees it. Recovered from the
429
+ * module state rather than carried on the object: staging is a single native
430
+ * session, so there is only ever one to find. */
431
+ JSValue update_write(JSContext* ctx, JSValue, int argc, JSValue* argv) {
432
+ MIKOtaClientState* state = state_of(ctx);
433
+ if (!state || !state->apply.active || !state->apply.session.update) {
434
+ return mik__result_err_named(ctx, "StagingFailed", "no staging session");
435
+ }
436
+ size_t len = 0;
437
+ const uint8_t* data = argc > 0 ? JS_GetUint8Array(ctx, &len, argv[0]) : nullptr;
438
+ if (!data) {
439
+ JS_FreeValue(ctx, JS_GetException(ctx));
440
+ return mik__result_err_named(ctx, "StagingFull", "bytes not a Uint8Array");
441
+ }
442
+ MIKOtaError error;
443
+ if (!state->apply.session.update->write(data, len, &error)) {
444
+ return mik__result_err_obj(ctx, error_to_js(ctx, error));
445
+ }
446
+ return mik__result_ok_void(ctx);
447
+ }
448
+
449
+ JSValue update_finish(JSContext* ctx, JSValue, int argc, JSValue* argv) {
450
+ MIKOtaClientState* state = state_of(ctx);
451
+ if (!state || !state->apply.active || !state->apply.session.update) {
452
+ return mik__result_err_named(ctx, "StagingFailed", "no staging session");
453
+ }
454
+ MIKOtaInstallOptions options = state->apply.options;
455
+ if (argc > 0 && JS_IsObject(argv[0])) {
456
+ options.trial_boots =
457
+ (int)opt_u32(ctx, argv[0], "trialBoots", (uint32_t)options.trial_boots);
458
+ options.require_confirm = opt_bool(ctx, argv[0], "requireConfirm", options.require_confirm);
459
+ }
460
+ MIKOtaError error;
461
+ if (!state->apply.session.update->finish(options, &error)) {
462
+ return mik__result_err_obj(ctx, error_to_js(ctx, error));
463
+ }
464
+ return mik__result_ok_void(ctx);
465
+ }
466
+
467
+ JSValue update_abort(JSContext* ctx, JSValue, int, JSValue*) {
468
+ MIKOtaClientState* state = state_of(ctx);
469
+ if (state && state->apply.active && state->apply.session.update) {
470
+ state->apply.session.update->abort();
471
+ }
472
+ return JS_UNDEFINED;
473
+ }
474
+
475
+ JSValue make_update_object(JSContext* ctx, size_t resume_offset) {
476
+ JSValue update = JS_NewObject(ctx);
477
+ JS_SetPropertyStr(ctx, update, "resumeOffset",
478
+ JS_NewInt64(ctx, static_cast<int64_t>(resume_offset)));
479
+ JS_SetPropertyStr(ctx, update, "write", JS_NewCFunction(ctx, update_write, "write", 1));
480
+ JS_SetPropertyStr(ctx, update, "finish", JS_NewCFunction(ctx, update_finish, "finish", 1));
481
+ JS_SetPropertyStr(ctx, update, "abort", JS_NewCFunction(ctx, update_abort, "abort", 0));
482
+ return update;
483
+ }
484
+
485
+ /* Close the attempt and settle applyOffer's promise. */
486
+ void settle_apply(JSContext* ctx, bool downloaded, const std::string& message) {
487
+ MIKOtaClientState* state = state_of(ctx);
488
+ if (!state || !state->apply.active) return;
489
+
490
+ MIKOtaApplyOutcome outcome = MIKOtaApplyOutcome::kStaged;
491
+ MIKOtaError error;
492
+ bool ok = mikrojs::mik__ota_policy_apply_end(&state->apply.session, downloaded, message,
493
+ state->apply.options, &outcome, &error);
494
+ JSValue result = ok ? mik__result_ok(ctx, JS_NewString(ctx, mikrojs::mik__ota_outcome_to_str(
495
+ outcome)))
496
+ : mik__result_err_obj(ctx, error_to_js(ctx, error));
497
+ state->apply.active = false;
498
+ MIK_ResolvePromise(ctx, &state->apply.promise, 1, &result);
499
+ }
500
+
501
+ JSValue download_fulfilled(JSContext* ctx, JSValue, int argc, JSValue* argv) {
502
+ /* The callback's contract is Result<void, {message}>: a rejected download is
503
+ * transient, so the message is all that survives into DownloadFailed. */
504
+ bool downloaded = false;
505
+ std::string message = "download failed";
506
+ if (argc > 0 && JS_IsObject(argv[0])) {
507
+ JSValue ok = JS_GetPropertyStr(ctx, argv[0], "ok");
508
+ downloaded = JS_ToBool(ctx, ok);
509
+ JS_FreeValue(ctx, ok);
510
+ if (!downloaded) {
511
+ JSValue error = JS_GetPropertyStr(ctx, argv[0], "error");
512
+ JSValue msg = JS_GetPropertyStr(ctx, error, "message");
513
+ const char* text = JS_ToCString(ctx, msg);
514
+ if (text) {
515
+ message = text;
516
+ JS_FreeCString(ctx, text);
517
+ }
518
+ JS_FreeValue(ctx, msg);
519
+ JS_FreeValue(ctx, error);
520
+ }
521
+ }
522
+ settle_apply(ctx, downloaded, message);
523
+ return JS_UNDEFINED;
524
+ }
525
+
526
+ JSValue download_rejected(JSContext* ctx, JSValue, int argc, JSValue* argv) {
527
+ std::string message = "download threw";
528
+ if (argc > 0) {
529
+ const char* text = JS_ToCString(ctx, argv[0]);
530
+ if (text) {
531
+ message = text;
532
+ JS_FreeCString(ctx, text);
533
+ }
534
+ }
535
+ settle_apply(ctx, false, message);
536
+ return JS_UNDEFINED;
537
+ }
538
+
539
+ /* applyOffer(offer, download, options?) -> Promise<Result<ApplyOutcome, OtaError>> */
540
+ JSValue ota_apply_offer(JSContext* ctx, JSValue, int argc, JSValue* argv) {
541
+ MIKOtaClientState* state = state_of(ctx);
542
+ CHECK_NOT_NULL(state);
543
+ if (argc < 2 || !JS_IsFunction(ctx, argv[1])) {
544
+ return JS_ThrowTypeError(ctx, "applyOffer(offer, download) needs a download function");
545
+ }
546
+ if (state->apply.active) {
547
+ JSValue busy = mik__result_err_named(ctx, "StagingFailed", "an update is already staging");
548
+ return MIK_NewResolvedPromise(ctx, 1, &busy);
549
+ }
550
+
551
+ MIKOtaOffer offer;
552
+ if (!mikrojs::mik__ota_parse_offer_js(ctx, argv[0], /*allow_insecure=*/true, &offer)) {
553
+ JSValue bad = mik__result_err_named(ctx, "StagingFailed", "offer is not usable");
554
+ return MIK_NewResolvedPromise(ctx, 1, &bad);
555
+ }
556
+
557
+ MIKOtaInstallOptions options;
558
+ if (argc > 2 && JS_IsObject(argv[2])) {
559
+ options.trial_boots =
560
+ (int)opt_u32(ctx, argv[2], "trialBoots", (uint32_t)options.trial_boots);
561
+ options.require_confirm = opt_bool(ctx, argv[2], "requireConfirm", options.require_confirm);
562
+ JSValue install = JS_GetPropertyStr(ctx, argv[2], "install");
563
+ const char* mode = JS_IsString(install) ? JS_ToCString(ctx, install) : nullptr;
564
+ options.install_now = mode && strcmp(mode, "now") == 0;
565
+ if (mode) JS_FreeCString(ctx, mode);
566
+ JS_FreeValue(ctx, install);
567
+ }
568
+
569
+ MIKOtaApplyOutcome outcome = MIKOtaApplyOutcome::kStaged;
570
+ MIKOtaError error;
571
+ if (!mikrojs::mik__ota_policy_apply_begin(state->env, offer, &state->apply.session, &outcome,
572
+ &error)) {
573
+ JSValue failed = mik__result_err_obj(ctx, error_to_js(ctx, error));
574
+ return MIK_NewResolvedPromise(ctx, 1, &failed);
575
+ }
576
+ if (!state->apply.session.update) {
577
+ /* Declined before staging: trial pending, current, abandoned, exhausted. */
578
+ JSValue declined =
579
+ mik__result_ok(ctx, JS_NewString(ctx, mikrojs::mik__ota_outcome_to_str(outcome)));
580
+ return MIK_NewResolvedPromise(ctx, 1, &declined);
581
+ }
582
+
583
+ state->apply.options = options;
584
+ state->apply.active = true;
585
+ JSValue promise = MIK_InitPromise(ctx, &state->apply.promise);
586
+ if (JS_IsException(promise)) {
587
+ state->apply.active = false;
588
+ state->apply.session.update.reset();
589
+ return JS_EXCEPTION;
590
+ }
591
+
592
+ JSValue update = make_update_object(ctx, state->apply.session.update->resume_offset());
593
+ JSValue returned = JS_Call(ctx, argv[1], JS_UNDEFINED, 1, &update);
594
+ JS_FreeValue(ctx, update);
595
+
596
+ if (JS_IsException(returned)) {
597
+ JSValue exc = JS_GetException(ctx);
598
+ const char* text = JS_ToCString(ctx, exc);
599
+ std::string message = text ? text : "download threw";
600
+ if (text) JS_FreeCString(ctx, text);
601
+ JS_FreeValue(ctx, exc);
602
+ JS_FreeValue(ctx, returned);
603
+ settle_apply(ctx, false, message);
604
+ return promise;
605
+ }
606
+
607
+ /* The callback may hand back a plain Result rather than a promise — or
608
+ * nothing, which is not a Result but must not throw on the way to being
609
+ * reported as a failed download. */
610
+ JSValue then = JS_IsObject(returned) ? JS_GetPropertyStr(ctx, returned, "then") : JS_UNDEFINED;
611
+ if (!JS_IsFunction(ctx, then)) {
612
+ JS_FreeValue(ctx, then);
613
+ JSValue args[1] = {returned};
614
+ JSValue ignored = download_fulfilled(ctx, JS_UNDEFINED, 1, args);
615
+ JS_FreeValue(ctx, ignored);
616
+ JS_FreeValue(ctx, returned);
617
+ return promise;
618
+ }
619
+ JSValue on_ok = JS_NewCFunction(ctx, download_fulfilled, "onFulfilled", 1);
620
+ JSValue on_err = JS_NewCFunction(ctx, download_rejected, "onRejected", 1);
621
+ JSValue chain_args[2] = {on_ok, on_err};
622
+ JSValue chained = JS_Call(ctx, then, returned, 2, chain_args);
623
+ JS_FreeValue(ctx, chained);
624
+ JS_FreeValue(ctx, on_ok);
625
+ JS_FreeValue(ctx, on_err);
626
+ JS_FreeValue(ctx, then);
627
+ JS_FreeValue(ctx, returned);
628
+ return promise;
629
+ }
630
+
631
+ int mik__ota_client_module_init(JSContext* ctx, JSModuleDef* m) {
632
+ JS_SetModuleExport(ctx, m, "check", JS_NewCFunction(ctx, mik__ota_client_check, "check", 1));
633
+ JS_SetModuleExport(ctx, m, "watch", JS_NewCFunction(ctx, mik__ota_client_watch, "watch", 1));
634
+ JS_SetModuleExport(ctx, m, "config", JS_NewCFunction(ctx, mik__ota_client_config, "config", 0));
635
+ JS_SetModuleExport(ctx, m, "reconcile", JS_NewCFunction(ctx, ota_reconcile, "reconcile", 0));
636
+ JS_SetModuleExport(ctx, m, "running", JS_NewCFunction(ctx, ota_running, "running", 0));
637
+ JS_SetModuleExport(ctx, m, "confirm", JS_NewCFunction(ctx, ota_confirm, "confirm", 0));
638
+ JS_SetModuleExport(ctx, m, "revert", JS_NewCFunction(ctx, ota_revert, "revert", 0));
639
+ JS_SetModuleExport(ctx, m, "bearer", JS_NewCFunction(ctx, ota_bearer, "bearer", 0));
640
+ JS_SetModuleExport(ctx, m, "registry", JS_NewCFunction(ctx, ota_registry, "registry", 0));
641
+ JS_SetModuleExport(ctx, m, "parseOffer",
642
+ JS_NewCFunction(ctx, ota_parse_offer, "parseOffer", 2));
643
+ JS_SetModuleExport(ctx, m, "applyOffer",
644
+ JS_NewCFunction(ctx, ota_apply_offer, "applyOffer", 3));
645
+ return 0;
646
+ }
647
+
648
+ } // namespace
649
+
650
+ JSModuleDef* mik__ota_client_init(JSContext* ctx) {
651
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
652
+ CHECK_NOT_NULL(mik_rt);
653
+ mik__ota_client_slot = MIK_AllocModuleSlot(mik_rt);
654
+
655
+ /* The bytecode version needs a context to derive (JS_WriteObject's first
656
+ * byte), so it is read here and handed to the env, which has none. */
657
+ size_t bc_len = 0;
658
+ uint8_t* bc_buf = JS_WriteObject(ctx, &bc_len, JS_NULL, JS_WRITE_OBJ_BYTECODE);
659
+ int bytecode_version = (bc_buf && bc_len > 0) ? bc_buf[0] : 0;
660
+ if (bc_buf) js_free(ctx, bc_buf);
661
+
662
+ auto* state = new MIKOtaClientState();
663
+ state->ctx = ctx;
664
+ /* The client drives HTTP from C and never imports native:mikro/http, so
665
+ * nothing else would bring the transport up. */
666
+ mik__http_ensure_native(ctx);
667
+
668
+ const MIKOtaEnv* env = mik__ota_env_for(mik_rt, bytecode_version);
669
+ state->env = env;
670
+ state->config = std::make_unique<MIKOtaConfigReader>(env);
671
+ state->client = std::make_unique<MIKOtaClient>(env);
672
+ mik__ota_client_st(mik_rt) = state;
673
+
674
+ JSModuleDef* m = JS_NewCModule(ctx, "native:mikro/ota_client", mik__ota_client_module_init);
675
+ if (!m) {
676
+ /* The loop consumer is only registered when init returns a module. */
677
+ delete state;
678
+ mik__ota_client_st(mik_rt) = nullptr;
679
+ return nullptr;
680
+ }
681
+ JS_AddModuleExport(ctx, m, "check");
682
+ JS_AddModuleExport(ctx, m, "watch");
683
+ JS_AddModuleExport(ctx, m, "config");
684
+ JS_AddModuleExport(ctx, m, "reconcile");
685
+ JS_AddModuleExport(ctx, m, "running");
686
+ JS_AddModuleExport(ctx, m, "confirm");
687
+ JS_AddModuleExport(ctx, m, "revert");
688
+ JS_AddModuleExport(ctx, m, "bearer");
689
+ JS_AddModuleExport(ctx, m, "registry");
690
+ JS_AddModuleExport(ctx, m, "parseOffer");
691
+ JS_AddModuleExport(ctx, m, "applyOffer");
692
+ return m;
693
+ }
694
+
695
+ void mik__ota_client_consume(JSContext* ctx) {
696
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
697
+ CHECK_NOT_NULL(mik_rt);
698
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
699
+ if (!state || !state->client) return;
700
+
701
+ state->client->Poll();
702
+
703
+ /* Drop settled checks. Done after Poll rather than inside the sink so the
704
+ * vector is never mutated while the machine is walking it. */
705
+ for (size_t i = state->pending.size(); i > 0; i--) {
706
+ if (state->pending[i - 1]->settled) state->pending.erase(state->pending.begin() + (i - 1));
707
+ }
708
+ }
709
+
710
+ void mik__ota_client_destroy(JSContext* ctx) {
711
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
712
+ CHECK_NOT_NULL(mik_rt);
713
+ MIKOtaClientState* state = mik__ota_client_st(mik_rt);
714
+ if (!state) return;
715
+
716
+ if (state->client) state->client->StopWatch();
717
+ /* Anything still waiting will never settle: free the promise values so
718
+ * teardown does not leave JSValues behind. */
719
+ for (auto& pending : state->pending) {
720
+ if (!pending->settled) MIK_FreePromise(ctx, &pending->promise);
721
+ }
722
+ state->pending.clear();
723
+ /* The client before the hooks: a round parked in beforeCheck or teardown
724
+ * still holds the hook pointer, and the client's teardown is what lets it
725
+ * go. */
726
+ if (state->apply.active) {
727
+ /* Nothing will settle it now; free the promise's values rather than
728
+ * leaving them behind. */
729
+ MIK_FreePromise(ctx, &state->apply.promise);
730
+ state->apply.active = false;
731
+ }
732
+ state->client.reset();
733
+ state->hooks.reset();
734
+ JS_FreeValue(ctx, state->on_config);
735
+ state->on_config = JS_UNDEFINED;
736
+ /* Before the runtime: the reader holds the last document it served. */
737
+ state->config.reset();
738
+ delete state;
739
+ mik__ota_client_st(mik_rt) = nullptr;
740
+ }
741
+
742
+ MIK_REGISTER_MODULE(ota_client, "native:mikro/ota_client", mik__ota_client_init,
743
+ mik__ota_client_consume, mik__ota_client_destroy)