@zogien/native-sdk 0.11.0-fork.3 → 0.11.0-fork.5

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 (67) hide show
  1. package/README.md +1 -1
  2. package/build/app.zig +53 -34
  3. package/build/cli.zig +22 -0
  4. package/package.json +10 -10
  5. package/packages/core/package-lock.json +2 -2
  6. package/packages/core/package.json +1 -1
  7. package/schemas/app.schema.json +9 -1
  8. package/skill-data/core/references/bridge-security-native-capabilities.md +4 -0
  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/linux/cef_host.cpp +22 -0
  14. package/src/platform/linux/gtk_host.c +317 -34
  15. package/src/platform/linux/gtk_host.h +37 -0
  16. package/src/platform/linux/root.zig +114 -20
  17. package/src/platform/macos/appkit_host.h +53 -0
  18. package/src/platform/macos/appkit_host.m +667 -67
  19. package/src/platform/macos/cef_host.mm +21 -0
  20. package/src/platform/macos/root.zig +146 -29
  21. package/src/platform/null_platform.zig +136 -0
  22. package/src/platform/root.zig +7 -0
  23. package/src/platform/types.zig +90 -0
  24. package/src/platform/windows/dialog_test.cpp +303 -0
  25. package/src/platform/windows/host_loop_test.cpp +169 -0
  26. package/src/platform/windows/root.zig +262 -39
  27. package/src/platform/windows/session_end_test.cpp +303 -0
  28. package/src/platform/windows/webview2_host.cpp +1435 -306
  29. package/src/primitives/app_manifest/root.zig +1 -0
  30. package/src/primitives/app_manifest/tests.zig +35 -0
  31. package/src/primitives/app_manifest/types.zig +12 -0
  32. package/src/primitives/app_manifest/validation.zig +44 -6
  33. package/src/root.zig +8 -0
  34. package/src/runtime/builtin_bridge.zig +108 -13
  35. package/src/runtime/channel_sources.zig +49 -1
  36. package/src/runtime/core.zig +24 -3
  37. package/src/runtime/dialog_tests.zig +367 -0
  38. package/src/runtime/dialogs.zig +385 -0
  39. package/src/runtime/effects.zig +83 -3
  40. package/src/runtime/effects_host_tests.zig +69 -1
  41. package/src/runtime/flow.zig +20 -6
  42. package/src/runtime/flow_reference.zig +20 -6
  43. package/src/runtime/platform_bridge_tests.zig +1 -0
  44. package/src/runtime/root.zig +9 -0
  45. package/src/runtime/single_instance.zig +893 -0
  46. package/src/runtime/system_services.zig +64 -82
  47. package/src/runtime/ui_app.zig +67 -0
  48. package/src/tooling/manifest.zig +111 -3
  49. package/src/tooling/package.zig +98 -8
  50. package/src/tooling/raw_manifest.zig +7 -0
  51. package/src/tooling/root.zig +2 -0
  52. package/src/tooling/templates.zig +44 -2
  53. package/src/tooling/update.zig +228 -10
  54. package/src/tooling/windows_installer.zig +245 -0
  55. package/src/tooling/zip_writer.zig +211 -0
  56. package/src/updater/engine.zig +936 -0
  57. package/src/updater/feed.zig +261 -12
  58. package/src/updater/macos.zig +187 -0
  59. package/src/updater/root.zig +41 -0
  60. package/src/updater/windows.zig +470 -0
  61. package/src/windows_install/config.zig +247 -0
  62. package/src/windows_install/main.zig +690 -0
  63. package/src/windows_install/payload.zig +146 -0
  64. package/src/windows_install/switch.zig +184 -0
  65. package/src/windows_install/tests.zig +11 -0
  66. package/src/windows_install/win32.zig +248 -0
  67. package/tools/native-sdk/main.zig +10 -9
@@ -404,95 +404,77 @@ pub fn RuntimeSystemServices(comptime Runtime: type) type {
404
404
  return writeTrueJson(output);
405
405
  }
406
406
 
407
- pub fn openFileDialogFromJson(self: *Runtime, payload: []const u8, output: []u8) ![]const u8 {
408
- var storage = json.StringStorage.init(output);
409
- const title = jsonStringField(payload, "title", &storage) orelse "";
410
- const default_path = jsonStringField(payload, "defaultPath", &storage) orelse "";
411
- const allow_dirs = jsonBoolField(payload, "allowDirectories") orelse false;
412
- const allow_multi = jsonBoolField(payload, "allowMultiple") orelse false;
413
- var dialog_buffer: [platform.max_dialog_paths_bytes]u8 = undefined;
414
- const result = try self.showOpenDialog(.{
415
- .title = title,
416
- .default_path = default_path,
417
- .allow_directories = allow_dirs,
418
- .allow_multiple = allow_multi,
419
- }, &dialog_buffer);
420
-
421
- var writer = std.Io.Writer.fixed(output);
422
- if (result.count == 0) {
423
- try writer.writeAll("null");
424
- } else {
425
- try writer.writeByte('[');
426
- var start: usize = 0;
427
- var i: usize = 0;
428
- for (result.paths, 0..) |ch, pos| {
429
- if (ch == '\n') {
430
- if (i > 0) try writer.writeByte(',');
431
- try json.writeString(&writer, result.paths[start..pos]);
432
- start = pos + 1;
433
- i += 1;
434
- }
435
- }
436
- if (start < result.paths.len) {
437
- if (i > 0) try writer.writeByte(',');
438
- try json.writeString(&writer, result.paths[start..]);
439
- }
440
- try writer.writeByte(']');
407
+ /// Read a `native-sdk.dialog.*` bridge payload. Strings land in
408
+ /// `storage` and borrow it.
409
+ pub fn dialogOptionsFromJson(kind: platform.DialogKind, payload: []const u8, storage: *json.StringStorage) !platform.DialogOptions {
410
+ switch (kind) {
411
+ .open => return .{ .open = .{
412
+ .title = jsonStringField(payload, "title", storage) orelse "",
413
+ .default_path = jsonStringField(payload, "defaultPath", storage) orelse "",
414
+ .allow_directories = jsonBoolField(payload, "allowDirectories") orelse false,
415
+ .allow_multiple = jsonBoolField(payload, "allowMultiple") orelse false,
416
+ } },
417
+ .save => return .{ .save = .{
418
+ .title = jsonStringField(payload, "title", storage) orelse "",
419
+ .default_path = jsonStringField(payload, "defaultPath", storage) orelse "",
420
+ .default_name = jsonStringField(payload, "defaultName", storage) orelse "",
421
+ } },
422
+ .message => {
423
+ const title = jsonStringField(payload, "title", storage) orelse "";
424
+ const message = jsonStringField(payload, "message", storage) orelse "";
425
+ const informative = jsonStringField(payload, "informativeText", storage) orelse "";
426
+ const primary = jsonStringField(payload, "primaryButton", storage) orelse "OK";
427
+ const secondary = jsonStringField(payload, "secondaryButton", storage) orelse "";
428
+ const tertiary = jsonStringField(payload, "tertiaryButton", storage) orelse "";
429
+ const style_str = jsonStringField(payload, "style", storage) orelse "info";
430
+ const style: platform.MessageDialogStyle = if (std.mem.eql(u8, style_str, "warning"))
431
+ .warning
432
+ else if (std.mem.eql(u8, style_str, "critical"))
433
+ .critical
434
+ else
435
+ .info;
436
+ return .{ .message = .{
437
+ .style = style,
438
+ .title = title,
439
+ .message = message,
440
+ .informative_text = informative,
441
+ .primary_button = primary,
442
+ .secondary_button = secondary,
443
+ .tertiary_button = tertiary,
444
+ } };
445
+ },
441
446
  }
442
- return writer.buffered();
443
447
  }
444
448
 
445
- pub fn saveFileDialogFromJson(self: *Runtime, payload: []const u8, output: []u8) ![]const u8 {
446
- var storage = json.StringStorage.init(output);
447
- const title = jsonStringField(payload, "title", &storage) orelse "";
448
- const default_path = jsonStringField(payload, "defaultPath", &storage) orelse "";
449
- const default_name = jsonStringField(payload, "defaultName", &storage) orelse "";
450
- var dialog_buffer: [platform.max_dialog_path_bytes]u8 = undefined;
451
- const path = try self.showSaveDialog(.{
452
- .title = title,
453
- .default_path = default_path,
454
- .default_name = default_name,
455
- }, &dialog_buffer);
456
-
449
+ /// The bridge result for a finished dialog: open dialogs answer an
450
+ /// array of paths, save dialogs a path, message dialogs the
451
+ /// pressed button's name. A dialog closed without a choice answers
452
+ /// `null` (open, save) or `"dismissed"` (message).
453
+ pub fn writeDialogResultJson(kind: platform.DialogKind, result: platform.DialogResult, output: []u8) ![]const u8 {
457
454
  var writer = std.Io.Writer.fixed(output);
458
- if (path) |p| {
459
- try json.writeString(&writer, p);
460
- } else {
461
- try writer.writeAll("null");
455
+ const chosen = result.outcome == .selected;
456
+ switch (kind) {
457
+ .open => {
458
+ if (!chosen or result.count == 0) {
459
+ try writer.writeAll("null");
460
+ return writer.buffered();
461
+ }
462
+ try writer.writeByte('[');
463
+ var paths = std.mem.splitScalar(u8, result.paths, '\n');
464
+ var first = true;
465
+ while (paths.next()) |path| {
466
+ if (path.len == 0) continue;
467
+ if (!first) try writer.writeByte(',');
468
+ first = false;
469
+ try json.writeString(&writer, path);
470
+ }
471
+ try writer.writeByte(']');
472
+ },
473
+ .save => if (chosen and result.paths.len > 0) try json.writeString(&writer, result.paths) else try writer.writeAll("null"),
474
+ .message => try json.writeString(&writer, if (chosen) @tagName(result.button) else "dismissed"),
462
475
  }
463
476
  return writer.buffered();
464
477
  }
465
-
466
- pub fn showMessageDialogFromJson(self: *Runtime, payload: []const u8, output: []u8) ![]const u8 {
467
- var storage = json.StringStorage.init(output);
468
- const title = jsonStringField(payload, "title", &storage) orelse "";
469
- const message = jsonStringField(payload, "message", &storage) orelse "";
470
- const informative = jsonStringField(payload, "informativeText", &storage) orelse "";
471
- const primary = jsonStringField(payload, "primaryButton", &storage) orelse "OK";
472
- const secondary = jsonStringField(payload, "secondaryButton", &storage) orelse "";
473
- const tertiary = jsonStringField(payload, "tertiaryButton", &storage) orelse "";
474
- const style_str = jsonStringField(payload, "style", &storage) orelse "info";
475
- const style: platform.MessageDialogStyle = if (std.mem.eql(u8, style_str, "warning"))
476
- .warning
477
- else if (std.mem.eql(u8, style_str, "critical"))
478
- .critical
479
- else
480
- .info;
481
-
482
- const result = try self.showMessageDialog(.{
483
- .style = style,
484
- .title = title,
485
- .message = message,
486
- .informative_text = informative,
487
- .primary_button = primary,
488
- .secondary_button = secondary,
489
- .tertiary_button = tertiary,
490
- });
491
-
492
- var writer = std.Io.Writer.fixed(output);
493
- try json.writeString(&writer, @tagName(result));
494
- return writer.buffered();
495
- }
496
478
  };
497
479
  }
498
480
 
@@ -47,6 +47,7 @@ const component_policy = @import("component_policy.zig");
47
47
  const hover_policy = @import("canvas_hover_policy.zig");
48
48
  const launch_timing = @import("launch_timing.zig");
49
49
  const runtime_effects = @import("effects.zig");
50
+ const runtime_dialogs = @import("dialogs.zig");
50
51
  const terminal_session = @import("terminal_session.zig");
51
52
  const ui_app_provenance = @import("ui_app_provenance.zig");
52
53
  const markup_development = @import("markup_development_policy.zig");
@@ -1684,6 +1685,8 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe
1684
1685
  .reveal_path_fn = effectsRevealPath,
1685
1686
  .format_local_time_fn = effectsFormatLocalTime,
1686
1687
  .execute_capability_fn = effectsExecuteCapability,
1688
+ .start_capability_fn = effectsStartCapability,
1689
+ .cancel_capability_fn = effectsCancelCapability,
1687
1690
  .replay_window_result_fn = effectsRestoreWindowResult,
1688
1691
  });
1689
1692
  if (runtime.options.session_recorder) |recorder| {
@@ -7025,6 +7028,70 @@ fn effectsRestoreWindowResult(context: *anyopaque, label: []const u8, closed: bo
7025
7028
  runtime.restoreWindowCapabilityResult(label, closed);
7026
7029
  }
7027
7030
 
7031
+ /// What a directory picker needs to answer its capability request, kept in
7032
+ /// the dialog's completion tag until the picker finishes.
7033
+ const DirectoryPickerRequest = struct {
7034
+ completion: runtime_effects.DesktopCapabilityCompletion,
7035
+ /// The request's correlation prefix, echoed in the reply header.
7036
+ prefix: [36]u8,
7037
+ prefix_len: u8,
7038
+ };
7039
+
7040
+ comptime {
7041
+ std.debug.assert(@sizeOf(DirectoryPickerRequest) <= runtime_dialogs.max_dialog_tag_bytes);
7042
+ }
7043
+
7044
+ /// `native-sdk.dialog.openDirectory` opens a native folder picker without
7045
+ /// blocking the event loop; the reply follows when the picker finishes.
7046
+ /// Other capabilities, and the picker on a platform without asynchronous
7047
+ /// dialogs, keep the synchronous `effectsExecuteCapability` and its
7048
+ /// replies.
7049
+ fn effectsStartCapability(context: *anyopaque, name: []const u8, payload: []const u8, completion: runtime_effects.DesktopCapabilityCompletion) anyerror!?u64 {
7050
+ if (!std.mem.eql(u8, name, "native-sdk.dialog.openDirectory")) return null;
7051
+ const runtime: *Runtime = @ptrCast(@alignCast(context));
7052
+ if (!runtime.options.platform.services.supportsAsyncDialogs()) return null;
7053
+ const desktop_files = @import("desktop_files.zig");
7054
+ if (!authorization.hasPermission(runtime.authorization_policy, runtime.options.security.permissions, security.permission_dialog)) return error.PermissionDenied;
7055
+ var reader = try desktop_files.request(payload);
7056
+ const prefix_len = reader.at;
7057
+ const title = try reader.field();
7058
+ const path = try reader.field();
7059
+ try reader.finish();
7060
+ if (title.len > 255 or path.len > desktop_files.max_path_bytes or std.mem.indexOfScalar(u8, path, 0) != null) return error.InvalidRequest;
7061
+ var request: DirectoryPickerRequest = .{ .completion = completion, .prefix = undefined, .prefix_len = @intCast(prefix_len) };
7062
+ @memcpy(request.prefix[0..prefix_len], payload[0..prefix_len]);
7063
+ return try runtime.requestOpenDialog(.{ .title = title, .default_path = path, .allow_directories = true, .allow_multiple = false }, null, .{
7064
+ .context = completion.context,
7065
+ .tag = std.mem.asBytes(&request),
7066
+ .on_result = directoryPickerFinished,
7067
+ });
7068
+ }
7069
+
7070
+ fn effectsCancelCapability(context: *anyopaque, handle: u64) void {
7071
+ const runtime: *Runtime = @ptrCast(@alignCast(context));
7072
+ runtime.cancelDialog(handle);
7073
+ }
7074
+
7075
+ fn directoryPickerFinished(context: *anyopaque, runtime: *Runtime, tag: []const u8, result: platform.DialogResult) anyerror!void {
7076
+ _ = .{ context, runtime };
7077
+ const desktop_files = @import("desktop_files.zig");
7078
+ var request: DirectoryPickerRequest = undefined;
7079
+ @memcpy(std.mem.asBytes(&request), tag[0..@sizeOf(DirectoryPickerRequest)]);
7080
+ const prefix = request.prefix[0..request.prefix_len];
7081
+ var output: [64 + 2 + platform.max_dialog_path_bytes]u8 = undefined;
7082
+ if (result.outcome == .failed) {
7083
+ request.completion.complete(true, output[0..try desktop_files.replyHeaderFor(&output, prefix, "DialogFailed")]);
7084
+ return;
7085
+ }
7086
+ // A cancelled picker answers an empty selection, as it always has.
7087
+ const selected = if (result.outcome == .selected and result.count > 0) result.paths else "";
7088
+ const header = try desktop_files.replyHeaderFor(&output, prefix, "");
7089
+ if (selected.len > 65535 or output.len - header < 2 or selected.len > output.len - header - 2) return error.OverBound;
7090
+ std.mem.writeInt(u16, output[header..][0..2], @intCast(selected.len), .little);
7091
+ @memcpy(output[header + 2 ..][0..selected.len], selected);
7092
+ request.completion.complete(true, output[0 .. header + 2 + selected.len]);
7093
+ }
7094
+
7028
7095
  fn effectsExecuteCapability(context: *anyopaque, name: []const u8, payload: []const u8, output: []u8) anyerror![]const u8 {
7029
7096
  const runtime: *Runtime = @ptrCast(@alignCast(context));
7030
7097
  const desktop_files = @import("desktop_files.zig");
@@ -212,6 +212,9 @@ pub const Metadata = struct {
212
212
  if (self.url_schemes.len > 0) allocator.free(self.url_schemes);
213
213
  if (self.updates.feed_url) |value| allocator.free(value);
214
214
  if (self.updates.public_key) |value| allocator.free(value);
215
+ for (self.updates.public_keys) |value| allocator.free(value);
216
+ if (self.updates.public_keys.len > 0) allocator.free(self.updates.public_keys);
217
+ if (self.updates.mode) |value| allocator.free(value);
215
218
  if (self.dmg.volume_name) |value| allocator.free(value);
216
219
  if (self.dmg.background) |value| allocator.free(value);
217
220
  for (self.dmg.items) |item| {
@@ -425,11 +428,50 @@ pub const DmgMetadata = struct {
425
428
  pub const UpdateMetadata = struct {
426
429
  feed_url: ?[]const u8 = null,
427
430
  public_key: ?[]const u8 = null,
431
+ public_keys: []const []const u8 = &.{},
428
432
  check_on_start: bool = false,
433
+ /// `host` or `app`; null leaves the host default.
434
+ mode: ?[]const u8 = null,
429
435
 
430
436
  pub fn enabled(self: UpdateMetadata) bool {
431
437
  return self.feed_url != null;
432
438
  }
439
+
440
+ /// Whether `public_key` is set and it and every `public_keys` entry
441
+ /// decode as base64 Ed25519 public keys.
442
+ pub fn publicKeysAreValid(self: UpdateMetadata) bool {
443
+ const primary = self.public_key orelse return false;
444
+ if (decodeUpdatePublicKey(primary) == null) return false;
445
+ for (self.public_keys) |key| {
446
+ if (decodeUpdatePublicKey(key) == null) return false;
447
+ }
448
+ return true;
449
+ }
450
+
451
+ /// Whether `public_key` or one of `public_keys` decodes to the same key
452
+ /// as this base64 public key.
453
+ pub fn trustsPublicKey(self: UpdateMetadata, public_key: []const u8) bool {
454
+ const wanted = decodeUpdatePublicKey(public_key) orelse return false;
455
+ if (self.public_key) |primary| {
456
+ if (decodeUpdatePublicKey(primary)) |bytes| {
457
+ if (std.mem.eql(u8, &bytes, &wanted)) return true;
458
+ }
459
+ }
460
+ for (self.public_keys) |key| {
461
+ if (decodeUpdatePublicKey(key)) |bytes| {
462
+ if (std.mem.eql(u8, &bytes, &wanted)) return true;
463
+ }
464
+ }
465
+ return false;
466
+ }
467
+
468
+ fn decodeUpdatePublicKey(value: []const u8) ?[32]u8 {
469
+ var bytes: [32]u8 = undefined;
470
+ const size = std.base64.standard.Decoder.calcSizeForSlice(value) catch return null;
471
+ if (size != bytes.len) return null;
472
+ std.base64.standard.Decoder.decode(&bytes, value) catch return null;
473
+ return bytes;
474
+ }
433
475
  };
434
476
 
435
477
  pub const FrontendDevMetadata = struct {
@@ -580,7 +622,9 @@ pub fn validateFile(allocator: std.mem.Allocator, io: std.Io, path: []const u8)
580
622
  defer allocator.free(file_associations);
581
623
  const url_schemes = parseUrlSchemes(allocator, metadata.url_schemes) catch return .{ .ok = false, .message = "app manifest URL schemes are invalid" };
582
624
  defer allocator.free(url_schemes);
583
- const updates = convertUpdates(metadata.updates) catch return .{ .ok = false, .message = "app manifest updates are invalid - updates require an HTTPS feed_url and a base64 Ed25519 public_key; check_on_start requires both" };
625
+ const updates_message = "app manifest updates are invalid - updates require an HTTPS feed_url and a base64 Ed25519 public_key; public_keys adds at most 3 further distinct keys; feed_url may name {target} and {current_version} in its path or query; check_on_start requires both; mode is \"host\" or \"app\"";
626
+ const updates = convertUpdates(metadata.updates) catch return .{ .ok = false, .message = updates_message };
627
+ app_manifest.validateUpdates(updates) catch return .{ .ok = false, .message = updates_message };
584
628
  // General manifest validation owns only values the app explicitly
585
629
  // declared. Archive-time fallbacks (notably display_name as the volume
586
630
  // name) are validated when a macOS archive is actually requested, so an
@@ -751,17 +795,20 @@ fn duplicateRawUpdates(allocator: std.mem.Allocator, raw: RawUpdates) !UpdateMet
751
795
  return .{
752
796
  .feed_url = try duplicateOptionalString(allocator, raw.feed_url),
753
797
  .public_key = try duplicateOptionalString(allocator, raw.public_key),
798
+ .public_keys = try duplicateStringList(allocator, raw.public_keys),
754
799
  .check_on_start = raw.check_on_start,
800
+ .mode = try duplicateOptionalString(allocator, raw.mode),
755
801
  };
756
802
  }
757
803
 
758
804
  fn convertUpdates(updates: UpdateMetadata) !app_manifest.UpdateConfig {
759
- if (updates.feed_url == null and updates.public_key == null and !updates.check_on_start) return .{};
805
+ if (updates.feed_url == null and updates.public_key == null and updates.public_keys.len == 0 and !updates.check_on_start and updates.mode == null) return .{};
760
806
  const feed_url = updates.feed_url orelse return error.InvalidUpdates;
761
807
  const public_key = updates.public_key orelse return error.InvalidUpdates;
762
808
  if (!std.mem.startsWith(u8, feed_url, "https://")) return error.InvalidUpdates;
763
809
  if (public_key.len == 0) return error.InvalidUpdates;
764
- return .{ .feed_url = feed_url, .public_key = public_key, .check_on_start = updates.check_on_start };
810
+ const mode: app_manifest.UpdateMode = if (updates.mode) |value| std.meta.stringToEnum(app_manifest.UpdateMode, value) orelse return error.InvalidUpdates else .host;
811
+ return .{ .feed_url = feed_url, .public_key = public_key, .public_keys = updates.public_keys, .check_on_start = updates.check_on_start, .mode = mode };
765
812
  }
766
813
 
767
814
  fn duplicateRawServicePackages(allocator: std.mem.Allocator, packages: []const raw_manifest.RawServicePackage) ![]const ServicePackageMetadata {
@@ -2555,6 +2602,67 @@ test "JSON manifest parser carries native update configuration" {
2555
2602
  try std.testing.expectEqualStrings("https://example.com/native-update.json", metadata.updates.feed_url.?);
2556
2603
  }
2557
2604
 
2605
+ test "JSON manifest parser carries per-target feeds and rotated update keys" {
2606
+ const metadata = try parseJsonText(std.testing.allocator,
2607
+ \\{
2608
+ \\ "id": "com.example.updates",
2609
+ \\ "name": "updates",
2610
+ \\ "version": "1.2.3",
2611
+ \\ "updates": {
2612
+ \\ "feed_url": "https://example.com/releases/{target}/native-update.json",
2613
+ \\ "public_key": "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo=",
2614
+ \\ "public_keys": ["11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="],
2615
+ \\ "mode": "app"
2616
+ \\ }
2617
+ \\}
2618
+ );
2619
+ defer metadata.deinit(std.testing.allocator);
2620
+ try std.testing.expectEqualStrings("https://example.com/releases/{target}/native-update.json", metadata.updates.feed_url.?);
2621
+ try std.testing.expectEqualStrings("app", metadata.updates.mode.?);
2622
+ try std.testing.expectEqual(app_manifest.UpdateMode.app, (try convertUpdates(metadata.updates)).mode);
2623
+ try std.testing.expect(metadata.updates.trustsPublicKey("wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo="));
2624
+ try std.testing.expect(metadata.updates.trustsPublicKey("11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="));
2625
+ try std.testing.expect(!metadata.updates.trustsPublicKey("IBLLkMpg6OXY2vZuInLSIz4EhtVX6MZhQe2JIBd9frc="));
2626
+ }
2627
+
2628
+ test "manifest validation names the update rules" {
2629
+ var cwd = std.Io.Dir.cwd();
2630
+ const root = ".zig-cache/test-validate-updates";
2631
+ try cwd.deleteTree(std.testing.io, root);
2632
+ defer cwd.deleteTree(std.testing.io, root) catch {};
2633
+ try cwd.createDirPath(std.testing.io, root);
2634
+ try cwd.writeFile(std.testing.io, .{ .sub_path = root ++ "/app.json", .data =
2635
+ \\{
2636
+ \\ "id": "com.example.updates",
2637
+ \\ "name": "updates",
2638
+ \\ "version": "1.2.3",
2639
+ \\ "updates": {
2640
+ \\ "feed_url": "https://example.com/{arch}/native-update.json",
2641
+ \\ "public_key": "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo="
2642
+ \\ }
2643
+ \\}
2644
+ });
2645
+ const result = try validateFile(std.testing.allocator, std.testing.io, root ++ "/app.json");
2646
+ try std.testing.expect(!result.ok);
2647
+ try std.testing.expect(std.mem.indexOf(u8, result.message, "{target} and {current_version}") != null);
2648
+
2649
+ try cwd.writeFile(std.testing.io, .{ .sub_path = root ++ "/app.json", .data =
2650
+ \\{
2651
+ \\ "id": "com.example.updates",
2652
+ \\ "name": "updates",
2653
+ \\ "version": "1.2.3",
2654
+ \\ "updates": {
2655
+ \\ "feed_url": "https://example.com/{target}/native-update.json",
2656
+ \\ "public_key": "wFDFY3pE+oYp//PMzOIwDLNipj2Z2V/FQUUmb0MyRFo=",
2657
+ \\ "mode": "automatic"
2658
+ \\ }
2659
+ \\}
2660
+ });
2661
+ const mode_result = try validateFile(std.testing.allocator, std.testing.io, root ++ "/app.json");
2662
+ try std.testing.expect(!mode_result.ok);
2663
+ try std.testing.expect(std.mem.indexOf(u8, mode_result.message, "mode is \"host\" or \"app\"") != null);
2664
+ }
2665
+
2558
2666
  test "JSON file validation rejects null with a teaching diagnostic regardless of extension case" {
2559
2667
  var cwd = std.Io.Dir.cwd();
2560
2668
  const root = ".zig-cache/test-validate-json-null";
@@ -9,6 +9,7 @@ const diagnostics = @import("diagnostics");
9
9
  const ios_tool = @import("ios.zig");
10
10
  const manifest_tool = @import("manifest.zig");
11
11
  const web_engine_tool = @import("web_engine.zig");
12
+ const windows_installer = @import("windows_installer.zig");
12
13
  const xcodeproj_tool = @import("xcodeproj.zig");
13
14
 
14
15
  /// The SDK's default app icon (kept in sync by `zig build generate-icon`):
@@ -86,9 +87,14 @@ pub const PackageOptions = struct {
86
87
  /// then staple and validate its ticket. Requires identity signing and a
87
88
  /// notarytool Keychain profile in `signing.profile`.
88
89
  notarize: bool = false,
89
- /// Emit the ZIP consumed by the native updater. macOS only; unlike the
90
- /// user-facing DMG, this archive contains exactly the packaged .app.
90
+ /// Emit the ZIP consumed by the native updater. On macOS it contains
91
+ /// exactly the packaged .app (unlike the user-facing DMG); on Windows
92
+ /// it is the install image a setup program carries
93
+ /// (windows_installer.zig).
91
94
  update_archive: bool = false,
95
+ /// Emit a per-user setup program beside a Windows artifact
96
+ /// (windows_installer.zig). Windows only.
97
+ installer: bool = false,
92
98
  /// The process environment, when the caller has one (the CLI). The
93
99
  /// Android artifact probes it for the SDK/NDK toolchain to assemble
94
100
  /// the debug APK; without it the generated project is still complete
@@ -161,6 +167,7 @@ pub const PackageStats = struct {
161
167
  web_layer: ?manifest_tool.WebLayer = null,
162
168
  archive_path: ?[]const u8 = null,
163
169
  update_archive_path: ?[]const u8 = null,
170
+ installer_path: ?[]const u8 = null,
164
171
  /// The Windows subsystem verdict: `console` when the package
165
172
  /// wrapped a CONSOLE-subsystem exe (the app will flash a terminal
166
173
  /// window behind itself on every launch), `gui` for a GUI-subsystem
@@ -278,13 +285,51 @@ pub fn createPackage(allocator: std.mem.Allocator, io: std.Io, options: PackageO
278
285
  stats.notarized = true;
279
286
  }
280
287
  if (options.update_archive) {
281
- if (options.target != .macos) return error.UnsupportedUpdateTarget;
282
288
  if (!options.metadata.updates.enabled()) return error.UpdatesNotConfigured;
283
- stats.update_archive_path = try createUpdateArchive(allocator, io, options);
289
+ stats.update_archive_path = switch (options.target) {
290
+ .macos => try createUpdateArchive(allocator, io, options),
291
+ .windows => try createWindowsImageFile(allocator, io, options, .update_archive),
292
+ else => return error.UnsupportedUpdateTarget,
293
+ };
294
+ }
295
+ if (options.installer) {
296
+ if (options.target != .windows) return error.UnsupportedInstallerTarget;
297
+ stats.installer_path = try createWindowsImageFile(allocator, io, options, .setup);
284
298
  }
285
299
  return stats;
286
300
  }
287
301
 
302
+ /// The per-user setup program or the update archive, beside the artifact
303
+ /// directory, for the architecture of the packaged executable.
304
+ fn createWindowsImageFile(allocator: std.mem.Allocator, io: std.Io, options: PackageOptions, kind: enum { setup, update_archive }) ![]const u8 {
305
+ const binary_path = options.binary_path orelse {
306
+ std.debug.print("error: --installer and --update-archive need the app executable; pass --binary\n", .{});
307
+ return error.MissingBinary;
308
+ };
309
+ const arch: windows_installer.Arch = if (try peExecutableIsArm64(io, binary_path)) .aarch64 else .x86_64;
310
+ const executable = try std.fmt.allocPrint(allocator, "{s}.exe", .{options.metadata.name});
311
+ defer allocator.free(executable);
312
+ const file_name = switch (kind) {
313
+ .setup => try windows_installer.setupFileName(allocator, options.metadata, arch),
314
+ .update_archive => try windows_installer.updateArchiveFileName(allocator, options.metadata, arch),
315
+ };
316
+ defer allocator.free(file_name);
317
+ const path = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(options.output_path) orelse ".", file_name });
318
+ errdefer allocator.free(path);
319
+ const image_options: windows_installer.Options = .{
320
+ .artifact_dir = options.output_path,
321
+ .metadata = options.metadata,
322
+ .executable = executable,
323
+ .arch = arch,
324
+ .output_path = path,
325
+ };
326
+ switch (kind) {
327
+ .setup => try windows_installer.createSetup(allocator, io, image_options),
328
+ .update_archive => try windows_installer.createUpdateArchive(allocator, io, image_options),
329
+ }
330
+ return path;
331
+ }
332
+
288
333
  fn metadataHasCapability(metadata: manifest_tool.Metadata, name: []const u8) bool {
289
334
  for (metadata.capabilities) |capability| {
290
335
  if (std.mem.eql(u8, capability, name)) return true;
@@ -332,6 +377,9 @@ pub fn printDiagnostic(stats: PackageStats) void {
332
377
  if (stats.update_archive_path) |archive| {
333
378
  std.debug.print(" update archive: {s}\n", .{archive});
334
379
  }
380
+ if (stats.installer_path) |installer| {
381
+ std.debug.print(" installer: {s}\n", .{installer});
382
+ }
335
383
  }
336
384
 
337
385
  fn createUpdateArchive(allocator: std.mem.Allocator, io: std.Io, options: PackageOptions) ![]const u8 {
@@ -500,7 +548,7 @@ fn createDesktopArtifact(allocator: std.mem.Allocator, io: std.Io, options: Pack
500
548
  try makeExecutable(dir, io, service_subpath);
501
549
  }
502
550
 
503
- const assets_output = try assetOutputPath(allocator, options.output_path, "resources", options);
551
+ const assets_output = try desktopAssetOutputPath(allocator, options);
504
552
  defer allocator.free(assets_output);
505
553
  const bundle_stats = try assets_tool.bundle(allocator, io, options.assets_dir, assets_output);
506
554
  try writeFile(dir, io, "README.txt", artifactReadme(options.target));
@@ -787,6 +835,20 @@ fn macosAssetOutputPath(allocator: std.mem.Allocator, options: PackageOptions) !
787
835
  return std.fs.path.join(allocator, &.{ options.output_path, "Contents/Resources" });
788
836
  }
789
837
 
838
+ /// Where a Windows or Linux artifact carries the app's assets: the macOS
839
+ /// bundle's layout (macosAssetOutputPath) under resources/. The packaged
840
+ /// runner and Windows host resolve relative asset paths against the
841
+ /// resources/ directory beside the executable's bin/, so a relative path
842
+ /// the app reads ("assets/fonts/mono.ttf") names the same file it names
843
+ /// in a dev run.
844
+ fn desktopAssetOutputPath(allocator: std.mem.Allocator, options: PackageOptions) ![]const u8 {
845
+ if (options.frontend != null) return assetOutputPath(allocator, options.output_path, "resources", options);
846
+ if (appRelativeAssetSubpath(options.assets_dir)) |subpath| {
847
+ return std.fs.path.join(allocator, &.{ options.output_path, "resources", subpath });
848
+ }
849
+ return std.fs.path.join(allocator, &.{ options.output_path, "resources" });
850
+ }
851
+
790
852
  /// The asset directory as an app-relative bundle subpath, or null when
791
853
  /// it cannot honestly be one (empty, ".", absolute, or escaping the app
792
854
  /// root through a ".." segment).
@@ -1465,7 +1527,7 @@ fn elfStringAt(strtab: []const u8, offset: u64) ?[]const u8 {
1465
1527
  /// Whether a PE executable targets arm64, read from the COFF machine
1466
1528
  /// field. Anything unrecognized falls back to x64, the default Windows
1467
1529
  /// build target.
1468
- fn peExecutableIsArm64(io: std.Io, path: []const u8) !bool {
1530
+ pub fn peExecutableIsArm64(io: std.Io, path: []const u8) !bool {
1469
1531
  var file = try std.Io.Dir.cwd().openFile(io, path, .{});
1470
1532
  defer file.close(io);
1471
1533
  var read_buffer: [4096]u8 = undefined;
@@ -1798,7 +1860,7 @@ fn appendFmt(allocator: std.mem.Allocator, out: *std.ArrayList(u8), comptime for
1798
1860
  try out.appendSlice(allocator, text);
1799
1861
  }
1800
1862
 
1801
- fn trimExtensionDot(extension: []const u8) []const u8 {
1863
+ pub fn trimExtensionDot(extension: []const u8) []const u8 {
1802
1864
  if (extension.len > 0 and extension[0] == '.') return extension[1..];
1803
1865
  return extension;
1804
1866
  }
@@ -2109,7 +2171,7 @@ fn windowsRegistrationScript(allocator: std.mem.Allocator, metadata: manifest_to
2109
2171
  return out.toOwnedSlice(allocator);
2110
2172
  }
2111
2173
 
2112
- fn windowsProgId(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata, association: manifest_tool.FileAssociationMetadata) ![]const u8 {
2174
+ pub fn windowsProgId(allocator: std.mem.Allocator, metadata: manifest_tool.Metadata, association: manifest_tool.FileAssociationMetadata) ![]const u8 {
2113
2175
  const app = try windowsIdentifierComponentAlloc(allocator, metadata.id);
2114
2176
  defer allocator.free(app);
2115
2177
  const name = try windowsIdentifierComponentAlloc(allocator, association.name);
@@ -3432,6 +3494,34 @@ test "macOS package mirrors app assets at their app-relative path" {
3432
3494
  try std.testing.expectEqualStrings("mp3-bytes", bundled);
3433
3495
  }
3434
3496
 
3497
+ test "windows and linux packages mirror app assets at their app-relative path" {
3498
+ // The packaged runner resolves "assets/music/track.mp3" against the
3499
+ // resources/ directory beside bin/, so the artifact keeps the asset
3500
+ // tree at the relative path a dev run reads.
3501
+ var cwd = std.Io.Dir.cwd();
3502
+ const root = ".zig-cache/test-package-desktop-asset-layout";
3503
+ try cwd.deleteTree(std.testing.io, root);
3504
+ defer cwd.deleteTree(std.testing.io, root) catch {};
3505
+ try cwd.createDirPath(std.testing.io, root ++ "/assets/music");
3506
+ try cwd.writeFile(std.testing.io, .{ .sub_path = root ++ "/assets/music/track.mp3", .data = "mp3-bytes" });
3507
+
3508
+ const metadata: manifest_tool.Metadata = .{ .id = "dev.example.app", .name = "demo", .version = "1.2.3" };
3509
+ inline for (.{ PackageTarget.windows, PackageTarget.linux }) |target| {
3510
+ const output = root ++ "/demo-" ++ @tagName(target);
3511
+ const stats = try createPackage(std.testing.allocator, std.testing.io, .{
3512
+ .metadata = metadata,
3513
+ .target = target,
3514
+ .output_path = output,
3515
+ .assets_dir = root ++ "/assets",
3516
+ });
3517
+ try std.testing.expectEqual(@as(usize, 1), stats.asset_count);
3518
+ const packaged = try readPath(std.testing.allocator, std.testing.io, output ++ "/resources/" ++ root ++ "/assets/music/track.mp3");
3519
+ defer std.testing.allocator.free(packaged);
3520
+ try std.testing.expectEqualStrings("mp3-bytes", packaged);
3521
+ try std.testing.expectError(error.FileNotFound, readPath(std.testing.allocator, std.testing.io, output ++ "/resources/music/track.mp3"));
3522
+ }
3523
+ }
3524
+
3435
3525
  test "app-relative asset subpaths accept plain trees and reject escapes" {
3436
3526
  try std.testing.expectEqualStrings("assets", appRelativeAssetSubpath("assets").?);
3437
3527
  try std.testing.expectEqualStrings("data/sounds", appRelativeAssetSubpath("data/sounds").?);
@@ -19,6 +19,11 @@ pub const RawManifest = struct {
19
19
  /// startup window is created; the runtime dock-presence command may
20
20
  /// still promote or demote the app later.
21
21
  dock_visible: bool = true,
22
+ /// Whether one process per bundle id, user, and login session owns
23
+ /// the app. A later launch hands its working directory and arguments
24
+ /// to the owner over the `native-sdk.app.launches` channel source and
25
+ /// exits before creating windows (Windows today).
26
+ single_instance: bool = false,
22
27
  persist: ?RawPersist = null,
23
28
  images: RawImages = .{},
24
29
  service_packages: []const RawServicePackage = &.{},
@@ -55,7 +60,9 @@ pub const RawManifest = struct {
55
60
  pub const RawUpdates = struct {
56
61
  feed_url: ?[]const u8 = null,
57
62
  public_key: ?[]const u8 = null,
63
+ public_keys: []const []const u8 = &.{},
58
64
  check_on_start: bool = false,
65
+ mode: ?[]const u8 = null,
59
66
  };
60
67
 
61
68
  pub const RawImages = struct {
@@ -21,6 +21,8 @@ pub const android = @import("android.zig");
21
21
  pub const xcodeproj = @import("xcodeproj.zig");
22
22
  pub const db = @import("db.zig");
23
23
  pub const update = @import("update.zig");
24
+ pub const zip_writer = @import("zip_writer.zig");
25
+ pub const windows_installer = @import("windows_installer.zig");
24
26
 
25
27
  test {
26
28
  @import("std").testing.refAllDecls(@This());