diff --git a/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj b/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj
index f13d378..3ced762 100644
--- a/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj
+++ b/NativeScriptWindowsDemo/NativeScriptWindowsDemo.csproj
@@ -104,7 +104,12 @@
-
+
+
+ PreserveNewest
+
+
PreserveNewest
diff --git a/NativeScriptWindowsDemo/RuntimeHost.cs b/NativeScriptWindowsDemo/RuntimeHost.cs
index 0aa25eb..a074ef9 100644
--- a/NativeScriptWindowsDemo/RuntimeHost.cs
+++ b/NativeScriptWindowsDemo/RuntimeHost.cs
@@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))]
private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder);
+ // Escape hatch for apps packed with `nsbundle_pack --key-hex ` (custom-key
+ // app.nsbundle containers). Must be called before runtime_init if used at all — apps
+ // packed with the default pepper (no --key-hex) never need to call this. Not wired to a
+ // default call site here; an app author supplies their own key material and calls this
+ // from Initialize() before runtime_init(...).
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))]
+ private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex);
+
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))]
+ private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath);
+
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))]
+ private static extern void runtime_free_protected_string(IntPtr ptr);
+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))]
private static extern IntPtr runtime_get_last_js_error();
@@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker()
}
#endif
+ /// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the
+ /// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to
+ /// the real filesystem. `path` is whatever candidate the caller already built for
+ /// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's
+ /// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here.
+ private static string TryReadVirtual(string path)
+ {
+ IntPtr ptr = IntPtr.Zero;
+ try
+ {
+ ptr = runtime_read_protected_file(path);
+ return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
+ }
+ catch { return null; }
+ finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); }
+ }
+
+ private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path);
+
+ private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path);
+
public void RunMainScript()
{
if (!_initialized)
@@ -208,7 +243,7 @@ public void RunMainScript()
foreach (var chunkName in new[] { "runtime.js", "vendor.js" })
{
var chunkPath = Path.Combine(dir, chunkName);
- if (File.Exists(chunkPath) &&
+ if (VExists(chunkPath) &&
!string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase))
{
chunks.Add(chunkPath);
@@ -218,7 +253,7 @@ public void RunMainScript()
foreach (var scriptPath in chunks)
{
- var script = File.ReadAllText(Path.GetFullPath(scriptPath));
+ var script = VReadAllText(Path.GetFullPath(scriptPath));
try
{
runtime_runscript(_runtime, script, Path.GetFileName(scriptPath));
@@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath()
foreach (var dir in appDirCandidates)
{
var candidate = Path.Combine(dir, "package.json");
- if (File.Exists(candidate))
+ if (VExists(candidate))
{
packageJsonPath = candidate;
resolvedBaseDir = dir;
@@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath()
}
// Also accept package.json at the project root (parent of bin/).
- if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json")))
+ if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json")))
{
packageJsonPath = Path.Combine(parentDir, "package.json");
resolvedBaseDir = parentDir;
@@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath()
string Fallback() =>
appDirCandidates
.SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") })
- .FirstOrDefault(File.Exists);
+ .FirstOrDefault(VExists);
if (packageJsonPath == null)
return Fallback();
@@ -304,7 +339,7 @@ string Fallback() =>
private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath)
{
- using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath));
+ using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath));
var config = new RuntimePackageConfig();
if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String)
config.Main = main.GetString();
@@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath)
foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" })
{
var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate);
- if (File.Exists(direct)) return direct;
+ if (VExists(direct)) return direct;
var appLower = Path.Combine(baseDir, "app", candidate);
- if (File.Exists(appLower)) return appLower;
+ if (VExists(appLower)) return appLower;
var appUpper = Path.Combine(baseDir, "App", candidate);
- if (File.Exists(appUpper)) return appUpper;
+ if (VExists(appUpper)) return appUpper;
}
return null;
}
diff --git a/TestApp/RuntimeHost.cs b/TestApp/RuntimeHost.cs
index 6a5cfd3..92fda05 100644
--- a/TestApp/RuntimeHost.cs
+++ b/TestApp/RuntimeHost.cs
@@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))]
private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder);
+ // Escape hatch for apps packed with `nsbundle_pack --key-hex ` (custom-key
+ // app.nsbundle containers). Must be called before runtime_init if used at all — apps
+ // packed with the default pepper (no --key-hex) never need to call this. Not wired to a
+ // default call site here; an app author supplies their own key material and calls this
+ // from Initialize() before runtime_init(...).
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))]
+ private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex);
+
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))]
+ private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath);
+
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))]
+ private static extern void runtime_free_protected_string(IntPtr ptr);
+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))]
private static extern IntPtr runtime_get_last_js_error();
@@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker()
}
#endif
+ /// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the
+ /// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to
+ /// the real filesystem. `path` is whatever candidate the caller already built for
+ /// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's
+ /// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here.
+ private static string TryReadVirtual(string path)
+ {
+ IntPtr ptr = IntPtr.Zero;
+ try
+ {
+ ptr = runtime_read_protected_file(path);
+ return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
+ }
+ catch { return null; }
+ finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); }
+ }
+
+ private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path);
+
+ private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path);
+
public void RunMainScript()
{
if (!_initialized)
@@ -208,7 +243,7 @@ public void RunMainScript()
foreach (var chunkName in new[] { "runtime.js", "vendor.js" })
{
var chunkPath = Path.Combine(dir, chunkName);
- if (File.Exists(chunkPath) &&
+ if (VExists(chunkPath) &&
!string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase))
{
chunks.Add(chunkPath);
@@ -218,7 +253,7 @@ public void RunMainScript()
foreach (var scriptPath in chunks)
{
- var script = File.ReadAllText(Path.GetFullPath(scriptPath));
+ var script = VReadAllText(Path.GetFullPath(scriptPath));
try
{
runtime_runscript(_runtime, script, Path.GetFileName(scriptPath));
@@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath()
foreach (var dir in appDirCandidates)
{
var candidate = Path.Combine(dir, "package.json");
- if (File.Exists(candidate))
+ if (VExists(candidate))
{
packageJsonPath = candidate;
resolvedBaseDir = dir;
@@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath()
}
// Also accept package.json at the project root (parent of bin/).
- if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json")))
+ if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json")))
{
packageJsonPath = Path.Combine(parentDir, "package.json");
resolvedBaseDir = parentDir;
@@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath()
string Fallback() =>
appDirCandidates
.SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") })
- .FirstOrDefault(File.Exists);
+ .FirstOrDefault(VExists);
if (packageJsonPath == null)
return Fallback();
@@ -304,7 +339,7 @@ string Fallback() =>
private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath)
{
- using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath));
+ using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath));
var config = new RuntimePackageConfig();
if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String)
config.Main = main.GetString();
@@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath)
foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" })
{
var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate);
- if (File.Exists(direct)) return direct;
+ if (VExists(direct)) return direct;
var appLower = Path.Combine(baseDir, "app", candidate);
- if (File.Exists(appLower)) return appLower;
+ if (VExists(appLower)) return appLower;
var appUpper = Path.Combine(baseDir, "App", candidate);
- if (File.Exists(appUpper)) return appUpper;
+ if (VExists(appUpper)) return appUpper;
}
return null;
}
diff --git a/TestApp/TestApp.csproj b/TestApp/TestApp.csproj
index 38fa999..03627e7 100644
--- a/TestApp/TestApp.csproj
+++ b/TestApp/TestApp.csproj
@@ -86,7 +86,12 @@
-
+
+
+ PreserveNewest
+
+
PreserveNewest
diff --git a/integration-tests/tests/protected_bundle.rs b/integration-tests/tests/protected_bundle.rs
new file mode 100644
index 0000000..2ef864d
--- /dev/null
+++ b/integration-tests/tests/protected_bundle.rs
@@ -0,0 +1,74 @@
+//! Proves the sealed `app.nsbundle` container (`runtime::source_protect`) actually serves as the
+//! JS source for a real `Runtime` run — not just round-tripping in isolation (see
+//! `source_protect`'s own unit tests for that). The plaintext staging directory is deleted right
+//! after packing, before `Runtime::new` ever runs, so there is no possible filesystem fallback:
+//! if the ESM import below resolves at all, it can only have come from the decrypted in-memory
+//! table.
+
+use runtime::source_protect;
+use runtime::Runtime;
+use std::fs;
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+static COUNTER: AtomicU64 = AtomicU64::new(0);
+
+fn scratch_dir(name: &str) -> PathBuf {
+ let n = COUNTER.fetch_add(1, Ordering::Relaxed);
+ let dir = std::env::temp_dir().join(format!(
+ "nsbundle_integration_{name}_{}_{n}",
+ std::process::id()
+ ));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+#[test]
+fn esm_import_resolves_from_sealed_bundle_with_no_plaintext_on_disk() {
+ // Stage the two-file ESM "app", pack it, then destroy the plaintext — everything downstream
+ // must come from the decrypted table or this test fails.
+ let staging = scratch_dir("stage");
+ fs::write(
+ staging.join("entry.mjs"),
+ b"import { value } from './dep.mjs';\n\
+ if (value !== 42) { throw new Error('wrong value: ' + value); }\n",
+ )
+ .unwrap();
+ fs::write(staging.join("dep.mjs"), b"export const value = 42;\n").unwrap();
+
+ let app_root = scratch_dir("approot");
+ let bundle_path = app_root.join("app.nsbundle");
+ source_protect::pack_directory(
+ &staging,
+ &bundle_path,
+ source_protect::KEY_MODE_DEFAULT,
+ source_protect::default_key(),
+ )
+ .unwrap();
+
+ fs::remove_dir_all(&staging).unwrap();
+ assert!(!staging.exists(), "plaintext staging dir must be gone");
+
+ // Runtime::new -> Runtime::source_protect::init_from_app_root locates app_root/app.nsbundle
+ // and decrypts it into the in-memory table before anything else runs.
+ let mut rt = Runtime::new(app_root.to_str().unwrap());
+ assert!(
+ source_protect::has_bundle(),
+ "app.nsbundle should have been found and loaded from {}",
+ app_root.display()
+ );
+
+ // Fetch the entry's own source from the protected table too (mirrors what a real host does
+ // via runtime_read_protected_file instead of File.ReadAllText).
+ let entry_source =
+ source_protect::read_text("entry.mjs").expect("entry.mjs should be served from the bundle");
+
+ rt.run_script(&entry_source, "entry.mjs");
+
+ assert_eq!(
+ runtime::get_last_js_error(),
+ None,
+ "ESM import of the bundle-only dep.mjs should have resolved cleanly"
+ );
+}
diff --git a/nativescript/src/lib.rs b/nativescript/src/lib.rs
index 7fad8e7..26e2da5 100644
--- a/nativescript/src/lib.rs
+++ b/nativescript/src/lib.rs
@@ -190,6 +190,20 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) {
let _ = LOCAL_FOLDER.set(s);
}
+/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle
+/// container packed with `nsbundle_pack --key-hex`. Must be called before `runtime_init` — same
+/// ordering requirement as `runtime_set_local_folder`. Returns 1 on success, 0 on malformed input
+/// (wrong length or non-hex characters). Apps sealed with the default pepper (`key_mode == 0`,
+/// i.e. `nsbundle_pack` invoked without `--key-hex`) never need to call this.
+#[no_mangle]
+pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int {
+ if key_hex.is_null() {
+ return 0;
+ }
+ let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy();
+ runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int
+}
+
#[no_mangle]
pub extern "C" fn runtime_init(app_root: *const c_char) -> i64 {
install_veh();
@@ -285,6 +299,32 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) {
}
}
+/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. Returns
+/// NULL when no bundle was loaded, or the given path isn't in it — this doubles as an existence
+/// probe, so callers (`RuntimeHost.cs`'s `VExists`/`VReadAllText`) can use one call for both.
+/// Non-NULL results must be freed with `runtime_free_protected_string`.
+#[no_mangle]
+pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char {
+ if virtual_path.is_null() {
+ return std::ptr::null_mut();
+ }
+ let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy();
+ match runtime::source_protect::read_text(path.as_ref()) {
+ Some(content) => CString::new(content)
+ .map(|c| c.into_raw())
+ .unwrap_or(std::ptr::null_mut()),
+ None => std::ptr::null_mut(),
+ }
+}
+
+/// Free a string previously returned by `runtime_read_protected_file`.
+#[no_mangle]
+pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) {
+ if !ptr.is_null() {
+ drop(unsafe { CString::from_raw(ptr) });
+ }
+}
+
// ─── Devtools FFI ─────────────────────────────────────────────────────────────
#[no_mangle]
diff --git a/packages/windows-hermes/Cargo.lock b/packages/windows-hermes/Cargo.lock
index 3d571d2..e0507e6 100644
--- a/packages/windows-hermes/Cargo.lock
+++ b/packages/windows-hermes/Cargo.lock
@@ -17,6 +17,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
- "getrandom",
+ "getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
@@ -171,6 +206,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core",
"typenum",
]
@@ -244,6 +290,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -378,6 +433,17 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -390,6 +456,16 @@ dependencies = [
"wasip2",
]
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -593,6 +669,15 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -837,6 +922,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -878,6 +969,18 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -923,6 +1026,15 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -975,12 +1087,14 @@ dependencies = [
name = "runtime"
version = "0.1.0"
dependencies = [
+ "aes-gcm",
"ahash",
"anyhow",
"backtrace",
"byteorder",
"chrono",
"form_urlencoded",
+ "getrandom 0.2.17",
"libc",
"libffi",
"metadata",
@@ -1141,6 +1255,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -1238,6 +1358,16 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -1279,6 +1409,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
diff --git a/packages/windows-hermes/src/abi.rs b/packages/windows-hermes/src/abi.rs
index 9bb12e1..4f5d9cb 100644
--- a/packages/windows-hermes/src/abi.rs
+++ b/packages/windows-hermes/src/abi.rs
@@ -143,6 +143,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) {
runtime::set_log_dir(s);
}
+/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle
+/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input.
+#[no_mangle]
+pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int {
+ if key_hex.is_null() {
+ return 0;
+ }
+ let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy();
+ runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int
+}
+
#[no_mangle]
pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) {
// No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity.
@@ -170,3 +181,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) {
drop(unsafe { CString::from_raw(ptr) });
}
}
+
+/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL
+/// means no bundle loaded or the path isn't in it. Free non-NULL results with
+/// `runtime_free_protected_string`.
+#[no_mangle]
+pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char {
+ if virtual_path.is_null() {
+ return std::ptr::null_mut();
+ }
+ let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy();
+ match runtime::source_protect::read_text(path.as_ref()) {
+ Some(content) => CString::new(content)
+ .map(|c| c.into_raw())
+ .unwrap_or(std::ptr::null_mut()),
+ None => std::ptr::null_mut(),
+ }
+}
+
+#[no_mangle]
+pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) {
+ if !ptr.is_null() {
+ drop(unsafe { CString::from_raw(ptr) });
+ }
+}
diff --git a/packages/windows-jsc/Cargo.lock b/packages/windows-jsc/Cargo.lock
index b3b44cf..eea7305 100644
--- a/packages/windows-jsc/Cargo.lock
+++ b/packages/windows-jsc/Cargo.lock
@@ -17,6 +17,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
- "getrandom",
+ "getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
@@ -171,6 +206,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core",
"typenum",
]
@@ -244,6 +290,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -378,6 +433,17 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -390,6 +456,16 @@ dependencies = [
"wasip2",
]
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -593,6 +669,15 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -837,6 +922,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -878,6 +969,18 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -923,6 +1026,15 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -975,12 +1087,14 @@ dependencies = [
name = "runtime"
version = "0.1.0"
dependencies = [
+ "aes-gcm",
"ahash",
"anyhow",
"backtrace",
"byteorder",
"chrono",
"form_urlencoded",
+ "getrandom 0.2.17",
"libc",
"libffi",
"metadata",
@@ -1141,6 +1255,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -1238,6 +1358,16 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -1279,6 +1409,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
diff --git a/packages/windows-jsc/src/abi.rs b/packages/windows-jsc/src/abi.rs
index 00dd5e3..fc6dce6 100644
--- a/packages/windows-jsc/src/abi.rs
+++ b/packages/windows-jsc/src/abi.rs
@@ -146,6 +146,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) {
runtime::set_log_dir(s);
}
+/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle
+/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input.
+#[no_mangle]
+pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int {
+ if key_hex.is_null() {
+ return 0;
+ }
+ let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy();
+ runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int
+}
+
#[no_mangle]
pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) {
// No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity.
@@ -173,3 +184,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) {
drop(unsafe { CString::from_raw(ptr) });
}
}
+
+/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL
+/// means no bundle loaded or the path isn't in it. Free non-NULL results with
+/// `runtime_free_protected_string`.
+#[no_mangle]
+pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char {
+ if virtual_path.is_null() {
+ return std::ptr::null_mut();
+ }
+ let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy();
+ match runtime::source_protect::read_text(path.as_ref()) {
+ Some(content) => CString::new(content)
+ .map(|c| c.into_raw())
+ .unwrap_or(std::ptr::null_mut()),
+ None => std::ptr::null_mut(),
+ }
+}
+
+#[no_mangle]
+pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) {
+ if !ptr.is_null() {
+ drop(unsafe { CString::from_raw(ptr) });
+ }
+}
diff --git a/packages/windows-quickjs/Cargo.lock b/packages/windows-quickjs/Cargo.lock
index 911792f..9798c71 100644
--- a/packages/windows-quickjs/Cargo.lock
+++ b/packages/windows-quickjs/Cargo.lock
@@ -17,6 +17,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
- "getrandom",
+ "getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
@@ -171,6 +206,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core",
"typenum",
]
@@ -244,6 +290,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -378,6 +433,17 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -390,6 +456,16 @@ dependencies = [
"wasip2",
]
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -593,6 +669,15 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -837,6 +922,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -878,6 +969,18 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -923,6 +1026,15 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -975,12 +1087,14 @@ dependencies = [
name = "runtime"
version = "0.1.0"
dependencies = [
+ "aes-gcm",
"ahash",
"anyhow",
"backtrace",
"byteorder",
"chrono",
"form_urlencoded",
+ "getrandom 0.2.17",
"libc",
"libffi",
"metadata",
@@ -1141,6 +1255,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -1238,6 +1358,16 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -1279,6 +1409,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
diff --git a/packages/windows-quickjs/src/abi.rs b/packages/windows-quickjs/src/abi.rs
index 88df250..cbdaaa7 100644
--- a/packages/windows-quickjs/src/abi.rs
+++ b/packages/windows-quickjs/src/abi.rs
@@ -191,6 +191,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) {
runtime::set_log_dir(s);
}
+/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle
+/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input.
+#[no_mangle]
+pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int {
+ if key_hex.is_null() {
+ return 0;
+ }
+ let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy();
+ runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int
+}
+
#[no_mangle]
pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) {
// No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity.
@@ -218,3 +229,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) {
drop(unsafe { CString::from_raw(ptr) });
}
}
+
+/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL
+/// means no bundle loaded or the path isn't in it. Free non-NULL results with
+/// `runtime_free_protected_string`.
+#[no_mangle]
+pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char {
+ if virtual_path.is_null() {
+ return std::ptr::null_mut();
+ }
+ let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy();
+ match runtime::source_protect::read_text(path.as_ref()) {
+ Some(content) => CString::new(content)
+ .map(|c| c.into_raw())
+ .unwrap_or(std::ptr::null_mut()),
+ None => std::ptr::null_mut(),
+ }
+}
+
+#[no_mangle]
+pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) {
+ if !ptr.is_null() {
+ drop(unsafe { CString::from_raw(ptr) });
+ }
+}
diff --git a/packages/windows-v8/Cargo.lock b/packages/windows-v8/Cargo.lock
index 36563bb..130e06c 100644
--- a/packages/windows-v8/Cargo.lock
+++ b/packages/windows-v8/Cargo.lock
@@ -17,6 +17,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -24,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
- "getrandom",
+ "getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
@@ -171,6 +206,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "clang-sys"
version = "1.8.1"
@@ -231,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core",
"typenum",
]
@@ -244,6 +290,15 @@ dependencies = [
"syn",
]
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -378,6 +433,17 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -390,6 +456,16 @@ dependencies = [
"wasip2",
]
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -593,6 +669,15 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -837,6 +922,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -878,6 +969,18 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -923,6 +1026,15 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -975,12 +1087,14 @@ dependencies = [
name = "runtime"
version = "0.1.0"
dependencies = [
+ "aes-gcm",
"ahash",
"anyhow",
"backtrace",
"byteorder",
"chrono",
"form_urlencoded",
+ "getrandom 0.2.17",
"libc",
"libffi",
"metadata",
@@ -1141,6 +1255,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -1238,6 +1358,16 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -1279,6 +1409,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
diff --git a/packages/windows-v8/src/abi.rs b/packages/windows-v8/src/abi.rs
index 86540b3..e9752fb 100644
--- a/packages/windows-v8/src/abi.rs
+++ b/packages/windows-v8/src/abi.rs
@@ -146,6 +146,17 @@ pub extern "C" fn runtime_set_local_folder(path: *const c_char) {
runtime::set_log_dir(s);
}
+/// Supply a custom key (64 hex chars = 32 bytes) for opening a `key_mode == 1` app.nsbundle
+/// container. Must be called before `runtime_init`. Returns 1 on success, 0 on malformed input.
+#[no_mangle]
+pub extern "C" fn runtime_set_bundle_key(key_hex: *const c_char) -> c_int {
+ if key_hex.is_null() {
+ return 0;
+ }
+ let hex = unsafe { CStr::from_ptr(key_hex) }.to_string_lossy();
+ runtime::source_protect::set_custom_key_hex(hex.as_ref()) as c_int
+}
+
#[no_mangle]
pub extern "C" fn runtime_install_ctrlc_handler(_exit_code: i32) {
// No-op on the engine hosts: the WinUI 3 process owns Ctrl+C. Present for ABI parity.
@@ -173,3 +184,27 @@ pub extern "C" fn runtime_free_js_error(ptr: *mut c_char) {
drop(unsafe { CString::from_raw(ptr) });
}
}
+
+/// Read a JS source file out of the sealed app.nsbundle loaded for this process, if any. NULL
+/// means no bundle loaded or the path isn't in it. Free non-NULL results with
+/// `runtime_free_protected_string`.
+#[no_mangle]
+pub extern "C" fn runtime_read_protected_file(virtual_path: *const c_char) -> *mut c_char {
+ if virtual_path.is_null() {
+ return std::ptr::null_mut();
+ }
+ let path = unsafe { CStr::from_ptr(virtual_path) }.to_string_lossy();
+ match runtime::source_protect::read_text(path.as_ref()) {
+ Some(content) => CString::new(content)
+ .map(|c| c.into_raw())
+ .unwrap_or(std::ptr::null_mut()),
+ None => std::ptr::null_mut(),
+ }
+}
+
+#[no_mangle]
+pub extern "C" fn runtime_free_protected_string(ptr: *mut c_char) {
+ if !ptr.is_null() {
+ drop(unsafe { CString::from_raw(ptr) });
+ }
+}
diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml
index b71aad0..c551935 100644
--- a/runtime/Cargo.toml
+++ b/runtime/Cargo.toml
@@ -25,6 +25,8 @@ runtime-devtools = { path = "../runtime-devtools", optional = true }
napi = { version = "2", default-features = false, features = ["napi8"], optional = true }
# Native crash reporter (napi_engine hosts): symbolize SEH faults via the host PDB.
backtrace = "0.3"
+aes-gcm = "0.10"
+getrandom = "0.2"
windows-core = "0.62.2"
windows-collections = "0.3.2"
@@ -61,7 +63,6 @@ features = [
[features]
default = []
devtools = ["runtime-devtools"]
-# Engine-agnostic Node-API backend, ported from the rusty_v8 seam.
napi_engine = ["dep:napi"]
[dev-dependencies]
@@ -73,4 +74,8 @@ harness = false
[[bench]]
name = "async_runtime"
-harness = false
\ No newline at end of file
+harness = false
+
+[[bin]]
+name = "nsbundle_pack"
+path = "src/bin/nsbundle_pack.rs"
\ No newline at end of file
diff --git a/runtime/src/global_fns.rs b/runtime/src/global_fns.rs
index a3c4a4f..31bd107 100644
--- a/runtime/src/global_fns.rs
+++ b/runtime/src/global_fns.rs
@@ -197,23 +197,24 @@ pub(crate) fn normalize_js_path(path: &str) -> PathBuf {
}
pub(crate) fn try_resolve_with_known_extensions(candidate: PathBuf) -> PathBuf {
- if candidate.exists() {
+ // Each `.exists()` probe also checks the sealed app.nsbundle's decrypted table (a no-op,
+ // cheap OnceLock read when no bundle is loaded) — a packed app has no real `app/` directory
+ // on disk, so `is_dir()`/`.exists()` alone would never resolve anything.
+ if candidate.exists() || crate::source_protect::contains(&candidate.to_string_lossy()) {
return candidate;
}
if candidate.extension().is_none() {
for ext in ["js", "mjs", "cjs"] {
let with_ext = candidate.with_extension(ext);
- if with_ext.exists() {
+ if with_ext.exists() || crate::source_protect::contains(&with_ext.to_string_lossy()) {
return with_ext;
}
}
}
- if candidate.is_dir() {
- for index_file in ["index.js", "index.mjs", "index.cjs"] {
- let with_index = candidate.join(index_file);
- if with_index.exists() {
- return with_index;
- }
+ for index_file in ["index.js", "index.mjs", "index.cjs"] {
+ let with_index = candidate.join(index_file);
+ if with_index.exists() || crate::source_protect::contains(&with_index.to_string_lossy()) {
+ return with_index;
}
}
candidate
@@ -788,6 +789,14 @@ pub(crate) fn handle_read_text_file(
throw_js_error(scope, "__nsReadTextFile: path is empty");
return;
}
+ if let Some(content) = crate::source_protect::read_text(path.as_str()) {
+ if let Some(value) = v8::String::new(scope, content.as_str()) {
+ retval.set(value.into());
+ } else {
+ retval.set_null();
+ }
+ return;
+ }
match fs::read_to_string(Path::new(path.as_str())) {
Ok(content) => {
if let Some(value) = v8::String::new(scope, content.as_str()) {
@@ -946,7 +955,11 @@ pub(crate) fn handle_resolve_module_path(
let parent = parent_path
.map(|v| normalize_js_path(v.as_str()))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
- let base = if parent.is_file() {
+ // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, so also
+ // check the sealed bundle's table — otherwise a relative require() from a bundle-only file
+ // would wrongly treat the referrer itself as the base directory instead of its parent.
+ let base = if parent.is_file() || crate::source_protect::contains(&parent.to_string_lossy())
+ {
parent.parent().map(Path::to_path_buf).unwrap_or(parent)
} else {
parent
diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs
index 0a770eb..0d5e6ef 100644
--- a/runtime/src/lib.rs
+++ b/runtime/src/lib.rs
@@ -24,6 +24,7 @@ pub mod napi_engine;
mod ns_proxy;
mod property_call;
mod proxy_manifest_loader;
+pub mod source_protect;
pub mod timers;
mod type_description;
pub mod ui_dispatcher;
@@ -1330,25 +1331,26 @@ fn normalize_js_path(path: &str) -> PathBuf {
}
fn try_resolve_with_known_extensions(candidate: PathBuf) -> PathBuf {
- if candidate.exists() {
+ // Each `.exists()` probe also checks the sealed app.nsbundle's decrypted table (a no-op,
+ // cheap OnceLock read when no bundle is loaded) — a packed app has no real `app/` directory
+ // on disk, so `is_dir()`/`.exists()` alone would never resolve anything.
+ if candidate.exists() || crate::source_protect::contains(&candidate.to_string_lossy()) {
return candidate;
}
if candidate.extension().is_none() {
for ext in ["js", "mjs", "cjs"] {
let with_ext = candidate.with_extension(ext);
- if with_ext.exists() {
+ if with_ext.exists() || crate::source_protect::contains(&with_ext.to_string_lossy()) {
return with_ext;
}
}
}
- if candidate.is_dir() {
- for index_file in ["index.js", "index.mjs", "index.cjs"] {
- let with_index = candidate.join(index_file);
- if with_index.exists() {
- return with_index;
- }
+ for index_file in ["index.js", "index.mjs", "index.cjs"] {
+ let with_index = candidate.join(index_file);
+ if with_index.exists() || crate::source_protect::contains(&with_index.to_string_lossy()) {
+ return with_index;
}
}
@@ -1363,7 +1365,11 @@ fn resolve_esm_path(specifier: &str, referrer_path: Option<&str>) -> String {
let parent = referrer_path
.map(normalize_js_path)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
- let base = if parent.is_file() {
+ // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, so also
+ // check the sealed bundle's table — otherwise a relative import from a bundle-only module
+ // would wrongly treat the referrer itself as the base directory instead of its parent.
+ let base = if parent.is_file() || crate::source_protect::contains(&parent.to_string_lossy())
+ {
parent.parent().map(Path::to_path_buf).unwrap_or(parent)
} else {
parent
@@ -1450,6 +1456,10 @@ fn compile_module_graph(scope: &mut v8::PinScope<'_, '_>, source: &str, path: &s
if ESM_MODULE_REGISTRY.with(|r| r.borrow().contains_key(&child_path)) {
continue;
}
+ if let Some(content) = crate::source_protect::read_text(&child_path) {
+ compile_module_graph(scope, &content, &child_path);
+ continue;
+ }
match fs::read_to_string(&child_path) {
Ok(content) => compile_module_graph(scope, &content, &child_path),
Err(e) => debug_output(&format!(
@@ -7524,6 +7534,11 @@ pub(crate) fn handle_update_items_source(
impl Runtime {
pub fn new(app_root: &str) -> Self {
+ // Look for a sealed app.nsbundle next to app_root before anything else touches the
+ // filesystem for JS source — every read/resolve point below consults the decrypted
+ // in-memory table first and falls back to disk when none was found.
+ crate::source_protect::init_from_app_root(app_root);
+
INIT.call_once(|| {
// --expose-gc makes gc() available as a global JS function so callers
// can trigger a full GC sweep (useful for debugging and test harnesses).
@@ -7616,15 +7631,20 @@ impl Runtime {
let referrer_path = value_to_string(scope, resource_name);
let resolved = resolve_esm_path(&spec, referrer_path.as_deref());
- match std::fs::read_to_string(&resolved) {
- Ok(content) => compile_module_graph(scope, &content, &resolved),
- Err(e) => {
- if let Some(err_str) =
- v8::String::new(scope, &format!("ESM: cannot read {resolved}: {e}"))
- {
- resolver.reject(scope, err_str.into());
+ if let Some(content) = crate::source_protect::read_text(&resolved) {
+ compile_module_graph(scope, &content, &resolved);
+ } else {
+ match std::fs::read_to_string(&resolved) {
+ Ok(content) => compile_module_graph(scope, &content, &resolved),
+ Err(e) => {
+ if let Some(err_str) = v8::String::new(
+ scope,
+ &format!("ESM: cannot read {resolved}: {e}"),
+ ) {
+ resolver.reject(scope, err_str.into());
+ }
+ return Some(resolver.get_promise(scope));
}
- return Some(resolver.get_promise(scope));
}
}
diff --git a/runtime/src/napi_engine/host_abi.rs b/runtime/src/napi_engine/host_abi.rs
index c30a213..db8475c 100644
--- a/runtime/src/napi_engine/host_abi.rs
+++ b/runtime/src/napi_engine/host_abi.rs
@@ -30,6 +30,10 @@ use crate::napi_engine::{event_loop, globals, invoke, module_natives, ns_proxy};
/// This is exactly what the standalone hosts do before running app code; the only thing left to
/// the caller is running the engine's JS prelude/polyfills (which need the engine's own eval).
pub fn initialize_runtime(env: &Env, app_root: &str) -> napi::Result<()> {
+ // Look for a sealed app.nsbundle next to app_root before anything else touches the
+ // filesystem for JS source — module_natives' __nsReadTextFile/__nsResolveModulePath below
+ // consult the decrypted in-memory table first and fall back to disk when none was found.
+ crate::source_protect::init_from_app_root(app_root);
install_panic_logging_hook();
install_native_crash_handler();
invoke::ensure_winrt_initialized();
diff --git a/runtime/src/napi_engine/module_natives.rs b/runtime/src/napi_engine/module_natives.rs
index 5e497ca..8e1a714 100644
--- a/runtime/src/napi_engine/module_natives.rs
+++ b/runtime/src/napi_engine/module_natives.rs
@@ -55,6 +55,9 @@ pub fn install_module_natives(env: &Env, app_root: &str) -> napi::Result<()> {
if path.is_empty() {
return Err(napi::Error::from_reason("__nsReadTextFile: path is empty"));
}
+ if let Some(content) = crate::source_protect::read_text(&path) {
+ return Ok(env.create_string(&content)?);
+ }
match std::fs::read_to_string(Path::new(&path)) {
Ok(content) => Ok(env.create_string(&content)?),
Err(err) => Err(napi::Error::from_reason(format!(
@@ -84,7 +87,12 @@ pub fn install_module_natives(env: &Env, app_root: &str) -> napi::Result<()> {
let parent = parent_path
.map(|v| crate::global_fns::normalize_js_path(&v))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
- let base = if parent.is_file() {
+ // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`,
+ // so also check the sealed bundle's table — otherwise a relative require() from a
+ // bundle-only file would wrongly treat the referrer as the base dir, not its parent.
+ let base = if parent.is_file()
+ || crate::source_protect::contains(&parent.to_string_lossy())
+ {
parent.parent().map(Path::to_path_buf).unwrap_or(parent)
} else {
parent
diff --git a/runtime/src/source_protect.rs b/runtime/src/source_protect.rs
new file mode 100644
index 0000000..dc2f6d5
--- /dev/null
+++ b/runtime/src/source_protect.rs
@@ -0,0 +1,483 @@
+use std::collections::HashMap;
+use std::fs;
+use std::io::{self, Write};
+use std::path::Path;
+use std::sync::OnceLock;
+
+use aes_gcm::aead::{Aead, KeyInit, Payload};
+use aes_gcm::{Aes256Gcm, Key, Nonce};
+use byteorder::{ByteOrder, LittleEndian, WriteBytesExt};
+
+const MAGIC: &[u8; 4] = b"NSB1";
+const VERSION: u8 = 1;
+const HEADER_LEN: usize = 4 + 1 + 1 + 2 + 16 + 4; // magic+version+key_mode+reserved+key_check+entry_count
+const KEY_CHECK_AAD: &[u8] = b"nsbundle-keycheck";
+
+/// `key_mode` byte: the container was sealed with the compiled-in default pepper.
+pub const KEY_MODE_DEFAULT: u8 = 0;
+/// `key_mode` byte: the container expects a custom key via [`set_custom_key_hex`]/
+/// `runtime_set_bundle_key` before it can be opened.
+pub const KEY_MODE_CUSTOM: u8 = 1;
+
+const PEPPER_A: [u8; 32] = [
+ 0x4e, 0x53, 0x42, 0x31, 0x8a, 0x2f, 0xd1, 0x77, 0x03, 0x9c, 0x5e, 0x61, 0xb4, 0x22, 0xf0, 0x18,
+ 0x6d, 0xa9, 0x3b, 0xc7, 0x14, 0x5f, 0x88, 0xe2, 0x30, 0x0b, 0x97, 0x44, 0xd6, 0x1a, 0x59, 0xc3,
+];
+const PEPPER_B: [u8; 32] = [
+ 0x71, 0xe4, 0x0d, 0x9a, 0x56, 0x2c, 0xbf, 0x08, 0xa1, 0x33, 0x7e, 0xd0, 0x49, 0x8c, 0x15, 0xf6,
+ 0x3d, 0x60, 0xab, 0x24, 0x99, 0x0e, 0x52, 0xc8, 0x7b, 0x1f, 0xde, 0x45, 0x83, 0x6a, 0xf1, 0x27,
+];
+const PEPPER_C: [u8; 32] = [
+ 0x92, 0x1b, 0xc5, 0x4a, 0x0f, 0x87, 0x3e, 0xd9, 0x66, 0xaa, 0x21, 0x5c, 0xf3, 0x08, 0x7d, 0x94,
+ 0x11, 0x4e, 0xb0, 0x8f, 0x3a, 0xc6, 0x02, 0x59, 0xdd, 0x74, 0x1c, 0xa8, 0x60, 0xef, 0x35, 0x8b,
+];
+
+fn default_pepper_key() -> [u8; 32] {
+ static KEY: OnceLock<[u8; 32]> = OnceLock::new();
+ *KEY.get_or_init(|| {
+ let mut k = [0u8; 32];
+ for i in 0..32 {
+ k[i] = PEPPER_A[i] ^ PEPPER_B[i] ^ PEPPER_C[i];
+ }
+ k
+ })
+}
+
+static CUSTOM_KEY: OnceLock<[u8; 32]> = OnceLock::new();
+/// `None` until [`init_from_app_root`] runs; empty map means "no bundle found/loaded" so every
+/// lookup falls straight back to the filesystem.
+static BUNDLE_TABLE: OnceLock>> = OnceLock::new();
+
+fn seal(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], plaintext: &[u8]) -> Vec {
+ let cipher = Aes256Gcm::new(Key::::from_slice(key));
+ cipher
+ .encrypt(Nonce::from_slice(nonce), Payload { msg: plaintext, aad })
+ .expect("aes-256-gcm encrypt should not fail for in-memory buffers")
+}
+
+fn open(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], ciphertext: &[u8]) -> Result, ()> {
+ let cipher = Aes256Gcm::new(Key::::from_slice(key));
+ cipher
+ .decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad })
+ .map_err(|_| ())
+}
+
+fn random_nonce() -> [u8; 12] {
+ let mut buf = [0u8; 12];
+ getrandom::getrandom(&mut buf).expect("OS RNG must be available to seal app.nsbundle");
+ buf
+}
+
+fn hex_val(b: u8) -> Option {
+ match b {
+ b'0'..=b'9' => Some(b - b'0'),
+ b'a'..=b'f' => Some(b - b'a' + 10),
+ b'A'..=b'F' => Some(b - b'A' + 10),
+ _ => None,
+ }
+}
+
+fn hex_decode_32(hex: &str) -> Option<[u8; 32]> {
+ let bytes = hex.as_bytes();
+ if bytes.len() != 64 {
+ return None;
+ }
+ let mut out = [0u8; 32];
+ for i in 0..32 {
+ out[i] = (hex_val(bytes[i * 2])? << 4) | hex_val(bytes[i * 2 + 1])?;
+ }
+ Some(out)
+}
+
+/// Parse a 64-hex-char (32-byte) key and store it as the custom key used to open/seal
+/// `key_mode == 1` containers. Must be called before the container is opened (i.e. before
+/// `runtime_init`/`initialize_runtime`, mirroring `runtime_set_local_folder`'s ordering
+/// requirement). Returns `false` on malformed input (wrong length or non-hex characters).
+pub fn set_custom_key_hex(hex: &str) -> bool {
+ let Some(key) = hex_decode_32(hex) else {
+ return false;
+ };
+ let _ = CUSTOM_KEY.set(key);
+ true
+}
+
+/// Case-insensitively strip everything up to and including the last `app`/`App` path segment,
+/// lowercase the remainder, and normalize separators to `/`. Every call site that builds a
+/// candidate path for module resolution already joins onto an `app`/`App` directory (e.g.
+/// `module_natives.rs`'s `app_root.join("app")`/`.join("App")`, `global_fns.rs`'s identical
+/// probe), so this recovers the packer's TOC key regardless of which absolute prefix or casing
+/// a given host used. Falls back to the whole (lowercased) path if no such segment is present.
+///
+/// Simplification: this takes the *last* matching segment, so a legitimately nested directory
+/// literally named `app`/`App` deeper inside the packed tree would be mis-stripped. Not worth
+/// guarding against for a NativeScript webpack output tree, which doesn't nest a directory named
+/// that.
+fn relativize(path_like: &str) -> String {
+ let normalized = path_like.replace('\\', "/");
+ let segments: Vec<&str> = normalized.split('/').collect();
+ let stripped = match segments.iter().rposition(|s| s.eq_ignore_ascii_case("app")) {
+ Some(pos) => segments[pos + 1..].join("/"),
+ None => normalized,
+ };
+ // Relative-specifier resolution against a referrer with no directory component (e.g. a
+ // top-level "entry.mjs" importing "./dep.mjs") can leave a leading "./" in the joined
+ // candidate; strip it so it still matches the packer's TOC key (which never has one).
+ let mut s = stripped.as_str();
+ while let Some(rest) = s.strip_prefix("./") {
+ s = rest;
+ }
+ s.to_lowercase()
+}
+
+/// Look up a JS source file by any of the path forms the four read points build (absolute,
+/// relative, either casing of `app`/`App`). `None` means "not in the loaded bundle" — callers
+/// fall back to reading the real filesystem, whether that's because no bundle was loaded at all
+/// or the specific file simply isn't packed.
+pub fn read_text(path_like: &str) -> Option {
+ let table = BUNDLE_TABLE.get()?;
+ if table.is_empty() {
+ return None;
+ }
+ table
+ .get(&relativize(path_like))
+ .and_then(|bytes| String::from_utf8(bytes.clone()).ok())
+}
+
+/// Existence probe used by the extension/index-file resolution loops (`try_resolve_with_known_extensions`
+/// in both `lib.rs` and `global_fns.rs`) alongside the existing `Path::exists()` check.
+pub fn contains(path_like: &str) -> bool {
+ match BUNDLE_TABLE.get() {
+ Some(table) if !table.is_empty() => table.contains_key(&relativize(path_like)),
+ _ => false,
+ }
+}
+
+/// Whether a non-empty bundle is currently loaded.
+pub fn has_bundle() -> bool {
+ BUNDLE_TABLE.get().is_some_and(|t| !t.is_empty())
+}
+
+/// Locate and decrypt `app.nsbundle`, if present, next to the app root the host was initialized
+/// with. `app_root` is the same string passed to `runtime_init`/`initialize_runtime` — in practice
+/// the exe's own directory (`AppContext.BaseDirectory` on the C# side; see `RuntimeHost.cs`'s
+/// `ResolveEntryScriptPath`, which checks both that directory and its parent for the `app`/`App`
+/// folder). Checked here in the same two spots:
+///
+/// - `/app.nsbundle` (sibling of `app`/`App` when they live directly under `app_root`)
+/// - `/app.nsbundle` (sibling of `bin/` when the project root is one level up)
+///
+/// No container found at either candidate: leaves the table unset, every lookup falls back to
+/// disk — fully backward compatible with today's plaintext `app/` trees. Found but fails to open
+/// (bad magic/version, wrong default-pepper key, corrupted): logs and fails closed to an empty
+/// table (same plaintext fallback). Found with `key_mode == KEY_MODE_CUSTOM` and no key was ever
+/// supplied via [`set_custom_key_hex`]: fails loud instead — the app author explicitly opted into
+/// custom-key protection, so silently falling back to plaintext-on-disk (which doesn't even exist
+/// once packed) would hide that mistake rather than surface it.
+pub fn init_from_app_root(app_root: &str) {
+ if app_root.is_empty() {
+ return;
+ }
+ let base = Path::new(app_root);
+ let mut candidates = vec![base.join("app.nsbundle")];
+ if let Some(parent) = base.parent() {
+ candidates.push(parent.join("app.nsbundle"));
+ }
+
+ for candidate in &candidates {
+ if !candidate.is_file() {
+ continue;
+ }
+ match open_and_decrypt(candidate) {
+ Ok(table) => {
+ let _ = BUNDLE_TABLE.set(table);
+ }
+ Err(err) => {
+ eprintln!(
+ "[NativeScript] app.nsbundle at {} failed to load: {err}",
+ candidate.display()
+ );
+ let _ = BUNDLE_TABLE.set(HashMap::new());
+ }
+ }
+ return;
+ }
+}
+
+fn open_and_decrypt(path: &Path) -> Result>, String> {
+ let data = fs::read(path).map_err(|e| format!("read failed: {e}"))?;
+ if data.len() < HEADER_LEN || &data[0..4] != MAGIC {
+ return Err("not an nsbundle container (bad magic)".to_string());
+ }
+ let version = data[4];
+ if version != VERSION {
+ return Err(format!("unsupported nsbundle version {version}"));
+ }
+ let key_mode = data[5];
+ let key_check = &data[8..24];
+ let entry_count = LittleEndian::read_u32(&data[24..28]) as usize;
+
+ let key = match key_mode {
+ KEY_MODE_DEFAULT => default_pepper_key(),
+ KEY_MODE_CUSTOM => *CUSTOM_KEY.get().ok_or_else(|| {
+ "requires runtime_set_bundle_key(); none was set before the bundle was opened"
+ .to_string()
+ })?,
+ other => return Err(format!("unknown key_mode {other}")),
+ };
+
+ if open(&key, &[0u8; 12], KEY_CHECK_AAD, key_check).is_err() {
+ return Err("key mismatch (wrong key for this bundle)".to_string());
+ }
+
+ let mut cursor = HEADER_LEN;
+ struct TocEntry {
+ path: String,
+ blob_offset: u64,
+ blob_len: u64,
+ nonce: [u8; 12],
+ }
+ let mut toc = Vec::with_capacity(entry_count);
+ for _ in 0..entry_count {
+ if cursor + 2 > data.len() {
+ return Err("truncated TOC".to_string());
+ }
+ let path_len = LittleEndian::read_u16(&data[cursor..cursor + 2]) as usize;
+ cursor += 2;
+ if cursor + path_len > data.len() {
+ return Err("truncated TOC path".to_string());
+ }
+ let path = String::from_utf8(data[cursor..cursor + path_len].to_vec())
+ .map_err(|_| "invalid UTF-8 path in TOC".to_string())?;
+ cursor += path_len;
+ if cursor + 8 + 8 + 12 > data.len() {
+ return Err("truncated TOC entry".to_string());
+ }
+ let blob_offset = LittleEndian::read_u64(&data[cursor..cursor + 8]);
+ cursor += 8;
+ let blob_len = LittleEndian::read_u64(&data[cursor..cursor + 8]);
+ cursor += 8;
+ let mut nonce = [0u8; 12];
+ nonce.copy_from_slice(&data[cursor..cursor + 12]);
+ cursor += 12;
+ toc.push(TocEntry { path, blob_offset, blob_len, nonce });
+ }
+
+ let blob_section_start = cursor;
+ let mut table = HashMap::with_capacity(toc.len());
+ for entry in toc {
+ let start = blob_section_start + entry.blob_offset as usize;
+ let end = start + entry.blob_len as usize;
+ if end > data.len() {
+ return Err(format!("blob out of range for {}", entry.path));
+ }
+ let plaintext = open(&key, &entry.nonce, entry.path.as_bytes(), &data[start..end])
+ .map_err(|_| format!("failed to decrypt {} (tampered container or wrong key)", entry.path))?;
+ table.insert(entry.path.to_lowercase(), plaintext);
+ }
+ Ok(table)
+}
+
+fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(String, Vec)>) -> io::Result<()> {
+ for entry in fs::read_dir(dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ if path.is_dir() {
+ collect_files(root, &path, out)?;
+ } else if path.is_file() {
+ let rel = path
+ .strip_prefix(root)
+ .unwrap_or(&path)
+ .to_string_lossy()
+ .replace('\\', "/");
+ let bytes = fs::read(&path)?;
+ out.push((rel, bytes));
+ }
+ }
+ Ok(())
+}
+
+/// Pack every file under `input` (recursively) into a sealed `app.nsbundle` at `output`, encrypting
+/// each with `key` (AES-256-GCM, one blob per file, path bytes as AAD). `key_mode` is recorded
+/// verbatim in the header so the runtime knows whether to use the default pepper
+/// ([`KEY_MODE_DEFAULT`]) or require a custom key via `runtime_set_bundle_key`
+/// ([`KEY_MODE_CUSTOM`]) — the caller (the `nsbundle_pack` CLI) is responsible for using the same
+/// key value the app will supply at runtime when `key_mode == KEY_MODE_CUSTOM`.
+pub fn pack_directory(input: &Path, output: &Path, key_mode: u8, key: [u8; 32]) -> io::Result<()> {
+ let mut files = Vec::new();
+ collect_files(input, input, &mut files)?;
+ files.sort_by(|a, b| a.0.cmp(&b.0));
+
+ let key_check = seal(&key, &[0u8; 12], KEY_CHECK_AAD, &[]);
+ debug_assert_eq!(key_check.len(), 16, "empty-plaintext AES-GCM output must be tag-only");
+
+ let mut entries_buf = Vec::new();
+ let mut blobs = Vec::with_capacity(files.len());
+ let mut blob_offset: u64 = 0;
+ for (path, bytes) in &files {
+ let nonce = random_nonce();
+ let ciphertext = seal(&key, &nonce, path.as_bytes(), bytes);
+ entries_buf.write_u16::(path.len() as u16)?;
+ entries_buf.extend_from_slice(path.as_bytes());
+ entries_buf.write_u64::(blob_offset)?;
+ entries_buf.write_u64::(ciphertext.len() as u64)?;
+ entries_buf.extend_from_slice(&nonce);
+ blob_offset += ciphertext.len() as u64;
+ blobs.push(ciphertext);
+ }
+
+ let mut out = Vec::with_capacity(HEADER_LEN + entries_buf.len() + blob_offset as usize);
+ out.extend_from_slice(MAGIC);
+ out.push(VERSION);
+ out.push(key_mode);
+ out.extend_from_slice(&[0u8; 2]);
+ out.extend_from_slice(&key_check);
+ out.write_u32::(files.len() as u32)?;
+ out.extend_from_slice(&entries_buf);
+ for blob in blobs {
+ out.extend_from_slice(&blob);
+ }
+
+ fs::write(output, out)
+}
+
+/// The compiled-in default key, exposed so `nsbundle_pack` can seal `key_mode == KEY_MODE_DEFAULT`
+/// containers without duplicating the pepper constants.
+pub fn default_key() -> [u8; 32] {
+ default_pepper_key()
+}
+
+/// Parse a 64-hex-char string into a 32-byte key (used by `nsbundle_pack`'s `--key-hex` flag).
+pub fn parse_key_hex(hex: &str) -> Option<[u8; 32]> {
+ hex_decode_32(hex)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
+
+ fn scratch_dir(name: &str) -> std::path::PathBuf {
+ let n = COUNTER.fetch_add(1, Ordering::Relaxed);
+ let dir = std::env::temp_dir().join(format!("nsbundle_test_{name}_{}_{n}", std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ #[test]
+ fn pack_and_open_round_trip() {
+ let input = scratch_dir("roundtrip_in");
+ fs::write(input.join("bundle.js"), b"console.log('hi')").unwrap();
+ fs::create_dir_all(input.join("sub")).unwrap();
+ fs::write(input.join("sub").join("mod.js"), b"module.exports = 1;").unwrap();
+
+ let output = scratch_dir("roundtrip_out").join("app.nsbundle");
+ // key_mode 0 always opens with the real compiled-in pepper, regardless of what key
+ // pack_directory was called with — so the round trip must use that same pepper to open
+ // successfully. (Custom-key round trips are covered indirectly by tampered_ciphertext_fails,
+ // which packs KEY_MODE_CUSTOM and opens with the exact key it packed with.)
+ let key = default_key();
+ pack_directory(&input, &output, KEY_MODE_DEFAULT, key).unwrap();
+
+ let table = open_and_decrypt(&output).unwrap();
+ assert_eq!(
+ table.get("bundle.js").map(|v| v.as_slice()),
+ Some(b"console.log('hi')".as_slice())
+ );
+ assert_eq!(
+ table.get("sub/mod.js").map(|v| v.as_slice()),
+ Some(b"module.exports = 1;".as_slice())
+ );
+ }
+
+ #[test]
+ fn wrong_key_fails_key_check() {
+ let input = scratch_dir("wrongkey_in");
+ fs::write(input.join("a.js"), b"a").unwrap();
+ let output = scratch_dir("wrongkey_out").join("app.nsbundle");
+ pack_directory(&input, &output, KEY_MODE_DEFAULT, [1u8; 32]).unwrap();
+
+ // open_and_decrypt always resolves key_mode 0 to the real default pepper, so instead
+ // exercise the primitive directly: the key used to check must match the key used to seal.
+ let data = fs::read(&output).unwrap();
+ let key_check = &data[8..24];
+ assert!(open(&[2u8; 32], &[0u8; 12], KEY_CHECK_AAD, key_check).is_err());
+ assert!(open(&[1u8; 32], &[0u8; 12], KEY_CHECK_AAD, key_check).is_ok());
+ }
+
+ #[test]
+ fn tampered_ciphertext_fails() {
+ let input = scratch_dir("tamper_in");
+ fs::write(input.join("a.js"), b"original content").unwrap();
+ let output_dir = scratch_dir("tamper_out");
+ let output = output_dir.join("app.nsbundle");
+ let key = [9u8; 32];
+ pack_directory(&input, &output, KEY_MODE_CUSTOM, key).unwrap();
+
+ let mut data = fs::read(&output).unwrap();
+ let last = data.len() - 1;
+ data[last] ^= 0xff; // flip a byte inside the last blob's GCM tag/ciphertext
+ fs::write(&output, &data).unwrap();
+
+ // Manually walk the header since open_and_decrypt() only knows the default/custom keys
+ // via the global statics, not an arbitrary caller-supplied key.
+ let entry_count = LittleEndian::read_u32(&data[24..28]) as usize;
+ assert_eq!(entry_count, 1);
+ let path_len = LittleEndian::read_u16(&data[HEADER_LEN..HEADER_LEN + 2]) as usize;
+ let mut cursor = HEADER_LEN + 2 + path_len;
+ let blob_offset = LittleEndian::read_u64(&data[cursor..cursor + 8]);
+ cursor += 8;
+ let blob_len = LittleEndian::read_u64(&data[cursor..cursor + 8]);
+ cursor += 8;
+ let mut nonce = [0u8; 12];
+ nonce.copy_from_slice(&data[cursor..cursor + 12]);
+ cursor += 12;
+ let blob_start = cursor + blob_offset as usize;
+ let blob_end = blob_start + blob_len as usize;
+ assert!(open(&key, &nonce, b"a.js", &data[blob_start..blob_end]).is_err());
+ }
+
+ #[test]
+ fn aad_binds_ciphertext_to_its_path() {
+ let key = [3u8; 32];
+ let nonce = [0u8; 12];
+ let ciphertext = seal(&key, &nonce, b"a.js", b"secret contents");
+ // Same key/nonce/ciphertext, wrong AAD (as if the TOC had been spliced to claim this
+ // blob belongs to a different file) must fail rather than decrypt.
+ assert!(open(&key, &nonce, b"b.js", &ciphertext).is_err());
+ assert!(open(&key, &nonce, b"a.js", &ciphertext).is_ok());
+ }
+
+ #[test]
+ fn relativize_strips_app_segment_case_insensitively() {
+ assert_eq!(relativize("C:\\proj\\bin\\App\\sub\\file.js"), "sub/file.js");
+ assert_eq!(relativize("C:/proj/bin/app/file.js"), "file.js");
+ assert_eq!(relativize("sub/file.js"), "sub/file.js");
+ assert_eq!(relativize("C:\\no\\app\\segment\\HERE\\file.js"), "segment/here/file.js");
+ }
+
+ #[test]
+ fn relativize_strips_leading_current_dir_prefix() {
+ // Left over from joining a relative specifier ("./dep.mjs") against an empty base when
+ // the referrer has no directory component of its own (e.g. a top-level "entry.mjs").
+ assert_eq!(relativize("./dep.mjs"), "dep.mjs");
+ assert_eq!(relativize(".\\dep.mjs"), "dep.mjs");
+ }
+
+ #[test]
+ fn hex_key_round_trips() {
+ // Exactly 64 hex chars (32 bytes): "00112233445566778899aabbccddeeff" (16 bytes) twice.
+ let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
+ assert_eq!(hex.len(), 64);
+ let key = parse_key_hex(hex).unwrap();
+ assert_eq!(key[0], 0x00);
+ assert_eq!(key[1], 0x11);
+ assert_eq!(key[15], 0xff);
+ assert_eq!(key[31], 0xff);
+ assert!(parse_key_hex("too-short").is_none());
+ assert!(parse_key_hex(&"zz".repeat(32)).is_none());
+ assert!(parse_key_hex(&format!("{hex}ff")).is_none()); // 65 hex chars: one byte too long
+ }
+}
diff --git a/sbg_output/sbg_metadata.json b/sbg_output/sbg_metadata.json
new file mode 100644
index 0000000..494252f
--- /dev/null
+++ b/sbg_output/sbg_metadata.json
@@ -0,0 +1,55 @@
+[
+ {
+ "typeName": "TemplateNamedJsonObject",
+ "className": "TemplateNamedJsonObject",
+ "namespace": null,
+ "baseClass": "Windows.Data.Json.JsonObject",
+ "methods": [
+ {
+ "name": "ToString",
+ "returnType": "String",
+ "parameters": []
+ }
+ ],
+ "properties": [],
+ "interfaces": [],
+ "isAutoGeneratedName": false,
+ "registeredAt": "2026-08-05T12:06:04.925Z"
+ },
+ {
+ "typeName": "com.tns.gen.winrt.windows.data.json.JsonObject_AutoProxy_2",
+ "className": "JsonObject_AutoProxy_2",
+ "namespace": "com.tns.gen.winrt.windows.data.json",
+ "baseClass": "Windows.Data.Json.JsonObject",
+ "methods": [
+ {
+ "name": "ToString",
+ "returnType": "String",
+ "parameters": []
+ }
+ ],
+ "properties": [],
+ "interfaces": [],
+ "isAutoGeneratedName": false,
+ "registeredAt": "2026-08-05T12:06:04.959Z"
+ },
+ {
+ "typeName": "com.tns.gen.winrt.windows.Object_AutoProxy_3",
+ "className": "Object_AutoProxy_3",
+ "namespace": "com.tns.gen.winrt.windows",
+ "baseClass": "Object",
+ "methods": [
+ {
+ "name": "ToString",
+ "returnType": "String",
+ "parameters": []
+ }
+ ],
+ "properties": [],
+ "interfaces": [
+ "Windows.Foundation.IStringable"
+ ],
+ "isAutoGeneratedName": false,
+ "registeredAt": "2026-08-05T12:06:04.977Z"
+ }
+]
\ No newline at end of file
diff --git a/template/build.ps1 b/template/build.ps1
index 2c5bd64..5900012 100644
--- a/template/build.ps1
+++ b/template/build.ps1
@@ -289,6 +289,45 @@ if (Test-Path $x64Path) {
Write-Host " copied dotnet-tool-x64.exe -> dotnet-tool.exe"
}
+# nsbundle_pack prebuilt binaries (source-protection packer — seals an app's webpack output
+# directory into an encrypted app.nsbundle. It's a [[bin]]
+# target inside the `runtime` crate rather than its own package, unlike dotnet-tool, but is
+# otherwise staged identically so nativescript-cli can resolve/invoke it the same way.
+Write-Host "`n=== Build nsbundle_pack prebuilt binaries ===" -ForegroundColor Cyan
+if (-not (Test-Path $ToolsDir)) { New-Item -ItemType Directory -Force -Path $ToolsDir | Out-Null }
+
+foreach ($t in $Targets) {
+ $arch = $t.Arch
+ $rustTarget = $t.RustTarget
+ Write-Host "Building nsbundle_pack for $arch ($rustTarget)..."
+ Push-Location $RepoRoot
+ try {
+ & cargo build -p runtime --bin nsbundle_pack --release --target $rustTarget
+ $buildExit = $LASTEXITCODE
+ } finally {
+ Pop-Location
+ }
+ if ($buildExit -ne 0) {
+ Write-Host "cargo build for nsbundle_pack failed for target $rustTarget (exit $buildExit). Skipping copy for $arch." -ForegroundColor Yellow
+ continue
+ }
+ $candidate = Join-Path $RepoRoot "target\$rustTarget\release\nsbundle_pack.exe"
+ if (Test-Path $candidate) {
+ $dest = Join-Path $ToolsDir "nsbundle_pack-$arch.exe"
+ Copy-Item -Force $candidate $dest
+ Write-Host " copied nsbundle_pack -> $(Resolve-Path $dest -Relative)"
+ } else {
+ Write-Host "Expected build output not found: $candidate" -ForegroundColor Yellow
+ }
+}
+
+# Provide a generic `nsbundle_pack.exe` fallback (copy x64 if available)
+$nsbundlePackX64Path = Join-Path $ToolsDir "nsbundle_pack-x64.exe"
+if (Test-Path $nsbundlePackX64Path) {
+ Copy-Item -Force $nsbundlePackX64Path (Join-Path $ToolsDir "nsbundle_pack.exe")
+ Write-Host " copied nsbundle_pack-x64.exe -> nsbundle_pack.exe"
+}
+
# sbg prebuilt binaries
Write-Host "`n=== Build sbg prebuilt binaries ===" -ForegroundColor Cyan
diff --git a/template/framework/__PROJECT_NAME__/RuntimeHost.cs b/template/framework/__PROJECT_NAME__/RuntimeHost.cs
index cb73376..92b2e0e 100644
--- a/template/framework/__PROJECT_NAME__/RuntimeHost.cs
+++ b/template/framework/__PROJECT_NAME__/RuntimeHost.cs
@@ -32,6 +32,20 @@ internal sealed class RuntimeHost : IDisposable
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_local_folder))]
private static extern void runtime_set_local_folder([MarshalAs(UnmanagedType.LPUTF8Str)] string localFolder);
+ // Escape hatch for apps packed with `nsbundle_pack --key-hex ` (custom-key
+ // app.nsbundle containers). Must be called before runtime_init if used at all — apps
+ // packed with the default pepper (no --key-hex) never need to call this. Not wired to a
+ // default call site here; an app author supplies their own key material and calls this
+ // from Initialize() before runtime_init(...).
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_set_bundle_key))]
+ private static extern int runtime_set_bundle_key([MarshalAs(UnmanagedType.LPUTF8Str)] string keyHex);
+
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_read_protected_file))]
+ private static extern IntPtr runtime_read_protected_file([MarshalAs(UnmanagedType.LPUTF8Str)] string virtualPath);
+
+ [DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_free_protected_string))]
+ private static extern void runtime_free_protected_string(IntPtr ptr);
+
[DllImport(NativeScriptLibrary, EntryPoint = nameof(runtime_get_last_js_error))]
private static extern IntPtr runtime_get_last_js_error();
@@ -191,6 +205,27 @@ private static bool ConsumeDebugBreakMarker()
}
#endif
+ /// Reads a file that may live inside a sealed app.nsbundle instead of on disk: tries the
+ /// protected-VFS native call first (cheap no-op when no bundle is loaded), falls back to
+ /// the real filesystem. `path` is whatever candidate the caller already built for
+ /// `File.Exists`/`File.ReadAllText` — the native side strips it down to the bundle's
+ /// virtual (app/App-relative) path itself, so no extra bookkeeping is needed here.
+ private static string TryReadVirtual(string path)
+ {
+ IntPtr ptr = IntPtr.Zero;
+ try
+ {
+ ptr = runtime_read_protected_file(path);
+ return ptr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(ptr);
+ }
+ catch { return null; }
+ finally { if (ptr != IntPtr.Zero) runtime_free_protected_string(ptr); }
+ }
+
+ private static bool VExists(string path) => TryReadVirtual(path) != null || File.Exists(path);
+
+ private static string VReadAllText(string path) => TryReadVirtual(path) ?? File.ReadAllText(path);
+
public void RunMainScript()
{
if (!_initialized)
@@ -208,7 +243,7 @@ public void RunMainScript()
foreach (var chunkName in new[] { "runtime.js", "vendor.js" })
{
var chunkPath = Path.Combine(dir, chunkName);
- if (File.Exists(chunkPath) &&
+ if (VExists(chunkPath) &&
!string.Equals(chunkPath, Path.GetFullPath(entryPath), StringComparison.OrdinalIgnoreCase))
{
chunks.Add(chunkPath);
@@ -218,7 +253,7 @@ public void RunMainScript()
foreach (var scriptPath in chunks)
{
- var script = File.ReadAllText(Path.GetFullPath(scriptPath));
+ var script = VReadAllText(Path.GetFullPath(scriptPath));
try
{
runtime_runscript(_runtime, script, Path.GetFileName(scriptPath));
@@ -260,7 +295,7 @@ private static string ResolveEntryScriptPath()
foreach (var dir in appDirCandidates)
{
var candidate = Path.Combine(dir, "package.json");
- if (File.Exists(candidate))
+ if (VExists(candidate))
{
packageJsonPath = candidate;
resolvedBaseDir = dir;
@@ -269,7 +304,7 @@ private static string ResolveEntryScriptPath()
}
// Also accept package.json at the project root (parent of bin/).
- if (packageJsonPath == null && File.Exists(Path.Combine(parentDir, "package.json")))
+ if (packageJsonPath == null && VExists(Path.Combine(parentDir, "package.json")))
{
packageJsonPath = Path.Combine(parentDir, "package.json");
resolvedBaseDir = parentDir;
@@ -278,7 +313,7 @@ private static string ResolveEntryScriptPath()
string Fallback() =>
appDirCandidates
.SelectMany(d => new[] { Path.Combine(d, "bundle.js"), Path.Combine(d, "bundle.mjs") })
- .FirstOrDefault(File.Exists);
+ .FirstOrDefault(VExists);
if (packageJsonPath == null)
return Fallback();
@@ -304,7 +339,7 @@ string Fallback() =>
private static RuntimePackageConfig ParsePackageConfig(string packageJsonPath)
{
- using var doc = JsonDocument.Parse(File.ReadAllText(packageJsonPath));
+ using var doc = JsonDocument.Parse(VReadAllText(packageJsonPath));
var config = new RuntimePackageConfig();
if (doc.RootElement.TryGetProperty("main", out var main) && main.ValueKind == JsonValueKind.String)
config.Main = main.GetString();
@@ -321,11 +356,11 @@ private static string ResolveScriptPath(string baseDir, string scriptPath)
foreach (var candidate in new[] { normalized, normalized + ".js", normalized + ".mjs" })
{
var direct = Path.IsPathRooted(candidate) ? candidate : Path.Combine(baseDir, candidate);
- if (File.Exists(direct)) return direct;
+ if (VExists(direct)) return direct;
var appLower = Path.Combine(baseDir, "app", candidate);
- if (File.Exists(appLower)) return appLower;
+ if (VExists(appLower)) return appLower;
var appUpper = Path.Combine(baseDir, "App", candidate);
- if (File.Exists(appUpper)) return appUpper;
+ if (VExists(appUpper)) return appUpper;
}
return null;
}
diff --git a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj
index 7b04187..f6b4353 100644
--- a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj
+++ b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj
@@ -87,7 +87,12 @@
-
+
+
+ PreserveNewest
+
+
PreserveNewest
diff --git a/template/framework/tools/ManifestMerger/ManifestMerger.dll b/template/framework/tools/ManifestMerger/ManifestMerger.dll
index ddfb2c4..f8e030d 100644
Binary files a/template/framework/tools/ManifestMerger/ManifestMerger.dll and b/template/framework/tools/ManifestMerger/ManifestMerger.dll differ
diff --git a/template/framework/tools/ManifestMerger/ManifestMerger.pdb b/template/framework/tools/ManifestMerger/ManifestMerger.pdb
index fcded83..7a10f68 100644
Binary files a/template/framework/tools/ManifestMerger/ManifestMerger.pdb and b/template/framework/tools/ManifestMerger/ManifestMerger.pdb differ
diff --git a/template/framework/tools/dotnet-tool-x64.exe b/template/framework/tools/dotnet-tool-x64.exe
index 33fa49e..a769122 100644
Binary files a/template/framework/tools/dotnet-tool-x64.exe and b/template/framework/tools/dotnet-tool-x64.exe differ
diff --git a/template/framework/tools/dotnet-tool.exe b/template/framework/tools/dotnet-tool.exe
index 33fa49e..a769122 100644
Binary files a/template/framework/tools/dotnet-tool.exe and b/template/framework/tools/dotnet-tool.exe differ
diff --git a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll
index d438b26..ba194b7 100644
Binary files a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll and b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.dll differ
diff --git a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe
index 86d0fd8..a16a9c2 100644
Binary files a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe and b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.exe differ
diff --git a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb
index 0ea976a..ec74d14 100644
Binary files a/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb and b/template/framework/tools/dotnet-typings-gen/dotnet-typings-gen.pdb differ
diff --git a/template/framework/tools/nsbundle_pack-x64.exe b/template/framework/tools/nsbundle_pack-x64.exe
new file mode 100644
index 0000000..f799ab2
Binary files /dev/null and b/template/framework/tools/nsbundle_pack-x64.exe differ
diff --git a/template/framework/tools/nsbundle_pack.exe b/template/framework/tools/nsbundle_pack.exe
new file mode 100644
index 0000000..f799ab2
Binary files /dev/null and b/template/framework/tools/nsbundle_pack.exe differ
diff --git a/template/framework/tools/sbg-x64.exe b/template/framework/tools/sbg-x64.exe
index 4f31250..f904708 100644
Binary files a/template/framework/tools/sbg-x64.exe and b/template/framework/tools/sbg-x64.exe differ
diff --git a/template/framework/tools/sbg.exe b/template/framework/tools/sbg.exe
index 4f31250..f904708 100644
Binary files a/template/framework/tools/sbg.exe and b/template/framework/tools/sbg.exe differ
diff --git a/template/framework/tools/typings-generator-x64.exe b/template/framework/tools/typings-generator-x64.exe
index e674d73..97ac0ad 100644
Binary files a/template/framework/tools/typings-generator-x64.exe and b/template/framework/tools/typings-generator-x64.exe differ
diff --git a/template/framework/tools/typings-generator.exe b/template/framework/tools/typings-generator.exe
index e674d73..97ac0ad 100644
Binary files a/template/framework/tools/typings-generator.exe and b/template/framework/tools/typings-generator.exe differ