Skip to content

Commit c80a64b

Browse files
committed
feat(windows): encrypt bundle
1 parent 2053117 commit c80a64b

5 files changed

Lines changed: 94 additions & 2 deletions

File tree

lib/common/mobile/windows/windows-application-manager.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,42 @@ export class WindowsApplicationManager extends ApplicationManagerBase {
9696
this.$logger.warn(`[Windows] Pre-install uninstall failed: ${err}`);
9797
}
9898
}
99+
// Two distinct artifact shapes reach here (see WindowsProjectService's
100+
// getValidBuildOutputData): a debug build resolves to the loose bin/ output's
101+
// AppxManifest.xml, meant for `-Register` (in-place dev registration — no signature
102+
// validation, matches "AppxManifest.xml triggers Add-AppxPackage -Register (dev flow)");
103+
// a release build resolves to the packaged .msix/.msixupload under AppPackages/, which
104+
// `-Register` rejects outright ("An invalid manifest file name was passed to this
105+
// function") and which instead needs a plain package install via `-Path` — exactly what
106+
// the generated Install.ps1/Add-AppDevPackage.ps1 next to it do. A `-Path` install of an
107+
// unsigned release package still fails signature validation (0x800B0100); that's expected
108+
// — pass `--certificate`/`--certificate-thumbprint` at build time for a sideloadable
109+
// release package, this isn't something the install step can paper over.
110+
const isLooseManifest = packageFilePath.toLowerCase().endsWith("appxmanifest.xml");
111+
let addAppxCommand: string;
112+
if (isLooseManifest) {
113+
addAppxCommand = `Add-AppxPackage -ForceApplicationShutdown -Register -Path "${packageFilePath}"`;
114+
} else {
115+
// Thread through any Dependencies\<arch>\*.msix (e.g. the Windows App SDK runtime
116+
// framework package) the same way Add-AppDevPackage.ps1 does via `-DependencyPath` —
117+
// needed on a machine that doesn't already have that framework package installed.
118+
const arch = process.arch === "arm64" ? "arm64" : "x64";
119+
const dependencyDir = path.join(path.dirname(packageFilePath), "Dependencies", arch);
120+
const dependencyGlob = path.join(dependencyDir, "*.msix");
121+
addAppxCommand = fs.existsSync(dependencyDir)
122+
? `$deps = Get-ChildItem -Path "${dependencyGlob}" -ErrorAction SilentlyContinue; ` +
123+
`if ($deps) { Add-AppxPackage -Path "${packageFilePath}" -DependencyPath $deps.FullName -ForceApplicationShutdown } ` +
124+
`else { Add-AppxPackage -Path "${packageFilePath}" -ForceApplicationShutdown }`
125+
: `Add-AppxPackage -Path "${packageFilePath}" -ForceApplicationShutdown`;
126+
}
99127
await this.$childProcess.spawnFromEvent(
100128
"powershell.exe",
101129
[
102130
"-NoProfile",
103131
"-ExecutionPolicy",
104132
"Bypass",
105133
"-Command",
106-
`Add-AppxPackage -ForceApplicationShutdown -Register -Path "${packageFilePath}"`,
134+
addAppxCommand,
107135
],
108136
"close",
109137
{},

lib/data/build-data.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export class WindowsBuildData extends BuildData {
5252
public storeUpload: boolean;
5353
public msixBundle: boolean;
5454
public architectures: string[];
55+
public sourceProtect: boolean;
56+
public sourceProtectKeyHex: string;
5557

5658
constructor(projectDir: string, platform: string, data: any) {
5759
super(projectDir, platform, data);
@@ -64,6 +66,8 @@ export class WindowsBuildData extends BuildData {
6466
this.architectures = data.arch
6567
? [data.arch]
6668
: data.architectures ?? ["x64"];
69+
this.sourceProtect = data.sourceProtect;
70+
this.sourceProtectKeyHex = data.sourceProtectKeyHex;
6771
}
6872
}
6973

lib/definitions/project.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,14 @@ interface INsConfigIOS extends INsConfigPlaform {
134134

135135
interface INSConfigVisionOS extends INsConfigIOS {}
136136

137-
interface INsConfigWindows extends INsConfigPlaform {}
137+
interface INsConfigWindows extends INsConfigPlaform {
138+
/**
139+
* Seal the app's webpack output into an encrypted app.nsbundle instead of shipping it as
140+
* plaintext. Only takes effect on release builds; overridable per-invocation with
141+
* --source-protect / --no-source-protect.
142+
*/
143+
sourceProtect?: boolean;
144+
}
138145

139146
interface INsConfigAndroid extends INsConfigPlaform {
140147
v8Flags?: string;

lib/options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ export class Options {
210210
},
211211
storeUpload: { type: OptionType.Boolean, hasSensitiveValue: false },
212212
msixbundle: { type: OptionType.Boolean, hasSensitiveValue: false },
213+
sourceProtect: { type: OptionType.Boolean, hasSensitiveValue: false },
214+
sourceProtectKeyHex: { type: OptionType.String, hasSensitiveValue: true },
213215
arch: { type: OptionType.String, hasSensitiveValue: false },
214216
release: {
215217
type: OptionType.Boolean,

lib/services/windows-project-service.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,57 @@ export class WindowsProjectService
562562
catch (err) {
563563
this.$logger.warn(`dotnet-tool check failed: ${err}`);
564564
}
565+
566+
// Source protection: seal the just-written webpack output (app/) into an encrypted
567+
// app.nsbundle via nsbundle_pack.
568+
// Release builds only — HMR/LiveSync dev builds patch app/ incrementally, which a sealed
569+
// container can't do, and --release/--hmr are already mutually exclusive CLI flags.
570+
// Never fatal: a missing tool or failed pack just leaves the plaintext app/ folder, which
571+
// the .csproj already falls back to via Condition="Exists('app.nsbundle')".
572+
try {
573+
const isRelease = !!(_prepareData as any)?.release;
574+
const wantsSourceProtect =
575+
(this.$options as any).sourceProtect ??
576+
projectData?.nsConfig?.windows?.sourceProtect ??
577+
false;
578+
if (isRelease && wantsSourceProtect) {
579+
const arch = process.arch === "arm64" ? "arm64" : "x64";
580+
const packCandidates = [
581+
process.env.NSBUNDLE_PACK_PATH,
582+
path.join(platformData.projectRoot, "tools", `nsbundle_pack-${arch}.exe`),
583+
path.join(platformData.projectRoot, "tools", "nsbundle_pack.exe"),
584+
].filter(Boolean as any);
585+
let packExe: string | null = null;
586+
for (const p of packCandidates) {
587+
if (p && fs.existsSync(p)) { packExe = p as string; break; }
588+
}
589+
if (packExe) {
590+
const keyHex =
591+
(this.$options as any).sourceProtectKeyHex ||
592+
process.env.NS_WINDOWS_BUNDLE_KEY;
593+
const args = [
594+
"--input", path.join(appProjectDir, "app"),
595+
"--output", path.join(appProjectDir, "app.nsbundle"),
596+
];
597+
if (keyHex) { args.push("--key-hex", keyHex); }
598+
this.$logger.info(`Running nsbundle_pack (source protection): ${packExe}`);
599+
try {
600+
const result = await this.$childProcess.spawnFromEvent(packExe, args, "close", { cwd: platformData.projectRoot }, { throwError: false });
601+
if (result && result.stdout) { this.$logger.info(result.stdout); }
602+
}
603+
catch (err) {
604+
this.$logger.warn(`nsbundle_pack execution failed: ${err}`);
605+
}
606+
}
607+
else {
608+
this.$logger.info("Source protection requested but nsbundle_pack.exe was not found under tools/ — skipping (plaintext app/ will be packaged).");
609+
}
610+
}
611+
}
612+
catch (err) {
613+
this.$logger.warn(`Source protection check failed: ${err}`);
614+
}
615+
565616
const pluginsDir = path.join(appProjectDir, "plugins");
566617

567618
// Ensure plugins directory exists inside the platform app folder (where csproj expects it)

0 commit comments

Comments
 (0)