moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* apply-signing-config.mjs — write the captured Windows code-signing thumbprint
* into app/src-tauri/tauri.build-override.conf.json.
*
* Read by `npm run tauri:sign:pilot:win:apply`. Idempotent: re-running with the
* same thumbprint produces an identical file. Updating the cert (rotation) is
* handled by re-running the upstream cert-gen script + this CLI.
*
* Pure logic lives in `signing-config.ts`; this is the thin file-I/O wrapper.
*
* Reference: docs/code-signing-pilot-and-launch.md §1.1
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// The pure helpers below mirror app/scripts/signing-config.ts so this CLI has
// zero TS-loader dependency at runtime. The .ts version is the canonical
// implementation tested by signing-config.test.ts (19 cases covering parse,
// merge, idempotency, immutability). Keep the two implementations in lockstep:
// any change to parseThumbprintString or addWindowsSigningToOverride below
// MUST be mirrored in signing-config.ts and vice versa.
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_DIR = resolve(SCRIPT_DIR, '..');
const REPO_ROOT = resolve(APP_DIR, '..');
const OVERRIDE_PATH = resolve(
APP_DIR,
'src-tauri',
'tauri.build-override.conf.json',
);
const THUMBPRINT_PATH = resolve(APP_DIR, 'src-tauri', '.thumbprint.txt');
const DEFAULT_DIGEST_ALGORITHM = 'sha256';
const DEFAULT_TIMESTAMP_URL = 'http://timestamp.digicert.com';
const THUMBPRINT_LENGTH = 40;
const HEX_PATTERN = /^[0-9A-F]+$/;
function parseThumbprintString(raw) {
if (!raw || raw.trim().length === 0) {
throw new Error('Thumbprint is empty — cert generation may have failed.');
}
const compact = raw.replace(/\s+/g, '').toUpperCase();
if (compact.length !== THUMBPRINT_LENGTH || !HEX_PATTERN.test(compact)) {
throw new Error(
`Thumbprint must be 40 hex characters; got ${compact.length} chars (sample: "${compact.slice(0, 16)}...").`,
);
}
return compact;
}
function addWindowsSigningToOverride(config, thumbprint, options = {}) {
const normalisedThumbprint = parseThumbprintString(thumbprint);
const digestAlgorithm = options.digestAlgorithm ?? DEFAULT_DIGEST_ALGORITHM;
const timestampUrl = options.timestampUrl ?? DEFAULT_TIMESTAMP_URL;
const existingBundle = config.bundle ?? {};
const existingWindows = existingBundle.windows ?? {};
return {
...config,
bundle: {
...existingBundle,
windows: {
...existingWindows,
certificateThumbprint: normalisedThumbprint,
digestAlgorithm,
timestampUrl,
},
},
};
}
// ─── Main ───────────────────────────────────────────────────────────────────
function main() {
if (!existsSync(THUMBPRINT_PATH)) {
console.error(
`[apply-signing-config] thumbprint file missing: ${THUMBPRINT_PATH}`,
);
console.error(
'[apply-signing-config] Run `npm run tauri:sign:pilot:win:setup` (or app/scripts/sign-windows-pilot.ps1 -Mode Setup) first.',
);
process.exit(1);
}
if (!existsSync(OVERRIDE_PATH)) {
console.error(
`[apply-signing-config] override config missing: ${OVERRIDE_PATH}`,
);
process.exit(1);
}
const rawThumbprint = readFileSync(THUMBPRINT_PATH, 'utf8');
const overrideRaw = readFileSync(OVERRIDE_PATH, 'utf8');
let override;
try {
override = JSON.parse(overrideRaw);
} catch (err) {
console.error(
`[apply-signing-config] failed to parse ${OVERRIDE_PATH}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
let updated;
try {
updated = addWindowsSigningToOverride(override, rawThumbprint);
} catch (err) {
console.error(
`[apply-signing-config] failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
// Format with 2-space indent + trailing newline (matches existing JSON files
// in the repo). Idempotent: same input → same output bytes.
const serialised = JSON.stringify(updated, null, 2) + '\n';
writeFileSync(OVERRIDE_PATH, serialised, 'utf8');
const relativePath = OVERRIDE_PATH.replace(REPO_ROOT, '').replace(/^\\/, '');
console.log(
`[apply-signing-config] wrote thumbprint ${updated.bundle.windows.certificateThumbprint.slice(0, 8)}... to ${relativePath}`,
);
}
main();

View File

@@ -0,0 +1,180 @@
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import {
getNodeDownloadUrl,
getPythonDownloadUrl,
getResourcePaths,
getBundleStatus,
parseVersion,
isValidVersion,
} from './bundle-utils.js';
// ─── getNodeDownloadUrl ──────────────────────────────────────────────────────
describe('getNodeDownloadUrl', () => {
it('returns Windows x64 URL by default', () => {
const url = getNodeDownloadUrl('20.11.1');
expect(url).toBe('https://nodejs.org/dist/v20.11.1/win-x64/node.exe');
});
it('returns Windows arm64 URL', () => {
const url = getNodeDownloadUrl('20.11.1', 'win32', 'arm64');
expect(url).toBe('https://nodejs.org/dist/v20.11.1/win-arm64/node.exe');
});
it('returns macOS tar.gz URL', () => {
const url = getNodeDownloadUrl('20.11.1', 'darwin', 'x64');
expect(url).toContain('darwin-x64.tar.gz');
});
it('returns Linux tar.xz URL', () => {
const url = getNodeDownloadUrl('20.11.1', 'linux', 'x64');
expect(url).toContain('linux-x64.tar.xz');
});
it('includes the version in the URL', () => {
const url = getNodeDownloadUrl('18.19.0');
expect(url).toContain('v18.19.0');
});
});
// ─── getPythonDownloadUrl ────────────────────────────────────────────────────
describe('getPythonDownloadUrl', () => {
it('returns Windows amd64 embed URL by default', () => {
const url = getPythonDownloadUrl('3.11.8');
expect(url).toBe(
'https://www.python.org/ftp/python/3.11.8/python-3.11.8-embed-amd64.zip',
);
});
it('returns Windows arm64 URL', () => {
const url = getPythonDownloadUrl('3.11.8', 'win32', 'arm64');
expect(url).toContain('embed-arm64.zip');
});
it('returns macOS pkg URL', () => {
const url = getPythonDownloadUrl('3.11.8', 'darwin', 'x64');
expect(url).toContain('macos11.pkg');
});
it('returns Linux source URL', () => {
const url = getPythonDownloadUrl('3.11.8', 'linux', 'x64');
expect(url).toContain('Python-3.11.8.tar.xz');
});
it('includes the version in the URL', () => {
const url = getPythonDownloadUrl('3.12.1');
expect(url).toContain('3.12.1');
});
});
// ─── getResourcePaths ────────────────────────────────────────────────────────
describe('getResourcePaths', () => {
it('returns correct Windows paths when platform is win32', () => {
const dir = '/app/src-tauri/resources';
const paths = getResourcePaths(dir, 'win32');
expect(paths.node).toBe(path.join(dir, 'node', 'node.exe'));
expect(paths.python).toBe(path.join(dir, 'python', 'python.exe'));
expect(paths.litellm).toBe(
path.join(dir, 'python', 'Lib', 'site-packages', 'litellm'),
);
});
it('handles Windows-style paths', () => {
const dir = 'C:\\Users\\user\\app\\resources';
const paths = getResourcePaths(dir, 'win32');
expect(paths.node).toContain('node.exe');
expect(paths.python).toContain('python.exe');
expect(paths.litellm).toContain('litellm');
});
it('returns correct Unix paths when platform is darwin', () => {
const dir = '/app/src-tauri/resources';
const paths = getResourcePaths(dir, 'darwin');
expect(paths.node).toBe(path.join(dir, 'node', 'bin', 'node'));
expect(paths.python).toBe(path.join(dir, 'python', 'bin', 'python3'));
expect(paths.litellm).toBe(
path.join(dir, 'python', 'lib', 'python3.11', 'site-packages', 'litellm'),
);
});
it('returns correct Unix paths when platform is linux', () => {
const dir = '/app/src-tauri/resources';
const paths = getResourcePaths(dir, 'linux');
expect(paths.node).toBe(path.join(dir, 'node', 'bin', 'node'));
expect(paths.python).toBe(path.join(dir, 'python', 'bin', 'python3'));
expect(paths.litellm).toBe(
path.join(dir, 'python', 'lib', 'python3.11', 'site-packages', 'litellm'),
);
});
});
// ─── getBundleStatus ─────────────────────────────────────────────────────────
describe('getBundleStatus', () => {
it('reports all missing when nothing exists', () => {
const mockExists = () => false;
const status = getBundleStatus('/fake/dir', mockExists);
expect(status.nodeReady).toBe(false);
expect(status.pythonReady).toBe(false);
expect(status.litellmReady).toBe(false);
});
it('reports all ready when all exist', () => {
const mockExists = () => true;
const status = getBundleStatus('/fake/dir', mockExists);
expect(status.nodeReady).toBe(true);
expect(status.pythonReady).toBe(true);
expect(status.litellmReady).toBe(true);
});
it('reports partial status correctly', () => {
const paths = getResourcePaths('/fake/dir', process.platform);
const existingPaths = new Set([paths.node, paths.python]);
const mockExists = (p: string) => existingPaths.has(p);
const status = getBundleStatus('/fake/dir', mockExists);
expect(status.nodeReady).toBe(true);
expect(status.pythonReady).toBe(true);
expect(status.litellmReady).toBe(false);
});
});
// ─── parseVersion ────────────────────────────────────────────────────────────
describe('parseVersion', () => {
it('parses a standard semver string', () => {
const v = parseVersion('20.11.1');
expect(v).toEqual({ major: 20, minor: 11, patch: 1 });
});
it('parses a version with zeros', () => {
const v = parseVersion('3.0.0');
expect(v).toEqual({ major: 3, minor: 0, patch: 0 });
});
});
// ─── isValidVersion ──────────────────────────────────────────────────────────
describe('isValidVersion', () => {
it('accepts a valid version', () => {
expect(isValidVersion('20.11.1')).toBe(true);
expect(isValidVersion('3.11.8')).toBe(true);
});
it('rejects invalid versions', () => {
expect(isValidVersion('20.11')).toBe(false);
expect(isValidVersion('abc')).toBe(false);
expect(isValidVersion('20.11.1.2')).toBe(false);
expect(isValidVersion('')).toBe(false);
});
});

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env npx tsx
/**
* bundle-runtimes — Download and prepare Node.js + Python runtimes
* for embedding in the Tauri installer.
*
* Usage:
* npx tsx app/scripts/bundle-runtimes.ts # Download all
* npx tsx app/scripts/bundle-runtimes.ts --status # Check what's ready
*
* Output directory: app/src-tauri/resources/
*/
import { existsSync, readdirSync, readFileSync, writeFileSync, createWriteStream } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { Readable } from 'node:stream';
import { getNodeDownloadUrl, getPythonDownloadUrl, getResourcePaths, getBundleStatus } from './bundle-utils.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
const RESOURCES_DIR = path.resolve(_dirname, '..', 'src-tauri', 'resources');
const NODE_VERSION = '20.11.1';
const PYTHON_VERSION = '3.11.8';
// ─── Side-effect functions (download / install) ─────────────────────────────
async function downloadFile(url: string, destPath: string): Promise<void> {
console.log(` Downloading: ${url}`);
console.log(` Destination: ${destPath}`);
await mkdir(path.dirname(destPath), { recursive: true });
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status} downloading ${url}`);
}
const fileStream = createWriteStream(destPath);
// Convert web ReadableStream to Node.js Readable
const body = res.body;
if (!body) throw new Error('Empty response body');
const nodeStream = Readable.fromWeb(body as import('node:stream/web').ReadableStream);
await pipeline(nodeStream, fileStream);
console.log(` Done.`);
}
async function downloadNodeBinary(version: string, resourcesDir: string): Promise<void> {
const url = getNodeDownloadUrl(version);
const dest = getResourcePaths(resourcesDir).node;
await downloadFile(url, dest);
}
async function downloadPythonEmbed(version: string, resourcesDir: string): Promise<void> {
if (process.platform !== 'win32') {
console.warn('Warning: Python embed bundling is currently only supported on Windows.');
console.warn('On macOS/Linux, use system Python or a different bundling strategy.');
return;
}
const url = getPythonDownloadUrl(version);
const zipDest = path.join(resourcesDir, 'python', `python-${version}-embed.zip`);
await downloadFile(url, zipDest);
const pythonDir = path.join(resourcesDir, 'python');
console.log(` Extracting to ${pythonDir}...`);
execFileSync('powershell', [
'-NoProfile',
'-Command',
`Expand-Archive -Force -Path "${zipDest}" -DestinationPath "${pythonDir}"`,
]);
await rm(zipDest, { force: true });
console.log(` Extracted.`);
}
async function installLiteLLM(resourcesDir: string): Promise<void> {
const pythonExe = getResourcePaths(resourcesDir).python;
if (!existsSync(pythonExe)) {
throw new Error('Python must be downloaded before installing LiteLLM');
}
const pythonDir = path.join(resourcesDir, 'python');
const pthFiles = readdirSync(pythonDir).filter((f) => f.endsWith('._pth'));
for (const pth of pthFiles) {
const pthPath = path.join(pythonDir, pth);
const content = readFileSync(pthPath, 'utf8');
const patched = content.replace(/^#\s*import site/m, 'import site');
writeFileSync(pthPath, patched);
}
const getPipUrl = 'https://bootstrap.pypa.io/get-pip.py';
const getPipDest = path.join(pythonDir, 'get-pip.py');
await downloadFile(getPipUrl, getPipDest);
console.log(' Installing pip...');
const targetDir = path.join(pythonDir, 'Lib', 'site-packages');
await mkdir(targetDir, { recursive: true });
execFileSync(pythonExe, [getPipDest, '--target', targetDir, '--no-warn-script-location'], {
stdio: 'inherit',
});
console.log(' Installing litellm...');
execFileSync(pythonExe, ['-m', 'pip', 'install', '--target', targetDir, 'litellm', '--no-warn-script-location'], {
stdio: 'inherit',
env: { ...process.env, PYTHONPATH: targetDir },
});
await rm(getPipDest, { force: true });
console.log(' LiteLLM installed.');
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--status')) {
const status = getBundleStatus(RESOURCES_DIR);
console.log('Bundle status:');
console.log(` Node.js : ${status.nodeReady ? 'READY' : 'NOT FOUND'}`);
console.log(` Python : ${status.pythonReady ? 'READY' : 'NOT FOUND'}`);
console.log(` LiteLLM : ${status.litellmReady ? 'READY' : 'NOT FOUND'}`);
return;
}
console.log('=== Waggle Runtime Bundler ===\n');
console.log(`Resources dir: ${RESOURCES_DIR}`);
console.log(`Node.js ${NODE_VERSION} | Python ${PYTHON_VERSION}\n`);
await mkdir(RESOURCES_DIR, { recursive: true });
const status = getBundleStatus(RESOURCES_DIR);
if (!status.nodeReady) {
console.log('[1/3] Downloading Node.js...');
await downloadNodeBinary(NODE_VERSION, RESOURCES_DIR);
} else {
console.log('[1/3] Node.js already present, skipping.');
}
if (!status.pythonReady) {
console.log('[2/3] Downloading embedded Python...');
await downloadPythonEmbed(PYTHON_VERSION, RESOURCES_DIR);
} else {
console.log('[2/3] Python already present, skipping.');
}
if (!status.litellmReady) {
console.log('[3/3] Installing LiteLLM...');
await installLiteLLM(RESOURCES_DIR);
} else {
console.log('[3/3] LiteLLM already present, skipping.');
}
console.log('\n=== All runtimes ready! ===');
}
main().catch((err: unknown) => {
console.error('Fatal error:', err);
process.exit(1);
});

123
app/scripts/bundle-utils.ts Normal file
View File

@@ -0,0 +1,123 @@
/**
* bundle-utils.ts — Pure utility functions for runtime bundling.
* No side effects — safe to import in tests.
*/
import { existsSync } from 'node:fs';
import path from 'node:path';
// ─── URL builders ────────────────────────────────────────────────────────────
/**
* Build the download URL for a Node.js binary.
*/
export function getNodeDownloadUrl(
version: string,
platform: string = 'win32',
arch: string = 'x64',
): string {
if (platform === 'win32') {
return `https://nodejs.org/dist/v${version}/win-${arch}/node.exe`;
}
if (platform === 'darwin') {
return `https://nodejs.org/dist/v${version}/node-v${version}-darwin-${arch}.tar.gz`;
}
// linux
return `https://nodejs.org/dist/v${version}/node-v${version}-linux-${arch}.tar.xz`;
}
/**
* Build the download URL for an embeddable Python zip.
*/
export function getPythonDownloadUrl(
version: string,
platform: string = 'win32',
arch: string = 'x64',
): string {
if (platform === 'win32') {
const archSuffix = arch === 'x64' ? 'amd64' : arch;
return `https://www.python.org/ftp/python/${version}/python-${version}-embed-${archSuffix}.zip`;
}
if (platform === 'darwin') {
return `https://www.python.org/ftp/python/${version}/python-${version}-macos11.pkg`;
}
return `https://www.python.org/ftp/python/${version}/Python-${version}.tar.xz`;
}
// ─── Path helpers ────────────────────────────────────────────────────────────
export interface ResourcePaths {
node: string;
python: string;
litellm: string;
}
/**
* Return the expected file paths for each bundled component.
*/
export function getResourcePaths(resourcesDir: string, platform: string = process.platform): ResourcePaths {
if (platform === 'win32') {
return {
node: path.join(resourcesDir, 'node', 'node.exe'),
python: path.join(resourcesDir, 'python', 'python.exe'),
litellm: path.join(resourcesDir, 'python', 'Lib', 'site-packages', 'litellm'),
};
}
// darwin / linux
return {
node: path.join(resourcesDir, 'node', 'bin', 'node'),
python: path.join(resourcesDir, 'python', 'bin', 'python3'),
litellm: path.join(resourcesDir, 'python', 'lib', 'python3.11', 'site-packages', 'litellm'),
};
}
// ─── Status ──────────────────────────────────────────────────────────────────
export interface BundleStatus {
nodeReady: boolean;
pythonReady: boolean;
litellmReady: boolean;
}
/**
* Check which runtimes are already present and ready.
* Accepts an optional existsSync override for testing.
*/
export function getBundleStatus(
resourcesDir: string,
_existsSync: (p: string) => boolean = existsSync,
): BundleStatus {
const paths = getResourcePaths(resourcesDir);
return {
nodeReady: _existsSync(paths.node),
pythonReady: _existsSync(paths.python),
litellmReady: _existsSync(paths.litellm),
};
}
// ─── Version helpers ─────────────────────────────────────────────────────────
export interface ParsedVersion {
major: number;
minor: number;
patch: number;
}
/**
* Parse a version string like '20.11.1' into { major, minor, patch }.
*/
export function parseVersion(version: string): ParsedVersion {
const parts = version.split('.').map(Number);
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
};
}
/**
* Validate a version string (must be X.Y.Z with numeric parts).
*/
export function isValidVersion(version: string): boolean {
return /^\d+\.\d+\.\d+$/.test(version);
}

View File

@@ -0,0 +1,263 @@
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import {
getDefaultInstallerConfig,
generateNsisDefines,
validateInstallPath,
isSystemPath,
getUninstallPrompt,
getVersionFromPackage,
} from './installer-config.js';
// ─── getDefaultInstallerConfig ──────────────────────────────────────────────
describe('getDefaultInstallerConfig', () => {
it('returns a valid config with expected defaults', () => {
const config = getDefaultInstallerConfig();
expect(config.productName).toBe('Waggle');
expect(config.version).toBe('0.1.0');
expect(config.publisher).toBe('Waggle');
expect(config.defaultInstallDir).toBe('C:\\Program Files\\Waggle');
expect(config.dataDir).toBe('~/.waggle');
expect(config.autostart).toBe(true);
expect(config.desktopShortcut).toBe(true);
expect(config.startMenuEntry).toBe(true);
expect(config.launchAfterInstall).toBe(true);
});
it('returns a new object each time', () => {
const a = getDefaultInstallerConfig();
const b = getDefaultInstallerConfig();
expect(a).toEqual(b);
expect(a).not.toBe(b);
});
});
// ─── generateNsisDefines ───────────────────────────────────────────────────
describe('generateNsisDefines', () => {
it('maps all config fields to NSIS defines', () => {
const config = getDefaultInstallerConfig();
const defines = generateNsisDefines(config);
expect(defines.PRODUCT_NAME).toBe('Waggle');
expect(defines.PRODUCT_VERSION).toBe('0.1.0');
expect(defines.PRODUCT_PUBLISHER).toBe('Waggle');
expect(defines.DEFAULT_INSTALL_DIR).toBe('C:\\Program Files\\Waggle');
expect(defines.DATA_DIR).toBe('~/.waggle');
expect(defines.AUTOSTART).toBe('1');
expect(defines.DESKTOP_SHORTCUT).toBe('1');
expect(defines.START_MENU_ENTRY).toBe('1');
expect(defines.LAUNCH_AFTER_INSTALL).toBe('1');
});
it('sets boolean defines to "0" when disabled', () => {
const config = getDefaultInstallerConfig();
config.autostart = false;
config.desktopShortcut = false;
config.startMenuEntry = false;
config.launchAfterInstall = false;
const defines = generateNsisDefines(config);
expect(defines.AUTOSTART).toBe('0');
expect(defines.DESKTOP_SHORTCUT).toBe('0');
expect(defines.START_MENU_ENTRY).toBe('0');
expect(defines.LAUNCH_AFTER_INSTALL).toBe('0');
});
it('handles custom product names and versions', () => {
const config = getDefaultInstallerConfig();
config.productName = 'Waggle Pro';
config.version = '2.5.0';
const defines = generateNsisDefines(config);
expect(defines.PRODUCT_NAME).toBe('Waggle Pro');
expect(defines.PRODUCT_VERSION).toBe('2.5.0');
});
});
// ─── validateInstallPath ────────────────────────────────────────────────────
describe('validateInstallPath', () => {
it('accepts a standard Program Files path', () => {
const result = validateInstallPath('C:\\Program Files\\Waggle');
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it('accepts a custom install path', () => {
const result = validateInstallPath('D:\\Apps\\Waggle');
expect(result.valid).toBe(true);
});
it('accepts forward slashes', () => {
const result = validateInstallPath('C:/Users/test/Waggle');
expect(result.valid).toBe(true);
});
it('accepts UNC paths', () => {
const result = validateInstallPath('\\\\server\\share\\Waggle');
expect(result.valid).toBe(true);
});
it('rejects empty path', () => {
const result = validateInstallPath('');
expect(result.valid).toBe(false);
expect(result.error).toContain('empty');
});
it('rejects whitespace-only path', () => {
const result = validateInstallPath(' ');
expect(result.valid).toBe(false);
expect(result.error).toContain('empty');
});
it('rejects relative paths', () => {
const result = validateInstallPath('Waggle\\bin');
expect(result.valid).toBe(false);
expect(result.error).toContain('absolute');
});
it('rejects paths with invalid characters', () => {
const result = validateInstallPath('C:\\Program Files\\Waggle<test>');
expect(result.valid).toBe(false);
expect(result.error).toContain('invalid characters');
});
it('rejects paths that are too long', () => {
const longPath = 'C:\\' + 'a'.repeat(250);
const result = validateInstallPath(longPath);
expect(result.valid).toBe(false);
expect(result.error).toContain('too long');
});
it('rejects bare drive root', () => {
const result = validateInstallPath('C:\\');
expect(result.valid).toBe(false);
expect(result.error).toContain('drive root');
});
it('rejects drive letter without backslash', () => {
const result = validateInstallPath('C:');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
// ─── isSystemPath ───────────────────────────────────────────────────────────
describe('isSystemPath', () => {
it('detects Program Files', () => {
expect(isSystemPath('C:\\Program Files\\Waggle')).toBe(true);
});
it('detects Program Files (x86)', () => {
expect(isSystemPath('C:\\Program Files (x86)\\Waggle')).toBe(true);
});
it('detects Windows directory', () => {
expect(isSystemPath('C:\\Windows\\System32')).toBe(true);
});
it('detects ProgramData', () => {
expect(isSystemPath('C:\\ProgramData\\Waggle')).toBe(true);
});
it('is case-insensitive', () => {
expect(isSystemPath('c:\\program files\\waggle')).toBe(true);
expect(isSystemPath('C:\\PROGRAM FILES\\Waggle')).toBe(true);
});
it('handles forward slashes', () => {
expect(isSystemPath('C:/Program Files/Waggle')).toBe(true);
});
it('returns false for user directories', () => {
expect(isSystemPath('C:\\Users\\test\\Waggle')).toBe(false);
});
it('returns false for custom paths', () => {
expect(isSystemPath('D:\\Apps\\Waggle')).toBe(false);
});
});
// ─── getUninstallPrompt ─────────────────────────────────────────────────────
describe('getUninstallPrompt', () => {
it('includes the data directory in the prompt', () => {
const prompt = getUninstallPrompt('~/.waggle');
expect(prompt).toContain('~/.waggle');
});
it('mentions keeping data for future use', () => {
const prompt = getUninstallPrompt('C:\\Users\\test\\.waggle');
expect(prompt).toContain('keep it for future use');
});
it('mentions deleting all data option', () => {
const prompt = getUninstallPrompt('~/.waggle');
expect(prompt).toContain('delete all data');
});
it('mentions agents and memories', () => {
const prompt = getUninstallPrompt('~/.waggle');
expect(prompt).toContain('agents');
expect(prompt).toContain('memories');
});
});
// ─── getVersionFromPackage ──────────────────────────────────────────────────
describe('getVersionFromPackage', () => {
const tmpDir = path.join(tmpdir(), 'waggle-installer-test-' + Date.now());
// Setup / teardown
const setup = () => mkdirSync(tmpDir, { recursive: true });
const cleanup = () => rmSync(tmpDir, { recursive: true, force: true });
it('reads the version from a valid package.json', () => {
setup();
try {
const pkgPath = path.join(tmpDir, 'package.json');
writeFileSync(pkgPath, JSON.stringify({ name: 'test', version: '1.2.3' }));
expect(getVersionFromPackage(pkgPath)).toBe('1.2.3');
} finally {
cleanup();
}
});
it('throws for package.json without version', () => {
setup();
try {
const pkgPath = path.join(tmpDir, 'package.json');
writeFileSync(pkgPath, JSON.stringify({ name: 'test' }));
expect(() => getVersionFromPackage(pkgPath)).toThrow('No valid "version"');
} finally {
cleanup();
}
});
it('throws for non-existent file', () => {
expect(() => getVersionFromPackage('/nonexistent/package.json')).toThrow();
});
it('reads the actual app package.json version', () => {
const appPkgPath = path.resolve(__dirname, '..', 'package.json');
const version = getVersionFromPackage(appPkgPath);
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
it('throws with a descriptive error for malformed JSON', () => {
setup();
try {
const pkgPath = path.join(tmpDir, 'bad.json');
writeFileSync(pkgPath, '{ not valid json!!!');
expect(() => getVersionFromPackage(pkgPath)).toThrow('Failed to parse');
} finally {
cleanup();
}
});
});

View File

@@ -0,0 +1,164 @@
/**
* installer-config.ts — Testable utility functions for NSIS installer configuration.
* No side effects — safe to import in tests.
*/
import { readFileSync } from 'node:fs';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface InstallerConfig {
productName: string;
version: string;
publisher: string;
defaultInstallDir: string;
dataDir: string; // ~/.waggle
autostart: boolean;
desktopShortcut: boolean;
startMenuEntry: boolean;
launchAfterInstall: boolean;
}
// ─── Default config ─────────────────────────────────────────────────────────
export function getDefaultInstallerConfig(): InstallerConfig {
return {
productName: 'Waggle',
version: '0.1.0',
publisher: 'Waggle',
defaultInstallDir: 'C:\\Program Files\\Waggle',
dataDir: '~/.waggle',
autostart: true,
desktopShortcut: true,
startMenuEntry: true,
launchAfterInstall: true,
};
}
// ─── NSIS defines ───────────────────────────────────────────────────────────
/**
* Generate NSIS !define statements from an InstallerConfig.
* These become compile-time constants in the NSIS script.
*/
export function generateNsisDefines(
config: InstallerConfig,
): Record<string, string> {
return {
PRODUCT_NAME: config.productName,
PRODUCT_VERSION: config.version,
PRODUCT_PUBLISHER: config.publisher,
DEFAULT_INSTALL_DIR: config.defaultInstallDir,
DATA_DIR: config.dataDir,
AUTOSTART: config.autostart ? '1' : '0',
DESKTOP_SHORTCUT: config.desktopShortcut ? '1' : '0',
START_MENU_ENTRY: config.startMenuEntry ? '1' : '0',
LAUNCH_AFTER_INSTALL: config.launchAfterInstall ? '1' : '0',
};
}
// ─── Path validation ────────────────────────────────────────────────────────
/**
* Validate a Windows install directory path.
* Returns { valid, error? }.
*/
export function validateInstallPath(
installPath: string,
): { valid: boolean; error?: string } {
if (!installPath || installPath.trim().length === 0) {
return { valid: false, error: 'Install path cannot be empty' };
}
const trimmed = installPath.trim();
// Must be an absolute path (drive letter or UNC)
const isAbsolute =
/^[A-Za-z]:[/\\]/.test(trimmed) || trimmed.startsWith('\\\\');
if (!isAbsolute) {
return { valid: false, error: 'Install path must be an absolute path' };
}
// Check for invalid characters (Windows filename restrictions)
// Drive prefix and backslashes/forward slashes are allowed
// For UNC paths (\\server\share\...), skip the leading \\; for drive paths, skip "C:\"
const pathBody = trimmed.startsWith('\\\\')
? trimmed.slice(2) // skip leading "\\" for UNC
: trimmed.slice(3); // skip "C:\" for drive paths
if (/[<>"|?*:]/.test(pathBody)) {
return {
valid: false,
error: 'Install path contains invalid characters: < > " | ? * :',
};
}
// Path should not be too long (Windows MAX_PATH = 260, but allow some room)
if (trimmed.length > 240) {
return {
valid: false,
error: 'Install path is too long (max 240 characters)',
};
}
// Should not be a root drive path alone
if (/^[A-Za-z]:[/\\]?$/.test(trimmed)) {
return {
valid: false,
error: 'Cannot install directly to a drive root',
};
}
return { valid: true };
}
// ─── System path detection ──────────────────────────────────────────────────
/**
* Check if a path is under a system-protected directory (requires admin/elevation).
*/
export function isSystemPath(installPath: string): boolean {
const normalized = installPath.replace(/\//g, '\\').toLowerCase();
const systemPrefixes = [
'c:\\program files\\',
'c:\\program files (x86)\\',
'c:\\windows\\',
'c:\\programdata\\',
];
return systemPrefixes.some((prefix) => normalized.startsWith(prefix));
}
// ─── Uninstall prompt ───────────────────────────────────────────────────────
/**
* Generate the uninstaller prompt text for data directory removal.
*/
export function getUninstallPrompt(dataDir: string): string {
return (
`Waggle stores your personal data (agents, memories, configuration) in:\n\n` +
` ${dataDir}\n\n` +
`Do you want to remove this data as well?\n\n` +
`Choose "Yes" to delete all data, or "No" to keep it for future use.`
);
}
// ─── Version from package.json ──────────────────────────────────────────────
/**
* Read the version string from a package.json file.
* Throws if the file cannot be read or has no version field.
*/
export function getVersionFromPackage(packageJsonPath: string): string {
const raw = readFileSync(packageJsonPath, 'utf-8');
let pkg: Record<string, unknown>;
try {
pkg = JSON.parse(raw);
} catch (err) {
throw new Error(
`Failed to parse ${packageJsonPath}: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!pkg.version || typeof pkg.version !== 'string') {
throw new Error(`No valid "version" field in ${packageJsonPath}`);
}
return pkg.version;
}

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# sign-macos-adhoc.sh — re-sign + verify a Tauri-built .app bundle for the
# Wave-1 Egzakta-internal pilot using ad-hoc signing.
#
# Implements docs/code-signing-pilot-and-launch.md §1.2 (macOS ad-hoc).
#
# Usage:
# ./sign-macos-adhoc.sh <path-to-Waggle.app>
#
# Tauri's bundle config (tauri.build-override.conf.json) already passes
# `signingIdentity: "-"` to codesign at build time, so the produced .app is
# already ad-hoc-signed. This script:
#
# 1. Re-signs the bundle with --force --deep to catch any nested helpers
# (sidecar binary, native deps) that Tauri's pass missed.
# 2. Verifies the signature with --verify --deep --strict.
#
# Distribute the result wrapped in .zip (NOT .dmg — Gatekeeper enforces
# notarization more aggressively on disk images since macOS 10.15).
#
# NOT for public Day-0 — that requires Apple Developer ID + notarization
# per docs/code-signing-pilot-and-launch.md §2.2.
set -euo pipefail
APP_PATH="${1:-}"
if [[ -z "$APP_PATH" ]]; then
echo "usage: $0 <path-to-Waggle.app>" >&2
exit 64 # EX_USAGE
fi
if [[ ! -d "$APP_PATH" ]]; then
echo "[sign-macos-adhoc] not a directory: $APP_PATH" >&2
exit 66 # EX_NOINPUT
fi
if [[ ! "$APP_PATH" =~ \.app$ ]]; then
echo "[sign-macos-adhoc] path must end in .app: $APP_PATH" >&2
exit 64
fi
if ! command -v codesign >/dev/null 2>&1; then
echo "[sign-macos-adhoc] codesign not found — Xcode command-line tools required." >&2
exit 69 # EX_UNAVAILABLE
fi
echo "[sign-macos-adhoc] re-signing: $APP_PATH"
codesign --force --deep --sign - "$APP_PATH"
echo "[sign-macos-adhoc] verifying signature"
codesign --verify --deep --strict "$APP_PATH"
echo "[sign-macos-adhoc] OK"
echo ""
echo "Next:"
echo " ditto -c -k --keepParent \"$APP_PATH\" \"${APP_PATH%.app}.zip\""
echo " # Distribute the .zip, NOT a .dmg, for the pilot."

View File

@@ -0,0 +1,203 @@
<#
.SYNOPSIS
Self-sign cert generation + signtool wrapper for the Wave-1 Egzakta-internal pilot build.
.DESCRIPTION
Implements docs/code-signing-pilot-and-launch.md §1.1 (Windows self-sign).
Two modes:
-Mode Setup Generate self-signed cert (idempotent — reuses existing cert
by subject if it exists), export to .pfx, write thumbprint
to app/src-tauri/.thumbprint.txt.
-Mode Sign Sign the artefact at -ArtifactPath using the cert produced
by Setup. Wraps signtool.exe.
Password resolution order (Setup): env WAGGLE_PILOT_PFX_PASSWORD; otherwise
Read-Host -AsSecureString prompt. Setup writes the .pfx to %USERPROFILE%
so it never lands inside the repo working tree.
NOT for public Day-0 signing — that uses a real EV Authenticode cert per §2.
.PARAMETER Mode
Setup or Sign. Setup is idempotent — safe to re-run.
.PARAMETER ArtifactPath
Required when -Mode Sign. Path to the .msi or .exe to sign.
.PARAMETER Subject
Cert subject. Default: "CN=Egzakta Internal Pilot, O=Egzakta Group, C=RS".
Override only if rotating cert identity.
.PARAMETER PfxPath
Where to write the exported .pfx. Default:
$env:USERPROFILE\waggle-pilot-codesign.pfx
.PARAMETER ThumbprintFile
Where to write the captured thumbprint for downstream consumption by
apply-signing-config.mjs. Default: app/src-tauri/.thumbprint.txt
(relative to repo root, resolved via this script's location).
.PARAMETER TimestampUrl
RFC3161 timestamp server. Default: http://timestamp.digicert.com.
.EXAMPLE
PS> $env:WAGGLE_PILOT_PFX_PASSWORD = "your-strong-pw"
PS> .\sign-windows-pilot.ps1 -Mode Setup
Generates cert (or reuses existing), writes thumbprint to .thumbprint.txt.
.EXAMPLE
PS> .\sign-windows-pilot.ps1 -Mode Sign -ArtifactPath .\target\release\bundle\msi\Waggle_0.2.0_x64_en-US.msi
Signs the MSI using the cert from Setup.
.NOTES
Last updated: LAUNCH-06 (Phase 2 Step 4).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('Setup', 'Sign')]
[string]$Mode,
[Parameter()]
[string]$ArtifactPath,
[Parameter()]
[string]$Subject = 'CN=Egzakta Internal Pilot, O=Egzakta Group, C=RS',
[Parameter()]
[string]$PfxPath = (Join-Path $env:USERPROFILE 'waggle-pilot-codesign.pfx'),
[Parameter()]
[string]$ThumbprintFile,
[Parameter()]
[string]$TimestampUrl = 'http://timestamp.digicert.com'
)
$ErrorActionPreference = 'Stop'
# ─── Resolve repo-root paths ────────────────────────────────────────────────
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$AppDir = Split-Path -Parent $ScriptDir # ...\waggle-os\app
$RepoRoot = Split-Path -Parent $AppDir # ...\waggle-os
if (-not $ThumbprintFile) {
$ThumbprintFile = Join-Path $AppDir 'src-tauri\.thumbprint.txt'
}
# ─── Helpers ────────────────────────────────────────────────────────────────
function Resolve-Password {
if ($env:WAGGLE_PILOT_PFX_PASSWORD) {
return ConvertTo-SecureString -String $env:WAGGLE_PILOT_PFX_PASSWORD -Force -AsPlainText
}
Write-Host 'WAGGLE_PILOT_PFX_PASSWORD not set in env — prompting.' -ForegroundColor Yellow
return Read-Host -Prompt 'Enter password to protect the .pfx export' -AsSecureString
}
function Find-Signtool {
# Prefer signtool from latest installed Windows SDK; fall back to PATH.
$candidates = @(
'C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe',
'C:\Program Files (x86)\Windows Kits\10\bin\10.0.22000.0\x64\signtool.exe',
'C:\Program Files (x86)\Windows Kits\10\bin\10.0.19041.0\x64\signtool.exe'
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) { return $candidate }
}
$fromPath = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($fromPath) { return $fromPath.Source }
throw 'signtool.exe not found. Install Windows 10 SDK or add signtool to PATH.'
}
# ─── Mode: Setup ────────────────────────────────────────────────────────────
if ($Mode -eq 'Setup') {
Write-Host "[setup] subject: $Subject"
Write-Host "[setup] pfx path: $PfxPath"
Write-Host "[setup] thumbprint out: $ThumbprintFile"
# Reuse existing cert by subject if present (idempotency).
$existing = Get-ChildItem 'Cert:\CurrentUser\My' |
Where-Object { $_.Subject -eq $Subject -and $_.HasPrivateKey } |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if ($existing -and $existing.NotAfter -gt (Get-Date)) {
Write-Host "[setup] reusing existing cert (NotAfter $($existing.NotAfter))" -ForegroundColor Green
$cert = $existing
} else {
Write-Host '[setup] generating new self-signed code-signing cert' -ForegroundColor Cyan
$cert = New-SelfSignedCertificate `
-Type CodeSigningCert `
-Subject $Subject `
-KeyUsage DigitalSignature `
-KeySpec Signature `
-KeyAlgorithm RSA -KeyLength 2048 `
-NotAfter (Get-Date).AddYears(2) `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}')
}
# Export .pfx (always — re-export is harmless and refreshes the file).
$pwd = Resolve-Password
Export-PfxCertificate -Cert $cert -FilePath $PfxPath -Password $pwd | Out-Null
Write-Host "[setup] exported .pfx -> $PfxPath" -ForegroundColor Green
# Write thumbprint where apply-signing-config.mjs expects it.
$thumbprintDir = Split-Path -Parent $ThumbprintFile
if (-not (Test-Path $thumbprintDir)) {
New-Item -ItemType Directory -Force -Path $thumbprintDir | Out-Null
}
Set-Content -Path $ThumbprintFile -Value $cert.Thumbprint -Encoding ascii -NoNewline
Write-Host "[setup] thumbprint -> $ThumbprintFile" -ForegroundColor Green
Write-Host ''
Write-Host 'Next:' -ForegroundColor Cyan
Write-Host ' 1. cd app && npm run tauri:sign:pilot:win:apply'
Write-Host ' 2. npm run tauri:build:win'
Write-Host ' 3. .\scripts\sign-windows-pilot.ps1 -Mode Sign -ArtifactPath <path-to-msi>'
return
}
# ─── Mode: Sign ─────────────────────────────────────────────────────────────
if ($Mode -eq 'Sign') {
if (-not $ArtifactPath) {
throw '-ArtifactPath required when -Mode Sign'
}
if (-not (Test-Path $ArtifactPath)) {
throw "Artifact not found: $ArtifactPath"
}
if (-not (Test-Path $PfxPath)) {
throw "PFX not found at $PfxPath. Run -Mode Setup first."
}
$signtool = Find-Signtool
$pwd = Resolve-Password
$plainPwd = [System.Net.NetworkCredential]::new('', $pwd).Password
Write-Host "[sign] signtool: $signtool"
Write-Host "[sign] artifact: $ArtifactPath"
& $signtool sign `
/f $PfxPath `
/p $plainPwd `
/tr $TimestampUrl `
/td sha256 /fd sha256 `
$ArtifactPath
if ($LASTEXITCODE -ne 0) {
throw "signtool failed with exit code $LASTEXITCODE"
}
Write-Host '[sign] verifying signature' -ForegroundColor Cyan
& $signtool verify /pa /v $ArtifactPath
if ($LASTEXITCODE -ne 0) {
throw "signtool verify failed with exit code $LASTEXITCODE"
}
Write-Host '[sign] OK' -ForegroundColor Green
return
}

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import {
parseThumbprintString,
addWindowsSigningToOverride,
addMacosAdhocToOverride,
type TauriOverrideConfig,
} from './signing-config.js';
// ─── parseThumbprintString ──────────────────────────────────────────────────
describe('parseThumbprintString', () => {
it('returns the uppercased thumbprint when given valid 40-hex input', () => {
const raw = 'abcdef0123456789abcdef0123456789abcdef01';
expect(parseThumbprintString(raw)).toBe(
'ABCDEF0123456789ABCDEF0123456789ABCDEF01',
);
});
it('strips surrounding whitespace and trailing newline (PowerShell output shape)', () => {
const raw = ' ABCDEF0123456789ABCDEF0123456789ABCDEF01\r\n';
expect(parseThumbprintString(raw)).toBe(
'ABCDEF0123456789ABCDEF0123456789ABCDEF01',
);
});
it('removes embedded whitespace inside the thumbprint (some clipboards introduce spaces)', () => {
const raw = 'AB CD EF 01 23 45 67 89 AB CD EF 01 23 45 67 89 AB CD EF 01';
expect(parseThumbprintString(raw)).toBe(
'ABCDEF0123456789ABCDEF0123456789ABCDEF01',
);
});
it('throws when the thumbprint is shorter than 40 chars', () => {
expect(() => parseThumbprintString('ABCDEF')).toThrow(
/must be 40 hex characters/i,
);
});
it('throws when the thumbprint contains non-hex characters', () => {
const raw = 'ZZCDEF0123456789ABCDEF0123456789ABCDEF01';
expect(() => parseThumbprintString(raw)).toThrow(/must be 40 hex characters/i);
});
it('throws on empty input', () => {
expect(() => parseThumbprintString('')).toThrow(/empty/i);
expect(() => parseThumbprintString(' ')).toThrow(/empty/i);
});
});
// ─── addWindowsSigningToOverride ────────────────────────────────────────────
describe('addWindowsSigningToOverride', () => {
const VALID_THUMBPRINT = 'ABCDEF0123456789ABCDEF0123456789ABCDEF01';
it('writes certificateThumbprint + sensible defaults when no options given', () => {
const input: TauriOverrideConfig = { build: { beforeBuildCommand: '' } };
const out = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
expect(out.bundle?.windows?.certificateThumbprint).toBe(VALID_THUMBPRINT);
expect(out.bundle?.windows?.digestAlgorithm).toBe('sha256');
expect(out.bundle?.windows?.timestampUrl).toBe(
'http://timestamp.digicert.com',
);
});
it('preserves existing top-level fields (build, app, etc.)', () => {
const input: TauriOverrideConfig = {
build: { beforeBuildCommand: 'echo hello' },
app: { security: { csp: 'default-src self' } },
};
const out = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
expect(out.build).toEqual({ beforeBuildCommand: 'echo hello' });
expect(out.app).toEqual({ security: { csp: 'default-src self' } });
});
it('preserves existing bundle.windows fields not related to signing', () => {
const input: TauriOverrideConfig = {
bundle: {
windows: { nsis: { installMode: 'currentUser' } },
},
};
const out = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
expect(out.bundle?.windows?.nsis).toEqual({ installMode: 'currentUser' });
expect(out.bundle?.windows?.certificateThumbprint).toBe(VALID_THUMBPRINT);
});
it('overrides custom digestAlgorithm and timestampUrl when options provided', () => {
const out = addWindowsSigningToOverride({}, VALID_THUMBPRINT, {
digestAlgorithm: 'sha384',
timestampUrl: 'http://timestamp.sectigo.com',
});
expect(out.bundle?.windows?.digestAlgorithm).toBe('sha384');
expect(out.bundle?.windows?.timestampUrl).toBe(
'http://timestamp.sectigo.com',
);
});
it('does not mutate the input config (immutability invariant)', () => {
const input: TauriOverrideConfig = { build: { beforeBuildCommand: '' } };
const inputSnapshot = JSON.parse(JSON.stringify(input));
addWindowsSigningToOverride(input, VALID_THUMBPRINT);
expect(input).toEqual(inputSnapshot);
});
it('is idempotent — applying twice with the same thumbprint yields equal output', () => {
const input: TauriOverrideConfig = {};
const once = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
const twice = addWindowsSigningToOverride(once, VALID_THUMBPRINT);
expect(twice).toEqual(once);
});
it('replaces an old thumbprint when called with a new one (cert rotation)', () => {
const input: TauriOverrideConfig = {};
const v1 = addWindowsSigningToOverride(input, VALID_THUMBPRINT);
const newThumb = '1234567890ABCDEF1234567890ABCDEF12345678';
const v2 = addWindowsSigningToOverride(v1, newThumb);
expect(v2.bundle?.windows?.certificateThumbprint).toBe(newThumb);
});
it('rejects an invalid thumbprint up front', () => {
expect(() =>
addWindowsSigningToOverride({}, 'too-short'),
).toThrow(/must be 40 hex characters/i);
});
});
// ─── addMacosAdhocToOverride ────────────────────────────────────────────────
describe('addMacosAdhocToOverride', () => {
it('sets bundle.macOS.signingIdentity to "-" (ad-hoc sign sentinel)', () => {
const out = addMacosAdhocToOverride({});
expect(out.bundle?.macOS?.signingIdentity).toBe('-');
});
it('preserves existing top-level and bundle fields', () => {
const input: TauriOverrideConfig = {
build: { beforeBuildCommand: 'echo' },
bundle: {
windows: { certificateThumbprint: 'AB'.repeat(20) },
},
};
const out = addMacosAdhocToOverride(input);
expect(out.build).toEqual({ beforeBuildCommand: 'echo' });
expect(out.bundle?.windows?.certificateThumbprint).toBe('AB'.repeat(20));
expect(out.bundle?.macOS?.signingIdentity).toBe('-');
});
it('does not mutate the input config', () => {
const input: TauriOverrideConfig = { bundle: { macOS: {} } };
const snapshot = JSON.parse(JSON.stringify(input));
addMacosAdhocToOverride(input);
expect(input).toEqual(snapshot);
});
it('is idempotent', () => {
const once = addMacosAdhocToOverride({});
const twice = addMacosAdhocToOverride(once);
expect(twice).toEqual(once);
});
it('preserves additional macOS fields (entitlements, providerShortName)', () => {
const input: TauriOverrideConfig = {
bundle: {
macOS: { entitlements: './ent.plist', providerShortName: 'TEAM' },
},
};
const out = addMacosAdhocToOverride(input);
expect(out.bundle?.macOS?.entitlements).toBe('./ent.plist');
expect(out.bundle?.macOS?.providerShortName).toBe('TEAM');
expect(out.bundle?.macOS?.signingIdentity).toBe('-');
});
});

View File

@@ -0,0 +1,147 @@
/**
* signing-config.ts — Pure utilities for managing code-signing fields in
* Tauri's `tauri.build-override.conf.json`.
*
* Used by:
* - `apply-signing-config.mjs` (LAUNCH-06 pilot wiring)
* - `tauri:sign:pilot:win:apply` npm script
*
* No filesystem side effects — safe to import in tests. The thin CLI wrapper
* does the file I/O.
*
* Reference: docs/code-signing-pilot-and-launch.md §1.1 (Windows self-sign)
* docs/code-signing-pilot-and-launch.md §1.2 (macOS ad-hoc)
*/
// ─── Types ──────────────────────────────────────────────────────────────────
export interface TauriBundleWindows {
certificateThumbprint?: string;
digestAlgorithm?: string;
timestampUrl?: string;
[key: string]: unknown;
}
export interface TauriBundleMacOS {
signingIdentity?: string;
[key: string]: unknown;
}
export interface TauriBundle {
windows?: TauriBundleWindows;
macOS?: TauriBundleMacOS;
[key: string]: unknown;
}
export interface TauriOverrideConfig {
bundle?: TauriBundle;
[key: string]: unknown;
}
export interface WindowsSigningOptions {
digestAlgorithm?: string;
timestampUrl?: string;
}
// ─── Defaults ───────────────────────────────────────────────────────────────
const DEFAULT_DIGEST_ALGORITHM = 'sha256';
const DEFAULT_TIMESTAMP_URL = 'http://timestamp.digicert.com';
const MACOS_ADHOC_IDENTITY = '-';
const THUMBPRINT_LENGTH = 40;
const HEX_PATTERN = /^[0-9A-F]+$/;
// ─── parseThumbprintString ──────────────────────────────────────────────────
/**
* Normalise a raw thumbprint string into the canonical 40-char uppercase form.
*
* Accepts whitespace anywhere (tab, space, newline) since PowerShell's
* `$cert.Thumbprint` plus clipboard round-tripping can introduce arbitrary
* spacing. Throws when the result is not exactly 40 hex characters.
*/
export function parseThumbprintString(raw: string): string {
if (!raw || raw.trim().length === 0) {
throw new Error('Thumbprint is empty — cert generation may have failed.');
}
const compact = raw.replace(/\s+/g, '').toUpperCase();
if (compact.length !== THUMBPRINT_LENGTH || !HEX_PATTERN.test(compact)) {
throw new Error(
`Thumbprint must be 40 hex characters; got ${compact.length} chars (sample: "${compact.slice(0, 16)}...").`,
);
}
return compact;
}
// ─── addWindowsSigningToOverride ────────────────────────────────────────────
/**
* Return a new override config with Windows code-signing fields applied.
*
* Preserves all existing top-level and bundle fields; replaces only the
* three signing-specific keys under `bundle.windows`. Idempotent — calling
* twice with the same thumbprint yields an equal result.
*/
export function addWindowsSigningToOverride<T extends TauriOverrideConfig>(
config: Readonly<T>,
thumbprint: string,
options?: WindowsSigningOptions,
): T {
const normalisedThumbprint = parseThumbprintString(thumbprint);
const digestAlgorithm = options?.digestAlgorithm ?? DEFAULT_DIGEST_ALGORITHM;
const timestampUrl = options?.timestampUrl ?? DEFAULT_TIMESTAMP_URL;
const existingBundle: TauriBundle = config.bundle ?? {};
const existingWindows: TauriBundleWindows = existingBundle.windows ?? {};
const nextWindows: TauriBundleWindows = {
...existingWindows,
certificateThumbprint: normalisedThumbprint,
digestAlgorithm,
timestampUrl,
};
const nextBundle: TauriBundle = {
...existingBundle,
windows: nextWindows,
};
return {
...config,
bundle: nextBundle,
};
}
// ─── addMacosAdhocToOverride ────────────────────────────────────────────────
/**
* Return a new override config with macOS ad-hoc signing applied.
*
* Sets `bundle.macOS.signingIdentity` to "-" (Tauri / codesign sentinel for
* ad-hoc sign). Preserves all other fields. Used during pilot before a real
* Apple Developer ID cert is procured.
*/
export function addMacosAdhocToOverride<T extends TauriOverrideConfig>(
config: Readonly<T>,
): T {
const existingBundle: TauriBundle = config.bundle ?? {};
const existingMacOS: TauriBundleMacOS = existingBundle.macOS ?? {};
const nextMacOS: TauriBundleMacOS = {
...existingMacOS,
signingIdentity: MACOS_ADHOC_IDENTITY,
};
const nextBundle: TauriBundle = {
...existingBundle,
macOS: nextMacOS,
};
return {
...config,
bundle: nextBundle,
};
}