This commit is contained in:
130
app/scripts/apply-signing-config.mjs
Normal file
130
app/scripts/apply-signing-config.mjs
Normal 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();
|
||||
180
app/scripts/bundle-runtimes.test.ts
Normal file
180
app/scripts/bundle-runtimes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
166
app/scripts/bundle-runtimes.ts
Normal file
166
app/scripts/bundle-runtimes.ts
Normal 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
123
app/scripts/bundle-utils.ts
Normal 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);
|
||||
}
|
||||
263
app/scripts/installer-config.test.ts
Normal file
263
app/scripts/installer-config.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
164
app/scripts/installer-config.ts
Normal file
164
app/scripts/installer-config.ts
Normal 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;
|
||||
}
|
||||
58
app/scripts/sign-macos-adhoc.sh
Normal file
58
app/scripts/sign-macos-adhoc.sh
Normal 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."
|
||||
203
app/scripts/sign-windows-pilot.ps1
Normal file
203
app/scripts/sign-windows-pilot.ps1
Normal 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
|
||||
}
|
||||
177
app/scripts/signing-config.test.ts
Normal file
177
app/scripts/signing-config.test.ts
Normal 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('-');
|
||||
});
|
||||
});
|
||||
147
app/scripts/signing-config.ts
Normal file
147
app/scripts/signing-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user