@zogien/native-sdk 0.11.0-fork.2 → 0.11.0-fork.4

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 (58) hide show
  1. package/README.md +1 -1
  2. package/build/app.zig +47 -23
  3. package/build/cli.zig +22 -0
  4. package/native-sdk.d.ts +2 -0
  5. package/package.json +10 -10
  6. package/packages/core/package-lock.json +2 -2
  7. package/packages/core/package.json +1 -1
  8. package/schemas/app.schema.json +9 -1
  9. package/skill-data/ts-core/SKILL.md +2 -0
  10. package/src/app_runner/app_assets.zig +85 -1
  11. package/src/app_runner/root.zig +63 -3
  12. package/src/app_runner/ts_core_main.zig +8 -0
  13. package/src/platform/macos/appkit_host.h +9 -0
  14. package/src/platform/macos/appkit_host.m +366 -0
  15. package/src/platform/macos/root.zig +29 -6
  16. package/src/platform/null_platform.zig +66 -0
  17. package/src/platform/root.zig +1 -0
  18. package/src/platform/types.zig +46 -0
  19. package/src/platform/windows/root.zig +174 -0
  20. package/src/platform/windows/scriptc_mingw_compat.c +248 -0
  21. package/src/platform/windows/webview2_host.cpp +500 -26
  22. package/src/primitives/app_manifest/root.zig +1 -0
  23. package/src/primitives/app_manifest/tests.zig +35 -0
  24. package/src/primitives/app_manifest/types.zig +12 -0
  25. package/src/primitives/app_manifest/validation.zig +44 -6
  26. package/src/root.zig +1 -0
  27. package/src/runtime/bridge_responses.zig +2 -0
  28. package/src/runtime/channel_sources.zig +49 -1
  29. package/src/runtime/core.zig +5 -0
  30. package/src/runtime/effects.zig +12 -1
  31. package/src/runtime/flow.zig +6 -0
  32. package/src/runtime/flow_reference.zig +6 -0
  33. package/src/runtime/root.zig +4 -0
  34. package/src/runtime/single_instance.zig +893 -0
  35. package/src/runtime/tests.zig +1 -0
  36. package/src/runtime/window_controls.zig +73 -0
  37. package/src/runtime/window_controls_tests.zig +192 -0
  38. package/src/runtime/window_storage.zig +3 -0
  39. package/src/tooling/manifest.zig +111 -3
  40. package/src/tooling/package.zig +98 -8
  41. package/src/tooling/raw_manifest.zig +7 -0
  42. package/src/tooling/root.zig +2 -0
  43. package/src/tooling/templates.zig +53 -3
  44. package/src/tooling/update.zig +228 -10
  45. package/src/tooling/windows_installer.zig +245 -0
  46. package/src/tooling/zip_writer.zig +211 -0
  47. package/src/updater/engine.zig +936 -0
  48. package/src/updater/feed.zig +261 -12
  49. package/src/updater/macos.zig +187 -0
  50. package/src/updater/root.zig +41 -0
  51. package/src/updater/windows.zig +470 -0
  52. package/src/windows_install/config.zig +247 -0
  53. package/src/windows_install/main.zig +690 -0
  54. package/src/windows_install/payload.zig +146 -0
  55. package/src/windows_install/switch.zig +184 -0
  56. package/src/windows_install/tests.zig +11 -0
  57. package/src/windows_install/win32.zig +248 -0
  58. package/tools/native-sdk/main.zig +10 -9
@@ -0,0 +1,936 @@
1
+ //! The app-driven updater: one process-wide engine an app drives through
2
+ //! host requests and observes through a channel source.
3
+ //!
4
+ //! native-sdk.updates.status the current status
5
+ //! native-sdk.updates.check start a check
6
+ //! native-sdk.updates.download download, verify and stage the available release
7
+ //! native-sdk.updates.cancel stop a download
8
+ //! native-sdk.updates.discard drop a staged release
9
+ //! native-sdk.updates.restart quit and start the staged release
10
+ //! native-sdk.updates.notes the known release's notes, as plain text
11
+ //!
12
+ //! Every request takes an empty payload and answers at once: `ok`, or
13
+ //! `err` with a reason (`unsupported`, `busy`, `not_available`,
14
+ //! `not_ready`, `not_downloading`, `invalid_request`, `restart_failed`).
15
+ //! `status` and `notes` answer with their text; the other requests only
16
+ //! say they were accepted. Work runs on a background thread, and every
17
+ //! state change, plus download progress, is posted to the
18
+ //! `native-sdk.updates` channel as a status record, in order: the channel
19
+ //! is the one place the app learns the status from as it changes.
20
+ //!
21
+ //! A status record is UTF-8 lines of `name value`, in this order:
22
+ //!
23
+ //! state <unsupported|idle|checking|up_to_date|available|downloading|ready|error>
24
+ //! installed <version>
25
+ //! version <release version> while a release is known
26
+ //! size <archive bytes> while a release is known, once a check named it
27
+ //! received <bytes> while downloading
28
+ //! percent <0-100> while downloading
29
+ //! reason <code> state unsupported: not_configured, platform,
30
+ //! not_installed, read_only
31
+ //! error <code> state error: network, http_status, feed_invalid,
32
+ //! signature_invalid, wrong_app, wrong_target,
33
+ //! archive_size, archive_digest, archive_invalid, disk
34
+ //! last updated <from> <to> in the launch that applied a staged release
35
+ //!
36
+ //! A record fits one channel post (`max_status_bytes`); release notes,
37
+ //! up to 16 KiB, come from the `notes` request instead.
38
+ //!
39
+ //! The engine never quits the app: the app asks for `restart`, or the
40
+ //! staged release applies without one, at the next launch (Windows: the
41
+ //! launcher switches) or when the app quits (macOS: `finish` starts the
42
+ //! helper). A staged release stays `ready` until then; `check` while
43
+ //! ready does nothing, and `discard` drops the release to look again.
44
+ //! Platform work (fetching, staging, the install helper) belongs to a
45
+ //! `Backend`.
46
+
47
+ const std = @import("std");
48
+ const feed = @import("feed.zig");
49
+
50
+ pub const request_prefix = "native-sdk.updates.";
51
+ pub const channel_source = "native-sdk.updates";
52
+ /// The longest status record: every line at its longest is under 512
53
+ /// bytes.
54
+ pub const max_status_bytes: usize = 1024;
55
+ /// The longest answer to a request: a status record or the notes.
56
+ pub const max_response_bytes: usize = @max(max_status_bytes, feed.max_release_notes_bytes);
57
+ const max_version_bytes: usize = 64;
58
+ const max_sinks: usize = 4;
59
+ const progress_post_min_bytes: u64 = 64 * 1024;
60
+
61
+ pub const State = enum { unsupported, idle, checking, up_to_date, available, downloading, ready, failed };
62
+
63
+ pub const Unsupported = enum { not_configured, platform, not_installed, read_only };
64
+
65
+ pub const Failure = enum {
66
+ network,
67
+ http_status,
68
+ feed_invalid,
69
+ signature_invalid,
70
+ wrong_app,
71
+ wrong_target,
72
+ archive_size,
73
+ archive_digest,
74
+ archive_invalid,
75
+ disk,
76
+ };
77
+
78
+ pub const FetchError = error{ Network, HttpStatus, TooLarge };
79
+ pub const StageError = error{ Network, HttpStatus, Cancelled, ArchiveSize, ArchiveDigest, ArchiveInvalid, Disk };
80
+
81
+ /// The release a check found, owned by the engine.
82
+ pub const Release = struct {
83
+ version_buffer: [max_version_bytes]u8 = undefined,
84
+ version_len: usize = 0,
85
+ url_buffer: [feed.max_archive_url_bytes]u8 = undefined,
86
+ url_len: usize = 0,
87
+ notes_buffer: [feed.max_release_notes_bytes]u8 = undefined,
88
+ notes_len: usize = 0,
89
+ archive_bytes: u64 = 0,
90
+ sha256: [64]u8 = undefined,
91
+
92
+ pub fn version(self: *const Release) []const u8 {
93
+ return self.version_buffer[0..self.version_len];
94
+ }
95
+ pub fn url(self: *const Release) []const u8 {
96
+ return self.url_buffer[0..self.url_len];
97
+ }
98
+ pub fn notes(self: *const Release) []const u8 {
99
+ return self.notes_buffer[0..self.notes_len];
100
+ }
101
+
102
+ fn set(self: *Release, release: feed.Release) void {
103
+ self.version_len = copyInto(&self.version_buffer, release.version);
104
+ self.url_len = copyInto(&self.url_buffer, release.archive_url);
105
+ self.notes_len = copyInto(&self.notes_buffer, release.release_notes);
106
+ self.archive_bytes = release.archive_bytes;
107
+ @memcpy(&self.sha256, release.sha256[0..64]);
108
+ }
109
+ };
110
+
111
+ /// Download progress a backend reports while staging, and the cancel
112
+ /// request it honors between reads.
113
+ pub const Progress = struct {
114
+ cancel_requested: std.atomic.Value(bool) = .init(false),
115
+
116
+ pub fn report(self: *Progress, received: u64) void {
117
+ _ = self;
118
+ reportProgress(received);
119
+ }
120
+
121
+ pub fn cancelled(self: *Progress) bool {
122
+ return self.cancel_requested.load(.acquire);
123
+ }
124
+ };
125
+
126
+ /// The platform half of the updater.
127
+ pub const Backend = struct {
128
+ context: *anyopaque,
129
+ /// Why this installation cannot update itself, or null when it can.
130
+ eligibility: *const fn (context: *anyopaque) ?Unsupported,
131
+ /// The version staged for the next launch, if a complete one is.
132
+ staged_version: *const fn (context: *anyopaque, output: []u8) ?[]const u8,
133
+ /// The previous launch's switch, `<from> <to>`, reported once.
134
+ take_last_update: *const fn (context: *anyopaque, output: []u8) ?[]const u8,
135
+ /// GET `url` into `output`.
136
+ fetch: *const fn (context: *anyopaque, url: []const u8, policy: feed.UrlPolicy, output: []u8) FetchError![]const u8,
137
+ /// Download the release's archive, check its size and SHA-256,
138
+ /// unpack and validate it, and stage it for the next launch.
139
+ stage: *const fn (context: *anyopaque, release: *const Release, policy: feed.UrlPolicy, progress: *Progress) StageError!void,
140
+ discard: *const fn (context: *anyopaque) void,
141
+ /// Starts the helper that installs the staged release once this
142
+ /// process exits, then starts it.
143
+ prepare_restart: *const fn (context: *anyopaque) bool,
144
+ /// Starts the helper that installs the staged release once this
145
+ /// process exits, without starting it; called as the app quits with
146
+ /// a release ready. Null where the next launch installs it instead.
147
+ install_on_exit: ?*const fn (context: *anyopaque) void = null,
148
+ };
149
+
150
+ /// Where status records go: the open `native-sdk.updates` channels.
151
+ pub const Sink = struct {
152
+ context: *anyopaque,
153
+ post: *const fn (context: *anyopaque, bytes: []const u8) Posted,
154
+ };
155
+
156
+ pub const Posted = enum {
157
+ delivered,
158
+ /// The channel holds too many undelivered posts; this one was dropped.
159
+ dropped,
160
+ /// The channel is closed for good.
161
+ closed,
162
+ };
163
+
164
+ pub const Config = struct {
165
+ /// The manifest feed URL, before expansion; empty when updates are
166
+ /// not configured.
167
+ feed_url: []const u8 = "",
168
+ /// Trusted keys, comma-joined.
169
+ public_keys: []const u8 = "",
170
+ bundle_id: []const u8 = "",
171
+ version: []const u8 = "",
172
+ target: ?[]const u8 = null,
173
+ policy: feed.UrlPolicy = .{},
174
+ backend: ?Backend = null,
175
+ /// Start a check right away (the manifest's `check_on_start`); the
176
+ /// app learns its result like any other check's.
177
+ check_on_start: bool = false,
178
+ };
179
+
180
+ pub const Response = struct {
181
+ ok: bool,
182
+ bytes: []const u8,
183
+ /// The app should quit now so the restart helper can install.
184
+ quit: bool = false,
185
+ };
186
+
187
+ const Engine = struct {
188
+ mutex: SpinMutex = .{},
189
+ state: State = .unsupported,
190
+ unsupported: Unsupported = .not_configured,
191
+ failure: Failure = .network,
192
+ feed_url_buffer: [feed.max_feed_url_bytes]u8 = undefined,
193
+ feed_url_len: usize = 0,
194
+ keys_buffer: [256]u8 = undefined,
195
+ keys_len: usize = 0,
196
+ bundle_buffer: [128]u8 = undefined,
197
+ bundle_len: usize = 0,
198
+ installed_buffer: [max_version_bytes]u8 = undefined,
199
+ installed_len: usize = 0,
200
+ target_buffer: [32]u8 = undefined,
201
+ target_len: usize = 0,
202
+ policy: feed.UrlPolicy = .{},
203
+ backend: ?Backend = null,
204
+ release: Release = .{},
205
+ has_release: bool = false,
206
+ received: u64 = 0,
207
+ last_update_buffer: [2 * max_version_bytes + 1]u8 = undefined,
208
+ last_update_len: usize = 0,
209
+ restart_prepared: bool = false,
210
+ sinks: [max_sinks]?Sink = .{null} ** max_sinks,
211
+ worker: ?std.Thread = null,
212
+ progress: Progress = .{},
213
+ last_progress_post_bytes: u64 = 0,
214
+ };
215
+
216
+ var engine: Engine = .{};
217
+
218
+ /// Configures the process's updater. Called once at startup, before the
219
+ /// app can send requests.
220
+ pub fn configure(config: Config) void {
221
+ configureState(config);
222
+ if (config.check_on_start) {
223
+ var output: [max_status_bytes]u8 = undefined;
224
+ _ = handleRequest(request_prefix ++ "check", "", &output);
225
+ }
226
+ }
227
+
228
+ fn configureState(config: Config) void {
229
+ join();
230
+ engine.mutex.lock();
231
+ defer engine.mutex.unlock();
232
+ engine = .{ .mutex = engine.mutex };
233
+ engine.installed_len = copyInto(&engine.installed_buffer, config.version);
234
+ engine.bundle_len = copyInto(&engine.bundle_buffer, config.bundle_id);
235
+ engine.policy = config.policy;
236
+ engine.backend = config.backend;
237
+ if (config.feed_url.len == 0 or config.public_keys.len == 0) {
238
+ engine.unsupported = .not_configured;
239
+ return;
240
+ }
241
+ const backend = config.backend orelse {
242
+ engine.unsupported = .platform;
243
+ return;
244
+ };
245
+ const target = config.target orelse {
246
+ engine.unsupported = .platform;
247
+ return;
248
+ };
249
+ const expanded = feed.expandFeedUrlWithPolicy(&engine.feed_url_buffer, config.feed_url, target, config.version, config.policy) catch {
250
+ engine.unsupported = .not_configured;
251
+ return;
252
+ };
253
+ engine.feed_url_len = expanded.len;
254
+ if (config.public_keys.len > engine.keys_buffer.len) {
255
+ engine.unsupported = .not_configured;
256
+ return;
257
+ }
258
+ engine.keys_len = copyInto(&engine.keys_buffer, config.public_keys);
259
+ engine.target_len = copyInto(&engine.target_buffer, target);
260
+ if (backend.take_last_update(backend.context, &engine.last_update_buffer)) |last| engine.last_update_len = last.len;
261
+ if (backend.eligibility(backend.context)) |reason| {
262
+ engine.unsupported = reason;
263
+ return;
264
+ }
265
+ var staged_buffer: [max_version_bytes]u8 = undefined;
266
+ if (backend.staged_version(backend.context, &staged_buffer)) |staged| {
267
+ engine.release = .{};
268
+ engine.release.version_len = copyInto(&engine.release.version_buffer, staged);
269
+ engine.has_release = true;
270
+ engine.state = .ready;
271
+ } else {
272
+ engine.state = .idle;
273
+ }
274
+ }
275
+
276
+ /// Answers one `native-sdk.updates.*` request. `output` receives the
277
+ /// status or the notes; the other requests answer an empty `ok`, and the
278
+ /// changes they make reach the app through the channel.
279
+ pub fn handleRequest(name: []const u8, payload: []const u8, output: []u8) Response {
280
+ if (!std.mem.startsWith(u8, name, request_prefix)) return .{ .ok = false, .bytes = "invalid_request" };
281
+ const verb = name[request_prefix.len..];
282
+ if (payload.len != 0) return .{ .ok = false, .bytes = "invalid_request" };
283
+ engine.mutex.lock();
284
+ if (std.mem.eql(u8, verb, "status")) {
285
+ defer engine.mutex.unlock();
286
+ return .{ .ok = true, .bytes = renderLocked(output) };
287
+ }
288
+ if (engine.state == .unsupported) {
289
+ engine.mutex.unlock();
290
+ return .{ .ok = false, .bytes = "unsupported" };
291
+ }
292
+ if (std.mem.eql(u8, verb, "check")) {
293
+ if (engine.state == .checking or engine.state == .downloading) {
294
+ engine.mutex.unlock();
295
+ return .{ .ok = false, .bytes = "busy" };
296
+ }
297
+ if (engine.state == .ready) {
298
+ engine.mutex.unlock();
299
+ return .{ .ok = true, .bytes = "" };
300
+ }
301
+ return startWorker(checkMain, .checking);
302
+ }
303
+ if (std.mem.eql(u8, verb, "download")) {
304
+ if (engine.state == .checking or engine.state == .downloading) {
305
+ engine.mutex.unlock();
306
+ return .{ .ok = false, .bytes = "busy" };
307
+ }
308
+ // A failed check forgets its release, so a failure that still has
309
+ // one is a download to retry.
310
+ if (engine.state != .available and !(engine.state == .failed and engine.has_release)) {
311
+ engine.mutex.unlock();
312
+ return .{ .ok = false, .bytes = "not_available" };
313
+ }
314
+ engine.received = 0;
315
+ engine.last_progress_post_bytes = 0;
316
+ engine.progress = .{};
317
+ return startWorker(downloadMain, .downloading);
318
+ }
319
+ if (std.mem.eql(u8, verb, "cancel")) {
320
+ defer engine.mutex.unlock();
321
+ if (engine.state != .downloading) return .{ .ok = false, .bytes = "not_downloading" };
322
+ engine.progress.cancel_requested.store(true, .release);
323
+ return .{ .ok = true, .bytes = "" };
324
+ }
325
+ if (std.mem.eql(u8, verb, "notes")) {
326
+ defer engine.mutex.unlock();
327
+ if (!engine.has_release) return .{ .ok = false, .bytes = "not_available" };
328
+ return .{ .ok = true, .bytes = output[0..copyInto(output, engine.release.notes())] };
329
+ }
330
+ // Requests arrive one at a time on the app's loop and no worker runs
331
+ // while a release is ready, so the backend's file and process work
332
+ // below runs outside the lock.
333
+ if (std.mem.eql(u8, verb, "discard")) {
334
+ if (engine.state != .ready) {
335
+ engine.mutex.unlock();
336
+ return .{ .ok = false, .bytes = "not_ready" };
337
+ }
338
+ const backend = engine.backend.?;
339
+ engine.has_release = false;
340
+ engine.state = .idle;
341
+ engine.mutex.unlock();
342
+ backend.discard(backend.context);
343
+ postStatus();
344
+ return .{ .ok = true, .bytes = "" };
345
+ }
346
+ if (std.mem.eql(u8, verb, "restart")) {
347
+ if (engine.state != .ready) {
348
+ engine.mutex.unlock();
349
+ return .{ .ok = false, .bytes = "not_ready" };
350
+ }
351
+ const backend = engine.backend.?;
352
+ // One helper per process: a repeated restart only asks to quit.
353
+ const prepared = engine.restart_prepared;
354
+ engine.mutex.unlock();
355
+ if (!prepared) {
356
+ if (!backend.prepare_restart(backend.context)) return .{ .ok = false, .bytes = "restart_failed" };
357
+ engine.mutex.lock();
358
+ engine.restart_prepared = true;
359
+ engine.mutex.unlock();
360
+ }
361
+ return .{ .ok = true, .bytes = "", .quit = true };
362
+ }
363
+ engine.mutex.unlock();
364
+ return .{ .ok = false, .bytes = "invalid_request" };
365
+ }
366
+
367
+ /// Adds an open `native-sdk.updates` channel and posts the current status
368
+ /// to it.
369
+ pub fn attach(sink: Sink) error{TooManyChannels}!void {
370
+ post_order.lock();
371
+ defer post_order.unlock();
372
+ var status_buffer: [max_status_bytes]u8 = undefined;
373
+ engine.mutex.lock();
374
+ const slot = for (&engine.sinks) |*slot| {
375
+ if (slot.* == null) break slot;
376
+ } else {
377
+ engine.mutex.unlock();
378
+ return error.TooManyChannels;
379
+ };
380
+ slot.* = sink;
381
+ const status = renderLocked(&status_buffer);
382
+ engine.mutex.unlock();
383
+ if (sink.post(sink.context, status) == .closed) detach(sink);
384
+ }
385
+
386
+ /// Called as the app quits normally. A ready release that no `restart`
387
+ /// will install goes in now, where the backend installs on exit.
388
+ pub fn finish() void {
389
+ engine.mutex.lock();
390
+ const backend = engine.backend;
391
+ const install = engine.state == .ready and !engine.restart_prepared;
392
+ engine.mutex.unlock();
393
+ if (!install) return;
394
+ const chosen = backend orelse return;
395
+ const install_on_exit = chosen.install_on_exit orelse return;
396
+ install_on_exit(chosen.context);
397
+ }
398
+
399
+ /// Waits for background work to finish (shutdown and tests).
400
+ pub fn join() void {
401
+ engine.mutex.lock();
402
+ const worker = engine.worker;
403
+ engine.worker = null;
404
+ engine.mutex.unlock();
405
+ if (worker) |thread| thread.join();
406
+ }
407
+
408
+ /// Called with the mutex held; releases it.
409
+ fn startWorker(comptime main: fn () void, state: State) Response {
410
+ const previous = engine.worker;
411
+ engine.worker = null;
412
+ const restore = engine.state;
413
+ engine.state = state;
414
+ engine.mutex.unlock();
415
+ if (previous) |thread| thread.join();
416
+ postStatus();
417
+ const thread = std.Thread.spawn(.{}, main, .{}) catch {
418
+ engine.mutex.lock();
419
+ engine.state = restore;
420
+ engine.mutex.unlock();
421
+ postStatus();
422
+ return .{ .ok = false, .bytes = "busy" };
423
+ };
424
+ engine.mutex.lock();
425
+ engine.worker = thread;
426
+ engine.mutex.unlock();
427
+ return .{ .ok = true, .bytes = "" };
428
+ }
429
+
430
+ fn checkMain() void {
431
+ var url_buffer: [feed.max_feed_url_bytes]u8 = undefined;
432
+ var keys_buffer: [256]u8 = undefined;
433
+ var bundle_buffer: [128]u8 = undefined;
434
+ var installed_buffer: [max_version_bytes]u8 = undefined;
435
+ var target_buffer: [32]u8 = undefined;
436
+ engine.mutex.lock();
437
+ const backend = engine.backend.?;
438
+ const policy = engine.policy;
439
+ const url = url_buffer[0..copyInto(&url_buffer, engine.feed_url_buffer[0..engine.feed_url_len])];
440
+ const keys = keys_buffer[0..copyInto(&keys_buffer, engine.keys_buffer[0..engine.keys_len])];
441
+ const bundle_id = bundle_buffer[0..copyInto(&bundle_buffer, engine.bundle_buffer[0..engine.bundle_len])];
442
+ const installed = installed_buffer[0..copyInto(&installed_buffer, engine.installed_buffer[0..engine.installed_len])];
443
+ const target = target_buffer[0..copyInto(&target_buffer, engine.target_buffer[0..engine.target_len])];
444
+ engine.mutex.unlock();
445
+
446
+ var feed_buffer: [feed.max_envelope_bytes]u8 = undefined;
447
+ const outcome: union(enum) { failed: Failure, up_to_date, available: feed.Release } = outcome: {
448
+ const envelope = backend.fetch(backend.context, url, policy, &feed_buffer) catch |err| break :outcome .{ .failed = switch (err) {
449
+ error.HttpStatus => .http_status,
450
+ error.TooLarge => .feed_invalid,
451
+ error.Network => .network,
452
+ } };
453
+ var verified = feed.verifyEnvelopeWithPolicy(std.heap.page_allocator, envelope, keys, policy) catch |err| break :outcome .{ .failed = switch (err) {
454
+ error.InvalidUpdateSignature, error.InvalidUpdateKey => .signature_invalid,
455
+ else => .feed_invalid,
456
+ } };
457
+ defer verified.deinit(std.heap.page_allocator);
458
+ const applies = feed.releaseApplies(verified.release, bundle_id, installed, target) catch |err| break :outcome .{ .failed = switch (err) {
459
+ error.UpdateBundleMismatch => .wrong_app,
460
+ error.UpdateTargetMismatch => .wrong_target,
461
+ else => .feed_invalid,
462
+ } };
463
+ if (!applies) break :outcome .up_to_date;
464
+ engine.mutex.lock();
465
+ engine.release.set(verified.release);
466
+ engine.mutex.unlock();
467
+ break :outcome .{ .available = verified.release };
468
+ };
469
+
470
+ engine.mutex.lock();
471
+ switch (outcome) {
472
+ .failed => |failure| {
473
+ engine.state = .failed;
474
+ engine.failure = failure;
475
+ engine.has_release = false;
476
+ },
477
+ .up_to_date => {
478
+ engine.state = .up_to_date;
479
+ engine.has_release = false;
480
+ },
481
+ .available => {
482
+ engine.state = .available;
483
+ engine.has_release = true;
484
+ },
485
+ }
486
+ engine.mutex.unlock();
487
+ postFinalStatus();
488
+ }
489
+
490
+ fn downloadMain() void {
491
+ engine.mutex.lock();
492
+ const backend = engine.backend.?;
493
+ const policy = engine.policy;
494
+ const release = engine.release;
495
+ engine.mutex.unlock();
496
+
497
+ const result = backend.stage(backend.context, &release, policy, &engine.progress);
498
+ engine.mutex.lock();
499
+ if (result) |_| {
500
+ engine.state = .ready;
501
+ } else |err| switch (err) {
502
+ error.Cancelled => engine.state = .available,
503
+ else => {
504
+ engine.state = .failed;
505
+ engine.failure = switch (err) {
506
+ error.Network => .network,
507
+ error.HttpStatus => .http_status,
508
+ error.ArchiveSize => .archive_size,
509
+ error.ArchiveDigest => .archive_digest,
510
+ error.ArchiveInvalid => .archive_invalid,
511
+ error.Disk => .disk,
512
+ error.Cancelled => unreachable,
513
+ };
514
+ },
515
+ }
516
+ engine.mutex.unlock();
517
+ postFinalStatus();
518
+ }
519
+
520
+ /// Posts progress about every 1% of the archive (at least 64 KiB apart)
521
+ /// and once at the end.
522
+ fn reportProgress(received: u64) void {
523
+ engine.mutex.lock();
524
+ engine.received = received;
525
+ const step = @max(engine.release.archive_bytes / 100, progress_post_min_bytes);
526
+ const due = received -| engine.last_progress_post_bytes >= step or received == engine.release.archive_bytes;
527
+ if (due) engine.last_progress_post_bytes = received;
528
+ engine.mutex.unlock();
529
+ if (due) postStatus();
530
+ }
531
+
532
+ /// Serializes rendering and posting, so channels receive records in the
533
+ /// order their states happened and the last record is the current state.
534
+ var post_order: SpinMutex = .{};
535
+
536
+ fn postStatus() void {
537
+ _ = postStatusTo(@splat(true));
538
+ }
539
+
540
+ /// Posts the current status to the sinks `targets` selects and answers
541
+ /// which of them dropped it.
542
+ fn postStatusTo(targets: [max_sinks]bool) [max_sinks]bool {
543
+ post_order.lock();
544
+ defer post_order.unlock();
545
+ var status_buffer: [max_status_bytes]u8 = undefined;
546
+ var sinks: [max_sinks]?Sink = undefined;
547
+ engine.mutex.lock();
548
+ const status = renderLocked(&status_buffer);
549
+ sinks = engine.sinks;
550
+ engine.mutex.unlock();
551
+ var dropped: [max_sinks]bool = @splat(false);
552
+ for (sinks, targets, &dropped) |maybe_sink, target, *drop| {
553
+ if (!target) continue;
554
+ const sink = maybe_sink orelse continue;
555
+ switch (sink.post(sink.context, status)) {
556
+ .delivered => {},
557
+ .dropped => drop.* = true,
558
+ .closed => detach(sink),
559
+ }
560
+ }
561
+ return dropped;
562
+ }
563
+
564
+ /// Posts the state a worker ends in. Nothing follows that record, so a
565
+ /// channel too full to take it gets it again, for about a second.
566
+ fn postFinalStatus() void {
567
+ var dropped = postStatusTo(@splat(true));
568
+ if (std.mem.indexOfScalar(bool, &dropped, true) == null) return;
569
+ var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{});
570
+ defer threaded.deinit();
571
+ const io = threaded.io();
572
+ var attempts: usize = 0;
573
+ while (attempts < 50 and std.mem.indexOfScalar(bool, &dropped, true) != null) : (attempts += 1) {
574
+ std.Io.sleep(io, std.Io.Duration.fromMilliseconds(20), .awake) catch return;
575
+ dropped = postStatusTo(dropped);
576
+ }
577
+ }
578
+
579
+ fn detach(sink: Sink) void {
580
+ engine.mutex.lock();
581
+ defer engine.mutex.unlock();
582
+ for (&engine.sinks) |*slot| {
583
+ if (slot.*) |existing| {
584
+ if (existing.context == sink.context and existing.post == sink.post) slot.* = null;
585
+ }
586
+ }
587
+ }
588
+
589
+ /// Renders the status record. Called with the mutex held.
590
+ fn renderLocked(output: []u8) []const u8 {
591
+ var writer = std.Io.Writer.fixed(output);
592
+ renderTo(&writer) catch return output[0..writer.end];
593
+ return writer.buffered();
594
+ }
595
+
596
+ fn renderTo(writer: *std.Io.Writer) !void {
597
+ try writer.print("state {s}\n", .{stateName(engine.state)});
598
+ try writer.print("installed {s}\n", .{engine.installed_buffer[0..engine.installed_len]});
599
+ if (engine.has_release) {
600
+ try writer.print("version {s}\n", .{engine.release.version()});
601
+ if (engine.release.archive_bytes > 0) try writer.print("size {d}\n", .{engine.release.archive_bytes});
602
+ }
603
+ if (engine.state == .downloading) {
604
+ try writer.print("received {d}\n", .{engine.received});
605
+ const total = engine.release.archive_bytes;
606
+ try writer.print("percent {d}\n", .{if (total == 0) 0 else @min(100, engine.received * 100 / total)});
607
+ }
608
+ if (engine.state == .unsupported) try writer.print("reason {s}\n", .{@tagName(engine.unsupported)});
609
+ if (engine.state == .failed) try writer.print("error {s}\n", .{@tagName(engine.failure)});
610
+ if (engine.last_update_len > 0) try writer.print("last updated {s}\n", .{engine.last_update_buffer[0..engine.last_update_len]});
611
+ }
612
+
613
+ fn stateName(state: State) []const u8 {
614
+ return switch (state) {
615
+ .failed => "error",
616
+ else => @tagName(state),
617
+ };
618
+ }
619
+
620
+ fn copyInto(buffer: []u8, value: []const u8) usize {
621
+ const length = @min(buffer.len, value.len);
622
+ @memcpy(buffer[0..length], value[0..length]);
623
+ return length;
624
+ }
625
+
626
+ const SpinMutex = struct {
627
+ inner: std.atomic.Mutex = .unlocked,
628
+
629
+ fn lock(self: *SpinMutex) void {
630
+ while (!self.inner.tryLock()) std.atomic.spinLoopHint();
631
+ }
632
+
633
+ fn unlock(self: *SpinMutex) void {
634
+ self.inner.unlock();
635
+ }
636
+ };
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // Tests
640
+
641
+ const Ed25519 = std.crypto.sign.Ed25519;
642
+
643
+ const FakeBackend = struct {
644
+ eligibility_reason: ?Unsupported = null,
645
+ staged: ?[]const u8 = null,
646
+ last_update: ?[]const u8 = null,
647
+ envelope: []const u8 = "",
648
+ fetch_error: ?FetchError = null,
649
+ stage_error: ?StageError = null,
650
+ stage_calls: usize = 0,
651
+ discarded: bool = false,
652
+ restart_calls: usize = 0,
653
+ exit_installs: usize = 0,
654
+ wait_for_cancel: bool = false,
655
+
656
+ fn backend(self: *FakeBackend) Backend {
657
+ return .{
658
+ .context = self,
659
+ .eligibility = eligibility,
660
+ .staged_version = stagedVersion,
661
+ .take_last_update = takeLastUpdate,
662
+ .fetch = fetch,
663
+ .stage = stage,
664
+ .discard = discard,
665
+ .prepare_restart = prepareRestart,
666
+ .install_on_exit = installOnExit,
667
+ };
668
+ }
669
+ fn get(context: *anyopaque) *FakeBackend {
670
+ return @ptrCast(@alignCast(context));
671
+ }
672
+ fn eligibility(context: *anyopaque) ?Unsupported {
673
+ return get(context).eligibility_reason;
674
+ }
675
+ fn stagedVersion(context: *anyopaque, output: []u8) ?[]const u8 {
676
+ const staged = get(context).staged orelse return null;
677
+ return output[0..copyInto(output, staged)];
678
+ }
679
+ fn takeLastUpdate(context: *anyopaque, output: []u8) ?[]const u8 {
680
+ const last = get(context).last_update orelse return null;
681
+ return output[0..copyInto(output, last)];
682
+ }
683
+ fn fetch(context: *anyopaque, url: []const u8, policy: feed.UrlPolicy, output: []u8) FetchError![]const u8 {
684
+ _ = url;
685
+ _ = policy;
686
+ const self = get(context);
687
+ if (self.fetch_error) |err| return err;
688
+ return output[0..copyInto(output, self.envelope)];
689
+ }
690
+ fn stage(context: *anyopaque, release: *const Release, policy: feed.UrlPolicy, progress: *Progress) StageError!void {
691
+ _ = policy;
692
+ const self = get(context);
693
+ self.stage_calls += 1;
694
+ progress.report(release.archive_bytes / 2);
695
+ if (self.wait_for_cancel) {
696
+ while (!progress.cancelled()) std.atomic.spinLoopHint();
697
+ return error.Cancelled;
698
+ }
699
+ if (self.stage_error) |err| return err;
700
+ progress.report(release.archive_bytes);
701
+ }
702
+ fn discard(context: *anyopaque) void {
703
+ get(context).discarded = true;
704
+ }
705
+ fn installOnExit(context: *anyopaque) void {
706
+ get(context).exit_installs += 1;
707
+ }
708
+ fn prepareRestart(context: *anyopaque) bool {
709
+ get(context).restart_calls += 1;
710
+ return true;
711
+ }
712
+ };
713
+
714
+ const RecordingSink = struct {
715
+ last: [max_status_bytes]u8 = undefined,
716
+ last_len: usize = 0,
717
+ /// Each record's state, in posting order.
718
+ log: [512]u8 = undefined,
719
+ log_len: usize = 0,
720
+ /// Posts to refuse as a full channel would.
721
+ drop_next: usize = 0,
722
+ mutex: SpinMutex = .{},
723
+
724
+ fn sink(self: *RecordingSink) Sink {
725
+ return .{ .context = self, .post = post };
726
+ }
727
+ fn post(context: *anyopaque, bytes: []const u8) Posted {
728
+ const self: *RecordingSink = @ptrCast(@alignCast(context));
729
+ self.mutex.lock();
730
+ defer self.mutex.unlock();
731
+ if (self.drop_next > 0) {
732
+ self.drop_next -= 1;
733
+ return .dropped;
734
+ }
735
+ self.last_len = copyInto(&self.last, bytes);
736
+ const state = bytes["state ".len .. std.mem.indexOfScalar(u8, bytes, '\n') orelse bytes.len];
737
+ self.log_len += copyInto(self.log[self.log_len..], state);
738
+ self.log_len += copyInto(self.log[self.log_len..], " ");
739
+ return .delivered;
740
+ }
741
+ fn status(self: *RecordingSink) []const u8 {
742
+ return self.last[0..self.last_len];
743
+ }
744
+ fn states(self: *RecordingSink) []const u8 {
745
+ return self.log[0..self.log_len];
746
+ }
747
+ };
748
+
749
+ fn signedFeed(allocator: std.mem.Allocator, key_pair: Ed25519.KeyPair, payload: []const u8) ![]u8 {
750
+ const signature = try key_pair.sign(payload, null);
751
+ const payload_base64 = try allocator.alloc(u8, std.base64.standard.Encoder.calcSize(payload.len));
752
+ defer allocator.free(payload_base64);
753
+ var signature_base64: [std.base64.standard.Encoder.calcSize(Ed25519.Signature.encoded_length)]u8 = undefined;
754
+ return std.fmt.allocPrint(allocator, "{{\"payload\":\"{s}\",\"signature\":\"{s}\"}}", .{
755
+ std.base64.standard.Encoder.encode(payload_base64, payload),
756
+ std.base64.standard.Encoder.encode(&signature_base64, &signature.toBytes()),
757
+ });
758
+ }
759
+
760
+ const test_release =
761
+ \\{"bundle_id":"dev.example.notes","version":"1.1.0","target":"windows-x86_64","archive_url":"https://example.com/notes-1.1.0.zip","archive_bytes":1000,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","release_notes":"Faster sync.\nNew icons."}
762
+ ;
763
+
764
+ fn testConfig(fake: *FakeBackend, public_key: []const u8) Config {
765
+ return .{
766
+ .feed_url = "https://example.com/{target}/native-update.json",
767
+ .public_keys = public_key,
768
+ .bundle_id = "dev.example.notes",
769
+ .version = "1.0.0",
770
+ .target = "windows-x86_64",
771
+ .backend = fake.backend(),
772
+ };
773
+ }
774
+
775
+ test "a check finds a release, a download stages it and a restart asks the app to quit" {
776
+ const allocator = std.testing.allocator;
777
+ const key_pair = try Ed25519.KeyPair.generateDeterministic([_]u8{0x61} ** Ed25519.KeyPair.seed_length);
778
+ var key_buffer: [44]u8 = undefined;
779
+ const public_key = std.base64.standard.Encoder.encode(&key_buffer, &key_pair.public_key.toBytes());
780
+ const envelope = try signedFeed(allocator, key_pair, test_release);
781
+ defer allocator.free(envelope);
782
+ var fake: FakeBackend = .{ .envelope = envelope };
783
+ configure(testConfig(&fake, public_key));
784
+ var sink: RecordingSink = .{};
785
+ try attach(sink.sink());
786
+ try std.testing.expect(std.mem.startsWith(u8, sink.status(), "state idle\ninstalled 1.0.0\n"));
787
+
788
+ var output: [max_status_bytes]u8 = undefined;
789
+ try std.testing.expectEqualStrings("not_available", handleRequest("native-sdk.updates.download", "", &output).bytes);
790
+ // Requests that start work only say they were accepted; the channel
791
+ // carries what happens.
792
+ const check = handleRequest("native-sdk.updates.check", "", &output);
793
+ try std.testing.expect(check.ok and check.bytes.len == 0);
794
+ join();
795
+ try std.testing.expectEqualStrings("state available\ninstalled 1.0.0\nversion 1.1.0\nsize 1000\n", sink.status());
796
+ try std.testing.expectEqualStrings("Faster sync.\nNew icons.", handleRequest("native-sdk.updates.notes", "", &output).bytes);
797
+
798
+ try std.testing.expect(handleRequest("native-sdk.updates.download", "", &output).ok);
799
+ join();
800
+ try std.testing.expectEqual(@as(usize, 1), fake.stage_calls);
801
+ try std.testing.expectEqualStrings("state ready\ninstalled 1.0.0\nversion 1.1.0\nsize 1000\n", sink.status());
802
+ try std.testing.expectEqualStrings("idle checking available downloading downloading ready ", sink.states());
803
+
804
+ const restart = handleRequest("native-sdk.updates.restart", "", &output);
805
+ try std.testing.expect(restart.ok and restart.quit);
806
+ const again = handleRequest("native-sdk.updates.restart", "", &output);
807
+ try std.testing.expect(again.ok and again.quit);
808
+ try std.testing.expectEqual(@as(usize, 1), fake.restart_calls);
809
+ }
810
+
811
+ test "the state a check ends in reaches a channel that was too full to take it" {
812
+ const allocator = std.testing.allocator;
813
+ const key_pair = try Ed25519.KeyPair.generateDeterministic([_]u8{0x65} ** Ed25519.KeyPair.seed_length);
814
+ var key_buffer: [44]u8 = undefined;
815
+ const public_key = std.base64.standard.Encoder.encode(&key_buffer, &key_pair.public_key.toBytes());
816
+ const envelope = try signedFeed(allocator, key_pair, test_release);
817
+ defer allocator.free(envelope);
818
+ var fake: FakeBackend = .{ .envelope = envelope };
819
+ configure(testConfig(&fake, public_key));
820
+ var sink: RecordingSink = .{};
821
+ try attach(sink.sink());
822
+ // `checking` and the first post of `available` are refused.
823
+ sink.drop_next = 2;
824
+ var output: [max_status_bytes]u8 = undefined;
825
+ try std.testing.expect(handleRequest("native-sdk.updates.check", "", &output).ok);
826
+ join();
827
+ try std.testing.expectEqualStrings("idle available ", sink.states());
828
+ }
829
+
830
+ test "an installed version reports no update and failures name their cause" {
831
+ const allocator = std.testing.allocator;
832
+ const key_pair = try Ed25519.KeyPair.generateDeterministic([_]u8{0x62} ** Ed25519.KeyPair.seed_length);
833
+ const other = try Ed25519.KeyPair.generateDeterministic([_]u8{0x63} ** Ed25519.KeyPair.seed_length);
834
+ var key_buffer: [44]u8 = undefined;
835
+ const public_key = std.base64.standard.Encoder.encode(&key_buffer, &key_pair.public_key.toBytes());
836
+ var output: [max_status_bytes]u8 = undefined;
837
+
838
+ const forged = try signedFeed(allocator, other, test_release);
839
+ defer allocator.free(forged);
840
+ var fake: FakeBackend = .{ .envelope = forged };
841
+ configure(testConfig(&fake, public_key));
842
+ _ = handleRequest("native-sdk.updates.check", "", &output);
843
+ join();
844
+ try std.testing.expect(std.mem.indexOf(u8, handleRequest("native-sdk.updates.status", "", &output).bytes, "state error\n") != null);
845
+ try std.testing.expect(std.mem.indexOf(u8, handleRequest("native-sdk.updates.status", "", &output).bytes, "error signature_invalid\n") != null);
846
+
847
+ const same = try signedFeed(allocator, key_pair, test_release);
848
+ defer allocator.free(same);
849
+ fake = .{ .envelope = same };
850
+ var config = testConfig(&fake, public_key);
851
+ config.version = "1.1.0";
852
+ configure(config);
853
+ _ = handleRequest("native-sdk.updates.check", "", &output);
854
+ join();
855
+ try std.testing.expect(std.mem.startsWith(u8, handleRequest("native-sdk.updates.status", "", &output).bytes, "state up_to_date\n"));
856
+
857
+ fake = .{ .fetch_error = error.HttpStatus };
858
+ configure(testConfig(&fake, public_key));
859
+ _ = handleRequest("native-sdk.updates.check", "", &output);
860
+ join();
861
+ try std.testing.expect(std.mem.indexOf(u8, handleRequest("native-sdk.updates.status", "", &output).bytes, "error http_status\n") != null);
862
+ }
863
+
864
+ test "an install that cannot update itself says why and refuses to act" {
865
+ var fake: FakeBackend = .{ .eligibility_reason = .not_installed };
866
+ configure(testConfig(&fake, "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="));
867
+ var output: [max_status_bytes]u8 = undefined;
868
+ try std.testing.expectEqualStrings("state unsupported\ninstalled 1.0.0\nreason not_installed\n", handleRequest("native-sdk.updates.status", "", &output).bytes);
869
+ try std.testing.expectEqualStrings("unsupported", handleRequest("native-sdk.updates.check", "", &output).bytes);
870
+
871
+ configure(.{ .version = "1.0.0" });
872
+ try std.testing.expectEqualStrings("state unsupported\ninstalled 1.0.0\nreason not_configured\n", handleRequest("native-sdk.updates.status", "", &output).bytes);
873
+ }
874
+
875
+ test "a staged release and the last update survive a relaunch" {
876
+ var fake: FakeBackend = .{ .staged = "1.1.0", .last_update = "0.9.0 1.0.0" };
877
+ configure(testConfig(&fake, "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="));
878
+ var output: [max_status_bytes]u8 = undefined;
879
+ try std.testing.expectEqualStrings("state ready\ninstalled 1.0.0\nversion 1.1.0\nlast updated 0.9.0 1.0.0\n", handleRequest("native-sdk.updates.status", "", &output).bytes);
880
+ // A release staged by an earlier launch has no notes to show.
881
+ const notes = handleRequest("native-sdk.updates.notes", "", &output);
882
+ try std.testing.expect(notes.ok and notes.bytes.len == 0);
883
+ try std.testing.expect(handleRequest("native-sdk.updates.discard", "", &output).ok);
884
+ try std.testing.expect(fake.discarded);
885
+ try std.testing.expect(std.mem.startsWith(u8, handleRequest("native-sdk.updates.status", "", &output).bytes, "state idle\n"));
886
+ try std.testing.expectEqualStrings("not_available", handleRequest("native-sdk.updates.notes", "", &output).bytes);
887
+ }
888
+
889
+ test "a cancelled download returns to the available release" {
890
+ const allocator = std.testing.allocator;
891
+ const key_pair = try Ed25519.KeyPair.generateDeterministic([_]u8{0x64} ** Ed25519.KeyPair.seed_length);
892
+ var key_buffer: [44]u8 = undefined;
893
+ const public_key = std.base64.standard.Encoder.encode(&key_buffer, &key_pair.public_key.toBytes());
894
+ const envelope = try signedFeed(allocator, key_pair, test_release);
895
+ defer allocator.free(envelope);
896
+ var fake: FakeBackend = .{ .envelope = envelope, .wait_for_cancel = true };
897
+ var config = testConfig(&fake, public_key);
898
+ config.check_on_start = true;
899
+ configure(config);
900
+ join();
901
+ var output: [max_status_bytes]u8 = undefined;
902
+ try std.testing.expect(std.mem.startsWith(u8, handleRequest("native-sdk.updates.status", "", &output).bytes, "state available\n"));
903
+ try std.testing.expect(handleRequest("native-sdk.updates.download", "", &output).ok);
904
+ try std.testing.expectEqualStrings("busy", handleRequest("native-sdk.updates.check", "", &output).bytes);
905
+ var downloading: []const u8 = "";
906
+ var spins: usize = 0;
907
+ while (std.mem.indexOf(u8, downloading, "received 500\n") == null and spins < 100_000_000) : (spins += 1) {
908
+ downloading = handleRequest("native-sdk.updates.status", "", &output).bytes;
909
+ }
910
+ try std.testing.expectEqualStrings("state downloading\ninstalled 1.0.0\nversion 1.1.0\nsize 1000\nreceived 500\npercent 50\n", downloading);
911
+ try std.testing.expect(handleRequest("native-sdk.updates.cancel", "", &output).ok);
912
+ join();
913
+ try std.testing.expect(std.mem.startsWith(u8, handleRequest("native-sdk.updates.status", "", &output).bytes, "state available\n"));
914
+ try std.testing.expectEqualStrings("not_downloading", handleRequest("native-sdk.updates.cancel", "", &output).bytes);
915
+ try std.testing.expectEqualStrings("invalid_request", handleRequest("native-sdk.updates.check", "x", &output).bytes);
916
+ }
917
+
918
+ test "a ready release installs as the app quits unless a restart will" {
919
+ var output: [max_status_bytes]u8 = undefined;
920
+ var fake: FakeBackend = .{ .staged = "1.1.0" };
921
+ configure(testConfig(&fake, "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="));
922
+ finish();
923
+ try std.testing.expectEqual(@as(usize, 1), fake.exit_installs);
924
+
925
+ fake = .{ .staged = "1.1.0" };
926
+ configure(testConfig(&fake, "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="));
927
+ try std.testing.expect(handleRequest("native-sdk.updates.restart", "", &output).quit);
928
+ finish();
929
+ try std.testing.expectEqual(@as(usize, 0), fake.exit_installs);
930
+
931
+ // Nothing ready, nothing to install.
932
+ fake = .{};
933
+ configure(testConfig(&fake, "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="));
934
+ finish();
935
+ try std.testing.expectEqual(@as(usize, 0), fake.exit_installs);
936
+ }