@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.
- package/README.md +1 -1
- package/build/app.zig +47 -23
- package/build/cli.zig +22 -0
- package/native-sdk.d.ts +2 -0
- package/package.json +10 -10
- package/packages/core/package-lock.json +2 -2
- package/packages/core/package.json +1 -1
- package/schemas/app.schema.json +9 -1
- package/skill-data/ts-core/SKILL.md +2 -0
- package/src/app_runner/app_assets.zig +85 -1
- package/src/app_runner/root.zig +63 -3
- package/src/app_runner/ts_core_main.zig +8 -0
- package/src/platform/macos/appkit_host.h +9 -0
- package/src/platform/macos/appkit_host.m +366 -0
- package/src/platform/macos/root.zig +29 -6
- package/src/platform/null_platform.zig +66 -0
- package/src/platform/root.zig +1 -0
- package/src/platform/types.zig +46 -0
- package/src/platform/windows/root.zig +174 -0
- package/src/platform/windows/scriptc_mingw_compat.c +248 -0
- package/src/platform/windows/webview2_host.cpp +500 -26
- package/src/primitives/app_manifest/root.zig +1 -0
- package/src/primitives/app_manifest/tests.zig +35 -0
- package/src/primitives/app_manifest/types.zig +12 -0
- package/src/primitives/app_manifest/validation.zig +44 -6
- package/src/root.zig +1 -0
- package/src/runtime/bridge_responses.zig +2 -0
- package/src/runtime/channel_sources.zig +49 -1
- package/src/runtime/core.zig +5 -0
- package/src/runtime/effects.zig +12 -1
- package/src/runtime/flow.zig +6 -0
- package/src/runtime/flow_reference.zig +6 -0
- package/src/runtime/root.zig +4 -0
- package/src/runtime/single_instance.zig +893 -0
- package/src/runtime/tests.zig +1 -0
- package/src/runtime/window_controls.zig +73 -0
- package/src/runtime/window_controls_tests.zig +192 -0
- package/src/runtime/window_storage.zig +3 -0
- package/src/tooling/manifest.zig +111 -3
- package/src/tooling/package.zig +98 -8
- package/src/tooling/raw_manifest.zig +7 -0
- package/src/tooling/root.zig +2 -0
- package/src/tooling/templates.zig +53 -3
- package/src/tooling/update.zig +228 -10
- package/src/tooling/windows_installer.zig +245 -0
- package/src/tooling/zip_writer.zig +211 -0
- package/src/updater/engine.zig +936 -0
- package/src/updater/feed.zig +261 -12
- package/src/updater/macos.zig +187 -0
- package/src/updater/root.zig +41 -0
- package/src/updater/windows.zig +470 -0
- package/src/windows_install/config.zig +247 -0
- package/src/windows_install/main.zig +690 -0
- package/src/windows_install/payload.zig +146 -0
- package/src/windows_install/switch.zig +184 -0
- package/src/windows_install/tests.zig +11 -0
- package/src/windows_install/win32.zig +248 -0
- package/tools/native-sdk/main.zig +10 -9
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
//! The install configuration `native package --installer` writes into the
|
|
2
|
+
//! install image as `app\native-install.json`. The setup program, the
|
|
3
|
+
//! launcher and the uninstaller read it to name the install folder, the
|
|
4
|
+
//! executable they start, the shortcut, the uninstall entry, and the
|
|
5
|
+
//! registry values the app registers under HKCU\Software\Classes.
|
|
6
|
+
//!
|
|
7
|
+
//! The setup payload is checked against its SHA-256 before anything is
|
|
8
|
+
//! read, but the values still name paths and registry keys, so every
|
|
9
|
+
//! field is validated: file names are plain names, registry keys stay
|
|
10
|
+
//! under Software\Classes, and nothing escapes the install folder.
|
|
11
|
+
|
|
12
|
+
const std = @import("std");
|
|
13
|
+
|
|
14
|
+
pub const file_name = "native-install.json";
|
|
15
|
+
pub const format_version: u32 = 1;
|
|
16
|
+
|
|
17
|
+
/// A string value the installer sets under HKCU. `{launcher}` in `value`
|
|
18
|
+
/// becomes the installed launcher's path and `{install}` the install
|
|
19
|
+
/// folder.
|
|
20
|
+
pub const RegistryValue = struct {
|
|
21
|
+
key: []const u8,
|
|
22
|
+
/// Empty for the key's default value.
|
|
23
|
+
name: []const u8 = "",
|
|
24
|
+
value: []const u8,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/// What the uninstaller removes. Without `name`, the whole key tree goes;
|
|
28
|
+
/// with `name`, only that value, and only while it still holds `if_value`
|
|
29
|
+
/// (so an association another app has since claimed stays).
|
|
30
|
+
pub const RegistryCleanup = struct {
|
|
31
|
+
key: []const u8,
|
|
32
|
+
name: ?[]const u8 = null,
|
|
33
|
+
if_value: ?[]const u8 = null,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
pub const InstallConfig = struct {
|
|
37
|
+
format: u32 = format_version,
|
|
38
|
+
bundle_id: []const u8,
|
|
39
|
+
/// The manifest's short name: the launcher is `<name>.exe`.
|
|
40
|
+
name: []const u8,
|
|
41
|
+
display_name: []const u8,
|
|
42
|
+
version: []const u8,
|
|
43
|
+
publisher: []const u8 = "",
|
|
44
|
+
/// The app executable, in `app\bin\`.
|
|
45
|
+
executable: []const u8,
|
|
46
|
+
/// The app icon, relative to `app\`, or empty.
|
|
47
|
+
icon: []const u8 = "",
|
|
48
|
+
registry: []const RegistryValue = &.{},
|
|
49
|
+
registry_cleanup: []const RegistryCleanup = &.{},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
pub const max_config_bytes: usize = 64 * 1024;
|
|
53
|
+
pub const max_registry_values: usize = 256;
|
|
54
|
+
pub const max_text_bytes: usize = 256;
|
|
55
|
+
pub const classes_prefix = "Software\\Classes\\";
|
|
56
|
+
/// In a registry value: the installed launcher's path.
|
|
57
|
+
pub const launcher_placeholder = "{launcher}";
|
|
58
|
+
/// In a registry value: the install folder.
|
|
59
|
+
pub const install_placeholder = "{install}";
|
|
60
|
+
|
|
61
|
+
pub const ValidationError = error{InvalidInstallConfig};
|
|
62
|
+
|
|
63
|
+
pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) !std.json.Parsed(InstallConfig) {
|
|
64
|
+
if (bytes.len == 0 or bytes.len > max_config_bytes) return error.InvalidInstallConfig;
|
|
65
|
+
var parsed = std.json.parseFromSlice(InstallConfig, allocator, bytes, .{ .ignore_unknown_fields = true }) catch return error.InvalidInstallConfig;
|
|
66
|
+
errdefer parsed.deinit();
|
|
67
|
+
try validate(parsed.value);
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pub fn write(config: InstallConfig, writer: *std.Io.Writer) !void {
|
|
72
|
+
try std.json.Stringify.value(config, .{ .whitespace = .indent_2 }, writer);
|
|
73
|
+
try writer.writeByte('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pub fn validate(config: InstallConfig) ValidationError!void {
|
|
77
|
+
if (config.format != format_version) return error.InvalidInstallConfig;
|
|
78
|
+
if (!isPathName(config.bundle_id) or config.bundle_id.len > 128) return error.InvalidInstallConfig;
|
|
79
|
+
if (!isPathName(config.name) or std.ascii.endsWithIgnoreCase(config.name, ".exe")) return error.InvalidInstallConfig;
|
|
80
|
+
if (!isPathName(config.executable) or !std.ascii.endsWithIgnoreCase(config.executable, ".exe")) return error.InvalidInstallConfig;
|
|
81
|
+
if (!isText(config.display_name) or config.display_name.len == 0) return error.InvalidInstallConfig;
|
|
82
|
+
if (!isText(config.version) or config.version.len == 0) return error.InvalidInstallConfig;
|
|
83
|
+
if (!isText(config.publisher)) return error.InvalidInstallConfig;
|
|
84
|
+
if (config.icon.len > 0 and !isRelativePath(config.icon)) return error.InvalidInstallConfig;
|
|
85
|
+
if (config.registry.len > max_registry_values or config.registry_cleanup.len > max_registry_values) return error.InvalidInstallConfig;
|
|
86
|
+
for (config.registry) |entry| {
|
|
87
|
+
if (!isClassesKey(entry.key) or !isText(entry.name) or entry.value.len > 1024 or hasControl(entry.value)) return error.InvalidInstallConfig;
|
|
88
|
+
}
|
|
89
|
+
for (config.registry_cleanup) |entry| {
|
|
90
|
+
if (!isClassesKey(entry.key)) return error.InvalidInstallConfig;
|
|
91
|
+
if (entry.name) |name| if (!isText(name)) return error.InvalidInstallConfig;
|
|
92
|
+
if (entry.if_value) |value| if (value.len > 1024 or hasControl(value)) return error.InvalidInstallConfig;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The folder under `%LOCALAPPDATA%\Programs` the app installs into: its
|
|
97
|
+
/// display name with the characters Windows does not allow in a file
|
|
98
|
+
/// name removed, or the bundle id when nothing usable is left.
|
|
99
|
+
pub fn installFolderName(output: []u8, display_name: []const u8, bundle_id: []const u8) ![]const u8 {
|
|
100
|
+
var length: usize = 0;
|
|
101
|
+
for (display_name) |byte| {
|
|
102
|
+
if (byte < 0x20 or std.mem.indexOfScalar(u8, "<>:\"/\\|?*", byte) != null) continue;
|
|
103
|
+
if (length == output.len) return error.NoSpaceLeft;
|
|
104
|
+
output[length] = byte;
|
|
105
|
+
length += 1;
|
|
106
|
+
}
|
|
107
|
+
var name = std.mem.trim(u8, output[0..length], " .");
|
|
108
|
+
if (name.len == 0 or isReservedDeviceName(name)) {
|
|
109
|
+
if (bundle_id.len > output.len) return error.NoSpaceLeft;
|
|
110
|
+
@memcpy(output[0..bundle_id.len], bundle_id);
|
|
111
|
+
name = output[0..bundle_id.len];
|
|
112
|
+
} else if (name.ptr != output.ptr) {
|
|
113
|
+
std.mem.copyForwards(u8, output[0..name.len], name);
|
|
114
|
+
name = output[0..name.len];
|
|
115
|
+
}
|
|
116
|
+
return name;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// The registry key of the app's entry in Settings > Apps, under HKCU.
|
|
120
|
+
pub fn uninstallKey(output: []u8, bundle_id: []const u8) ![]const u8 {
|
|
121
|
+
return std.fmt.bufPrint(output, "Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{s}", .{bundle_id});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn isPathName(value: []const u8) bool {
|
|
125
|
+
if (value.len == 0 or value.len > max_text_bytes) return false;
|
|
126
|
+
if (std.mem.eql(u8, value, ".") or std.mem.eql(u8, value, "..")) return false;
|
|
127
|
+
for (value) |byte| {
|
|
128
|
+
if (byte < 0x20 or std.mem.indexOfScalar(u8, "<>:\"/\\|?*", byte) != null) return false;
|
|
129
|
+
}
|
|
130
|
+
if (value[value.len - 1] == '.' or value[value.len - 1] == ' ') return false;
|
|
131
|
+
return !isReservedDeviceName(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fn isRelativePath(value: []const u8) bool {
|
|
135
|
+
if (value.len == 0 or value.len > max_text_bytes) return false;
|
|
136
|
+
var parts = std.mem.tokenizeAny(u8, value, "/\\");
|
|
137
|
+
var count: usize = 0;
|
|
138
|
+
while (parts.next()) |part| {
|
|
139
|
+
if (!isPathName(part)) return false;
|
|
140
|
+
count += 1;
|
|
141
|
+
}
|
|
142
|
+
return count > 0 and value[0] != '/' and value[0] != '\\';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fn isText(value: []const u8) bool {
|
|
146
|
+
return value.len <= max_text_bytes and !hasControl(value) and std.unicode.utf8ValidateSlice(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn hasControl(value: []const u8) bool {
|
|
150
|
+
for (value) |byte| {
|
|
151
|
+
if (byte < 0x20 or byte == 0x7f) return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn isClassesKey(key: []const u8) bool {
|
|
157
|
+
if (!std.mem.startsWith(u8, key, classes_prefix) or key.len == classes_prefix.len or key.len > 512) return false;
|
|
158
|
+
if (hasControl(key) or std.mem.indexOf(u8, key, "\\\\") != null or key[key.len - 1] == '\\') return false;
|
|
159
|
+
var parts = std.mem.splitScalar(u8, key, '\\');
|
|
160
|
+
while (parts.next()) |part| {
|
|
161
|
+
if (std.mem.eql(u8, part, "..")) return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fn isReservedDeviceName(value: []const u8) bool {
|
|
167
|
+
const stem_end = std.mem.indexOfScalar(u8, value, '.') orelse value.len;
|
|
168
|
+
const stem = std.mem.trimEnd(u8, value[0..stem_end], " ");
|
|
169
|
+
const reserved = [_][]const u8{ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" };
|
|
170
|
+
for (reserved) |name| {
|
|
171
|
+
if (std.ascii.eqlIgnoreCase(stem, name)) return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const test_config: InstallConfig = .{
|
|
177
|
+
.bundle_id = "dev.example.notes",
|
|
178
|
+
.name = "notes",
|
|
179
|
+
.display_name = "Acme Notes",
|
|
180
|
+
.version = "1.2.3",
|
|
181
|
+
.publisher = "Acme",
|
|
182
|
+
.executable = "notes.exe",
|
|
183
|
+
.icon = "app-icon.ico",
|
|
184
|
+
.registry = &.{
|
|
185
|
+
.{ .key = "Software\\Classes\\.acme", .value = "dev.example.notes.Note" },
|
|
186
|
+
.{ .key = "Software\\Classes\\dev.example.notes.Note\\shell\\open\\command", .value = "\"{launcher}\" \"%1\"" },
|
|
187
|
+
},
|
|
188
|
+
.registry_cleanup = &.{
|
|
189
|
+
.{ .key = "Software\\Classes\\dev.example.notes.Note" },
|
|
190
|
+
.{ .key = "Software\\Classes\\.acme", .name = "", .if_value = "dev.example.notes.Note" },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
test "install config round-trips through its JSON form" {
|
|
195
|
+
var output = std.Io.Writer.Allocating.init(std.testing.allocator);
|
|
196
|
+
defer output.deinit();
|
|
197
|
+
try write(test_config, &output.writer);
|
|
198
|
+
var parsed = try parse(std.testing.allocator, output.written());
|
|
199
|
+
defer parsed.deinit();
|
|
200
|
+
try std.testing.expectEqualStrings("notes.exe", parsed.value.executable);
|
|
201
|
+
try std.testing.expectEqual(@as(usize, 2), parsed.value.registry.len);
|
|
202
|
+
try std.testing.expectEqualStrings("dev.example.notes.Note", parsed.value.registry_cleanup[1].if_value.?);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
test "install config refuses names and keys that escape their place" {
|
|
206
|
+
var config = test_config;
|
|
207
|
+
config.executable = "..\\evil.exe";
|
|
208
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
209
|
+
config = test_config;
|
|
210
|
+
config.name = "notes.exe";
|
|
211
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
212
|
+
config = test_config;
|
|
213
|
+
config.bundle_id = "CON";
|
|
214
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
215
|
+
config = test_config;
|
|
216
|
+
config.icon = "..\\..\\icon.ico";
|
|
217
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
218
|
+
config = test_config;
|
|
219
|
+
config.registry = &.{.{ .key = "Software\\Microsoft\\Windows\\CurrentVersion\\Run", .name = "x", .value = "y" }};
|
|
220
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
221
|
+
config = test_config;
|
|
222
|
+
config.registry = &.{.{ .key = "Software\\Classes\\..\\Microsoft", .value = "y" }};
|
|
223
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
224
|
+
config = test_config;
|
|
225
|
+
config.registry_cleanup = &.{.{ .key = "Software\\Classes" }};
|
|
226
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
227
|
+
config = test_config;
|
|
228
|
+
config.format = 2;
|
|
229
|
+
try std.testing.expectError(error.InvalidInstallConfig, validate(config));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
test "install folders take the display name without forbidden characters" {
|
|
233
|
+
var buffer: [128]u8 = undefined;
|
|
234
|
+
try std.testing.expectEqualStrings("Acme Notes", try installFolderName(&buffer, "Acme Notes", "dev.example.notes"));
|
|
235
|
+
try std.testing.expectEqualStrings("Acme Notes 2", try installFolderName(&buffer, "Acme: Notes 2?", "dev.example.notes"));
|
|
236
|
+
try std.testing.expectEqualStrings("Notes", try installFolderName(&buffer, " .Notes. ", "dev.example.notes"));
|
|
237
|
+
try std.testing.expectEqualStrings("dev.example.notes", try installFolderName(&buffer, "???", "dev.example.notes"));
|
|
238
|
+
try std.testing.expectEqualStrings("dev.example.notes", try installFolderName(&buffer, "nul", "dev.example.notes"));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
test "the uninstall entry lives under the current user's Uninstall key" {
|
|
242
|
+
var buffer: [256]u8 = undefined;
|
|
243
|
+
try std.testing.expectEqualStrings(
|
|
244
|
+
"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\dev.example.notes",
|
|
245
|
+
try uninstallKey(&buffer, "dev.example.notes"),
|
|
246
|
+
);
|
|
247
|
+
}
|