Improve Windows beta packaging and installer flow (#768)

* Optimize Windows packaged web output

* Fix packaged contracts runtime build

* Optimize Windows packaged size pruning

* Prune Windows root Next payload

* Remove Windows bundled Node runtime

* Prune Windows standalone duplicate Next

* Add tools-pack cache foundation

* Cache Windows packaged build layers

* Cache Windows workspace builds

* Cache Electron-ready Windows app

* Split Windows tools-pack module

* Cache Windows dir build outputs

* Split Windows pack build modules

* Document Windows NSIS smoke namespace limits

* Move Windows NSIS smoke note to agents guide

* Optimize Windows beta packaging

* Bump packaged beta base version

* Improve Windows installer namespace UX

* Improve Windows tools-pack cache keys

* Stabilize Windows beta cache version keys

* Cache Windows workspace build outputs

* Optimize windows release beta cache layers

* Cache windows release dependencies

* Trim windows release cache before save

* Refresh windows tools-pack cache key

* Improve windows installer preflight prompts

* Fallback NSIS installer strings to English

* Fix Windows installer cleanup and preflight

* Improve Windows NSIS state logging

* Fix system NSIS Persian language alias

* Use long-path removal for Windows uninstall

* Fix mac tools-pack tests on Windows

* Address Windows packaging review feedback

* Fix Windows installer cache namespace isolation

* Include web output mode in Windows tarball cache key

* Use unique Windows release cache save keys
This commit is contained in:
PerishFire 2026-05-07 16:44:15 +08:00 committed by GitHub
parent 38eb78a382
commit 6efac8887e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 5542 additions and 1417 deletions

View file

@ -213,22 +213,230 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Compute Windows tools-pack cache key
id: win_tools_pack_cache_key
shell: pwsh
run: |
$epoch = (Get-Date).ToUniversalTime().ToString("yyyy-MM")
"epoch=$epoch" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Restore Windows tools-pack cache
id: win_tools_pack_cache_restore
uses: actions/cache/restore@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: tools-pack-win-v6-${{ runner.os }}-${{ steps.win_tools_pack_cache_key.outputs.epoch }}-${{ github.sha }}
restore-keys: |
tools-pack-win-v6-${{ runner.os }}-${{ steps.win_tools_pack_cache_key.outputs.epoch }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup NSIS
shell: pwsh
run: |
if ((Get-Command makensis.exe -ErrorAction SilentlyContinue) -or (Test-Path "C:\Program Files (x86)\NSIS\makensis.exe")) {
exit 0
}
choco install nsis -y --no-progress
- name: Apply beta package version
run: npm pkg set "version=${{ needs.metadata.outputs.beta_version }}" --prefix apps/packaged
- name: Build beta windows artifacts
shell: pwsh
run: >-
pnpm exec tools-pack win build
--dir "${{ runner.temp }}/tools-pack"
--namespace release-beta-win
--portable
--to nsis
--json
run: |
$ErrorActionPreference = "Stop"
$toolsPackDir = "${{ runner.temp }}/tools-pack"
$cacheDir = "${{ runner.temp }}/tools-pack-cache"
$buildJsonPath = Join-Path $env:RUNNER_TEMP "windows-tools-pack-build.json"
$buildArgs = @(
"exec", "tools-pack", "win", "build",
"--dir", $toolsPackDir,
"--cache-dir", $cacheDir,
"--namespace", "release-beta-win",
"--portable",
"--to", "nsis",
"--json"
)
try {
$buildOutput = pnpm @buildArgs
if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack cached build exited with code $LASTEXITCODE"
}
} catch {
Write-Warning "Windows tools-pack cached build failed; removing restored cache and retrying without cache."
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cacheDir
$buildOutput = pnpm exec tools-pack win build `
--dir $toolsPackDir `
--namespace release-beta-win `
--portable `
--to nsis `
--json
if ($LASTEXITCODE -ne 0) {
throw "Windows tools-pack uncached fallback build exited with code $LASTEXITCODE"
}
}
$buildOutput | Set-Content -Path $buildJsonPath
$buildOutput
- name: Smoke beta windows packaged runtime
working-directory: e2e
env:
OD_PACKAGED_E2E_WIN: "1"
OD_PACKAGED_E2E_NAMESPACE: release-beta-win
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/open-design-win-smoke.png
run: pnpm test specs/win.spec.ts
- name: Prune Windows tools-pack cache
shell: pwsh
continue-on-error: true
run: |
$cacheRoot = Join-Path $env:RUNNER_TEMP "tools-pack-cache"
if (!(Test-Path $cacheRoot)) {
"tools-pack cache root does not exist; nothing to prune"
exit 0
}
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue (Join-Path $cacheRoot "locks")
$maxBytes = 6GB
$entryRoot = Join-Path $cacheRoot "entries"
if (!(Test-Path $entryRoot)) {
"tools-pack cache entries root does not exist; nothing to prune"
exit 0
}
$discardedBytes = 0L
$discardedCount = 0
$packagedAppRoot = Join-Path $entryRoot "win.packaged-app"
if (Test-Path $packagedAppRoot) {
$packagedAppEntries = Get-ChildItem -Path $packagedAppRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName "manifest.json") }
foreach ($entry in $packagedAppEntries) {
$size = (Get-ChildItem -Path $entry.FullName -Recurse -File -Force -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
Remove-Item -Recurse -Force -LiteralPath $entry.FullName
$discardedBytes += [int64]($size ?? 0)
$discardedCount += 1
}
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $packagedAppRoot
}
$priorityByNode = @{
"win.electron-builder-dir" = 0
"win.workspace-build" = 1
"win.resource-tree" = 2
"win.workspace-tarballs" = 3
}
$entries = Get-ChildItem -Path $entryRoot -Directory -Recurse |
Where-Object { Test-Path (Join-Path $_.FullName "manifest.json") } |
ForEach-Object {
$size = (Get-ChildItem -Path $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
$node = Split-Path (Split-Path $_.FullName -Parent) -Leaf
[pscustomobject]@{
Path = $_.FullName
Node = $node
Priority = [int]($priorityByNode[$node] ?? 100)
Size = [int64]($size ?? 0)
LastWriteTimeUtc = $_.LastWriteTimeUtc
}
} |
Sort-Object Priority, @{ Expression = "LastWriteTimeUtc"; Descending = $true }
$keptBytes = 0L
$removedBytes = 0L
$removedCount = 0
foreach ($entry in $entries) {
if (($keptBytes + $entry.Size) -le $maxBytes) {
$keptBytes += $entry.Size
continue
}
Remove-Item -Recurse -Force -LiteralPath $entry.Path
$removedBytes += $entry.Size
$removedCount += 1
}
"keptBytes=$keptBytes removedBytes=$removedBytes removedCount=$removedCount discardedBytes=$discardedBytes discardedCount=$discardedCount maxBytes=$maxBytes"
- name: Summarize Windows tools-pack build
shell: pwsh
continue-on-error: true
run: |
$summaryPath = $env:GITHUB_STEP_SUMMARY
$buildJsonPath = Join-Path $env:RUNNER_TEMP "windows-tools-pack-build.json"
if (!(Test-Path $buildJsonPath)) {
"### Windows tools-pack build" | Add-Content -Path $summaryPath
"" | Add-Content -Path $summaryPath
"Build JSON was not found at `$buildJsonPath`." | Add-Content -Path $summaryPath
exit 0
}
$build = Get-Content -Raw -Path $buildJsonPath | ConvertFrom-Json
"### Windows tools-pack build" | Add-Content -Path $summaryPath
"" | Add-Content -Path $summaryPath
"| Phase | Duration |" | Add-Content -Path $summaryPath
"| --- | ---: |" | Add-Content -Path $summaryPath
foreach ($timing in $build.timings) {
$seconds = [math]::Round(([double]$timing.durationMs) / 1000, 1)
"| `$($timing.phase)` | ${seconds}s |" | Add-Content -Path $summaryPath
}
"" | Add-Content -Path $summaryPath
"| Cache node | Status | Reason | Duration |" | Add-Content -Path $summaryPath
"| --- | --- | --- | ---: |" | Add-Content -Path $summaryPath
foreach ($entry in $build.cacheReport.entries) {
$seconds = [math]::Round(([double]$entry.durationMs) / 1000, 1)
$reason = if ($null -eq $entry.reason) { "" } else { [string]$entry.reason }
"| `$($entry.nodeId)` | `$($entry.status)` | $reason | ${seconds}s |" | Add-Content -Path $summaryPath
}
$cacheRoot = Join-Path $env:RUNNER_TEMP "tools-pack-cache"
$entryRoot = Join-Path $cacheRoot "entries"
if (Test-Path $entryRoot) {
$entries = Get-ChildItem -Path $entryRoot -Directory -Recurse |
Where-Object { Test-Path (Join-Path $_.FullName "manifest.json") } |
ForEach-Object {
$size = (Get-ChildItem -Path $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
[pscustomobject]@{
Node = Split-Path (Split-Path $_.FullName -Parent) -Leaf
Size = [int64]($size ?? 0)
}
} |
Group-Object Node |
ForEach-Object {
[pscustomobject]@{
Node = $_.Name
Count = $_.Count
Size = [int64](($_.Group | Measure-Object -Property Size -Sum).Sum ?? 0)
}
} |
Sort-Object Size -Descending
"" | Add-Content -Path $summaryPath
"| Saved cache node | Entries | Size |" | Add-Content -Path $summaryPath
"| --- | ---: | ---: |" | Add-Content -Path $summaryPath
foreach ($entry in $entries) {
$mb = [math]::Round(([double]$entry.Size) / 1MB, 1)
"| `$($entry.Node)` | $($entry.Count) | ${mb} MB |" | Add-Content -Path $summaryPath
}
}
- name: Save Windows tools-pack cache
if: ${{ success() && steps.win_tools_pack_cache_restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: tools-pack-win-v6-${{ runner.os }}-${{ steps.win_tools_pack_cache_key.outputs.epoch }}-${{ github.sha }}
- name: Prepare windows beta assets
shell: pwsh
@ -237,20 +445,14 @@ jobs:
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
$sourceInstaller = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/release-beta-win/builder/Open Design-release-beta-win-setup.exe"
$sourceBlockmap = Join-Path $env:RUNNER_TEMP "tools-pack/out/win/namespaces/release-beta-win/builder/Open Design-release-beta-win-setup.exe.blockmap"
if (!(Test-Path $sourceInstaller)) {
throw "expected installer not found at $sourceInstaller"
}
if (!(Test-Path $sourceBlockmap)) {
throw "expected blockmap not found at $sourceBlockmap"
}
$windowsAssetSuffix = ".unsigned"
$versionedInstaller = "open-design-${{ needs.metadata.outputs.beta_version }}$windowsAssetSuffix-win-x64-setup.exe"
$versionedBlockmap = "open-design-${{ needs.metadata.outputs.beta_version }}$windowsAssetSuffix-win-x64-setup.exe.blockmap"
$checksumFile = "$versionedInstaller.sha256"
Copy-Item $sourceInstaller (Join-Path $releaseDir $versionedInstaller)
Copy-Item $sourceBlockmap (Join-Path $releaseDir $versionedBlockmap)
$installerPath = Join-Path $releaseDir $versionedInstaller
$hash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLowerInvariant()
@ -547,7 +749,7 @@ jobs:
echo "- mac assets: open-design-${{ needs.metadata.outputs.beta_version }}${{ needs.metadata.outputs.asset_version_suffix }}-mac-arm64.dmg, open-design-${{ needs.metadata.outputs.beta_version }}${{ needs.metadata.outputs.asset_version_suffix }}-mac-arm64.zip"
fi
if [ "$ENABLE_WIN" = "true" ]; then
echo "- win assets: open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-win-x64-setup.exe, open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-win-x64-setup.exe.blockmap"
echo "- win assets: open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-win-x64-setup.exe"
fi
if [ "$ENABLE_LINUX" = "true" ]; then
echo "- linux assets: open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-linux-x64.AppImage"

View file

@ -5,12 +5,27 @@ import { existsSync } from 'node:fs';
import { delimiter } from 'node:path';
import path from 'node:path';
import { homedir } from 'node:os';
import { wellKnownUserToolchainBins } from '@open-design/platform';
import {
createCommandInvocation,
wellKnownUserToolchainBins,
} from '@open-design/platform';
import { detectAcpModels } from './acp.js';
import { parsePiModels } from './pi-rpc.js';
const execFileP = promisify(execFile);
function execAgentFile(command, args, options = {}) {
const invocation = createCommandInvocation({
command,
args,
env: options.env,
});
return execFileP(invocation.command, invocation.args, {
...options,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
}
// Capability flags detected at probe time (per agent id). buildArgs consults
// this map so we only pass flags the installed CLI actually advertises in
// `--help`. Falls back to "off" when probing failed or hasn't run yet — that
@ -629,7 +644,7 @@ export const AGENT_DEFS = [
// so we use a custom fetchModels that reads stderr.
fetchModels: async (resolvedBin, env) => {
try {
const { stderr } = await execFileP(resolvedBin, ['--list-models'], {
const { stderr } = await execAgentFile(resolvedBin, ['--list-models'], {
env,
timeout: 20_000,
maxBuffer: 8 * 1024 * 1024,
@ -910,7 +925,7 @@ async function fetchModels(def, resolvedBin, env) {
}
if (!def.listModels) return def.fallbackModels;
try {
const { stdout } = await execFileP(resolvedBin, def.listModels.args, {
const { stdout } = await execAgentFile(resolvedBin, def.listModels.args, {
env,
timeout: def.listModels.timeoutMs ?? 5000,
// Models lists from popular CLIs (e.g. opencode) easily exceed the
@ -948,7 +963,7 @@ async function probe(def, configuredEnv = {}) {
);
let version = null;
try {
const { stdout } = await execFileP(resolved, def.versionArgs, {
const { stdout } = await execAgentFile(resolved, def.versionArgs, {
env: probeEnv,
timeout: 3000,
});
@ -961,7 +976,7 @@ async function probe(def, configuredEnv = {}) {
if (def.helpArgs && def.capabilityFlags) {
const caps = {};
try {
const { stdout } = await execFileP(resolved, def.helpArgs, {
const { stdout } = await execAgentFile(resolved, def.helpArgs, {
env: probeEnv,
timeout: 5000,
maxBuffer: 4 * 1024 * 1024,
@ -1206,8 +1221,8 @@ function looksLikeWindowsPath(p) {
// never go through this path);
// - the resolved binary is a `.cmd` / `.bat` shim — that's handled by
// `checkWindowsCmdShimCommandLineBudget` so we don't double-emit;
// - the resolved binary is a POSIX path on a POSIX host (no
// CreateProcess in play);
// - the resolved binary is not a Windows path (no CreateProcess
// command-line shape to budget);
// - the assembled command line fits under the safe limit.
//
// Pure: takes `resolvedBin` and `args` explicitly so a test on macOS can
@ -1220,12 +1235,11 @@ export function checkWindowsDirectExeCommandLineBudget(def, resolvedBin, args) {
// The cmd-shim guard owns `.bat` / `.cmd`; skip those here so a single
// oversized prompt doesn't trip both guards.
if (/\.(bat|cmd)$/i.test(resolvedBin)) return null;
// Only fire when the spawn would actually go through Windows'
// CreateProcess. On POSIX hosts, `execvp` accepts each argv entry as a
// separate buffer — there's no command-line concatenation step that
// could expand past a kernel cap, so we have nothing to guard.
if (process.platform !== 'win32' && !looksLikeWindowsPath(resolvedBin))
return null;
// Only fire for Windows-shaped resolved binaries. On POSIX-shaped
// paths, `execvp` accepts each argv entry as a separate buffer —
// there's no command-line concatenation step that could expand past a
// kernel cap, so we have nothing to guard.
if (!looksLikeWindowsPath(resolvedBin)) return null;
const argList = Array.isArray(args) ? args : [];
// `[command, ...args].map(quote).join(' ')` is the exact shape libuv
// builds before handing it to CreateProcess.

View file

@ -47,12 +47,22 @@ export async function importClaudeDesignZip(zipPath, projectDir) {
const entryFile = chooseEntryFile(files.map((f) => f.path));
if (!entryFile) throw new Error('zip does not contain an HTML file');
const dirCreates = new Map();
const ensureDir = (dir) => {
let pending = dirCreates.get(dir);
if (!pending) {
pending = mkdir(dir, { recursive: true });
dirCreates.set(dir, pending);
}
return pending;
};
await mkdir(projectDir, { recursive: true });
for (const f of files) {
await Promise.all(files.map(async (f) => {
const target = safeJoin(projectDir, f.path);
await mkdir(path.dirname(target), { recursive: true });
await ensureDir(path.dirname(target));
await writeFile(target, f.body);
}
}));
return {
entryFile,

View file

@ -37,7 +37,7 @@
// echo secrets back into the DOM.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import os from 'node:os';
import path from 'node:path';
import { MEDIA_PROVIDERS } from './media-models.js';
import { expandHomePrefix } from './home-expansion.js';
@ -201,7 +201,7 @@ function tokenFromCodexAuth(data) {
}
async function resolveOpenAIOAuthCredential() {
const home = homedir();
const home = os.homedir();
const hermesAuth = await readJsonIfPresent(
path.join(home, '.hermes', 'auth.json'),
);

View file

@ -1545,16 +1545,12 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
});
});
// Surfaces the absolute paths to `node` + `apps/daemon/dist/cli.js`
// so the Settings → MCP server panel can render snippets that work
// even when `od` isn't on the user's PATH (the common case for
// source clones - and macOS/Linux ship a /usr/bin/od octal-dump
// tool that shadows ours anyway). Computed from import.meta.url so
// both src/ (tsx dev) and dist/ (built) launches resolve to the
// same dist/cli.js path. Cached for 5s because the panel pings on
// every open and the path lookup + two existsSync calls are cheap
// but not free, and these paths cannot change without a daemon
// restart anyway.
// Surfaces the absolute paths to the daemon's Node-compatible runtime and
// CLI entry so the Settings → MCP server panel can render snippets that work
// even when `od` isn't on the user's PATH (the common case for source clones
// - and macOS/Linux ship a /usr/bin/od octal-dump tool that shadows ours
// anyway). Cached for 5s because the panel pings on every open and these
// paths cannot change without a daemon restart.
const INSTALL_INFO_TTL_MS = 5000;
let installInfoCache: { t: number; payload: object } | null = null;
@ -1566,20 +1562,14 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
if (installInfoCache && now - installInfoCache.t < INSTALL_INFO_TTL_MS) {
return res.json(installInfoCache.payload);
}
let cliPath;
try {
cliPath = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
} catch (err) {
return sendApiError(res, 500, 'CLI_RESOLVE_FAILED', String(err));
}
const cliPath = OD_BIN;
const cliExists = fs.existsSync(cliPath);
// process.execPath is the absolute path to the node binary that
// is running the daemon RIGHT NOW. We prefer it over bare `node`
// because IDE-spawned MCP clients inherit a minimal PATH from the
// OS launcher (Spotlight, Dock, etc.) that often does not see
// user-level node installs (nvm, fnm, asdf). On rare occasions
// (uninstall mid-session, exotic embeds) the path may not exist
// by the time the user copies the snippet; catch that and warn.
// process.execPath is the absolute path to the Node-compatible runtime
// that is running the daemon RIGHT NOW. In packaged builds this may be
// Electron running with ELECTRON_RUN_AS_NODE=1 rather than a separate
// bundled Node binary; surface the env requirement with the command so
// IDE-spawned MCP clients can reproduce the same mode from a minimal OS
// launcher environment.
const nodeExists = fs.existsSync(process.execPath);
const hints: string[] = [];
if (!cliExists) {
@ -1589,12 +1579,16 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
if (!nodeExists) {
hints.push(
`Node binary at ${process.execPath} no longer exists. Reinstall Node and restart the daemon.`,
`Node-compatible runtime at ${process.execPath} no longer exists. Reinstall Open Design or Node and restart the daemon.`,
);
}
const commandEnv = process.env.ELECTRON_RUN_AS_NODE === '1'
? { ELECTRON_RUN_AS_NODE: '1' }
: null;
const payload = {
command: process.execPath,
args: [cliPath, 'mcp', '--daemon-url', `http://127.0.0.1:${resolvedPort}`],
...(commandEnv == null ? {} : { env: commandEnv }),
daemonUrl: `http://127.0.0.1:${resolvedPort}`,
// Surface platform so the install panel can localize path hints
// (~/.cursor vs %USERPROFILE%\.cursor) and keyboard shortcuts

View file

@ -1590,12 +1590,19 @@ test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => {
test('detectAgents applies configured env while probing the CLI', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agent-env-'));
try {
const bin = join(dir, 'claude');
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "$CLAUDE_CONFIG_DIR"; exit 0; fi\nif [ "$1" = "-p" ]; then echo "--add-dir --include-partial-messages"; exit 0; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
const bin = join(dir, process.platform === 'win32' ? 'claude.cmd' : 'claude');
if (process.platform === 'win32') {
writeFileSync(
bin,
'@echo off\r\nif "%~1"=="--version" (\r\n echo %CLAUDE_CONFIG_DIR%\r\n exit /b 0\r\n)\r\nif "%~1"=="-p" (\r\n echo --add-dir --include-partial-messages\r\n exit /b 0\r\n)\r\nexit /b 0\r\n',
);
} else {
writeFileSync(
bin,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "$CLAUDE_CONFIG_DIR"; exit 0; fi\nif [ "$1" = "-p" ]; then echo "--add-dir --include-partial-messages"; exit 0; fi\nexit 0\n',
);
chmodSync(bin, 0o755);
}
process.env.PATH = dir;
process.env.OD_AGENT_HOME = dir;

View file

@ -11,7 +11,7 @@ import {
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { delimiter, join, resolve } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
composeLiveInstructionPrompt,
@ -162,51 +162,43 @@ process.exit(0);
});
it('surfaces Qoder assistant error records through the SSE error channel', async () => {
const binDir = mkdtempSync(join(tmpdir(), 'od-qoder-bin-'));
tempDirs.push(binDir);
const qoderBin = join(binDir, 'qodercli');
const qoderErrorLine = JSON.stringify({
type: 'assistant',
message: { content: [] },
error: { message: 'Qoder authentication expired' },
});
writeFileSync(
qoderBin,
`#!/bin/sh\nprintf '%s\\n' '${qoderErrorLine}'\nexit 0\n`,
'utf8',
await withFakeAgent(
'qodercli',
`console.log(${JSON.stringify(qoderErrorLine)});\nprocess.exit(0);\n`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'qoder',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('Qoder authentication expired');
expect(eventsBody).not.toContain('event: agent\\ndata: {"type":"error"');
expect(statusBody.status).toBe('failed');
},
);
chmodSync(qoderBin, 0o755);
process.env.PATH = binDir;
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'qoder',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('Qoder authentication expired');
expect(eventsBody).not.toContain('event: agent\\ndata: {"type":"error"');
expect(statusBody.status).toBe('failed');
});
it('fails Qoder runs when the result reports is_error with exit code 0', async () => {
const binDir = mkdtempSync(join(tmpdir(), 'od-qoder-bin-'));
tempDirs.push(binDir);
const qoderBin = join(binDir, 'qodercli');
const qoderResultLine = JSON.stringify({
type: 'result',
subtype: 'error',
@ -219,39 +211,37 @@ process.exit(0);
output_tokens: 1,
},
});
writeFileSync(
qoderBin,
`#!/bin/sh\nprintf '%s\\n' '${qoderResultLine}'\nexit 0\n`,
'utf8',
await withFakeAgent(
'qodercli',
`console.log(${JSON.stringify(qoderResultLine)});\nprocess.exit(0);\n`,
async () => {
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'qoder',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: agent');
expect(eventsBody).toContain('"type":"usage"');
expect(eventsBody).toContain('"isError":true');
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('Qoder run failed: tool_use_failed');
expect(statusBody.status).toBe('failed');
},
);
chmodSync(qoderBin, 0o755);
process.env.PATH = binDir;
const createResponse = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'qoder',
message: 'hello',
}),
});
expect(createResponse.status).toBe(202);
const { runId } = await createResponse.json() as { runId: string };
const eventsController = new AbortController();
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
signal: eventsController.signal,
});
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
eventsController.abort();
const statusBody = await waitForRunStatus(baseUrl, runId);
expect(eventsBody).toContain('event: agent');
expect(eventsBody).toContain('"type":"usage"');
expect(eventsBody).toContain('"isError":true');
expect(eventsBody).toContain('event: error');
expect(eventsBody).toContain('Qoder run failed: tool_use_failed');
expect(statusBody.status).toBe('failed');
});
});
@ -310,7 +300,7 @@ describe('chat prompt helpers', () => {
{ CODEX_HOME: '/tmp/custom-codex-home' },
'/home/tester',
),
).toBe('/tmp/custom-codex-home/generated_images');
).toBe(resolve('/tmp/custom-codex-home/generated_images'));
expect(
resolveCodexGeneratedImagesDir(

View file

@ -91,6 +91,19 @@ async function waitForFile(file: string, timeoutMs = 5_000): Promise<void> {
throw new Error(`Timed out waiting for ${file}`);
}
async function waitForPidToExit(pid: number, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
process.kill(pid, 0);
} catch {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for process ${pid} to exit`);
}
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
baseUrl = started.url;
@ -1006,9 +1019,16 @@ setInterval(() => {}, 1000);
});
},
);
await expect(fsp.readFile(termFile, 'utf8')).resolves.toBe('term');
if (process.platform !== 'win32') {
await expect(fsp.readFile(termFile, 'utf8')).resolves.toBe('term');
}
const pid = Number(await fsp.readFile(pidFile, 'utf8'));
expect(() => process.kill(pid, 0)).toThrow();
if (process.platform === 'win32') {
process.kill(pid, 'SIGKILL');
await waitForPidToExit(pid);
} else {
expect(() => process.kill(pid, 0)).toThrow();
}
} finally {
await fsp.rm(markerDir, { recursive: true, force: true });
}

View file

@ -10,6 +10,10 @@ function real(p: string): string {
try { return realpathSync(p); } catch { return p; }
}
function blockedSystemDir(): string {
return real(process.platform === 'win32' ? (process.env.SystemRoot ?? 'C:\\Windows') : '/etc');
}
test('rejects non-array input', () => {
assert.equal(validateLinkedDirs('not-array').error, 'linkedDirs must be an array');
assert.equal(validateLinkedDirs(null).error, 'linkedDirs must be an array');
@ -52,7 +56,7 @@ test('rejects filesystem root', () => {
});
test('rejects blocked system directories', () => {
const result = validateLinkedDirs([real('/etc')]);
const result = validateLinkedDirs([blockedSystemDir()]);
assert.ok(result.error);
assert.ok(result.error.includes('system directory'));
});
@ -61,7 +65,7 @@ test('rejects symlink pointing to blocked directory', () => {
const tmp = mkdtempSync(join(tmpdir(), 'od-linked-'));
const link = join(tmp, 'etc-link');
try {
symlinkSync('/etc', link);
symlinkSync(blockedSystemDir(), link, process.platform === 'win32' ? 'junction' : 'dir');
const result = validateLinkedDirs([link]);
assert.ok(result.error);
assert.ok(result.error.includes('system directory'));

View file

@ -27,11 +27,13 @@ describe('media-config OpenAI OAuth fallback', () => {
);
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
const originalDataDir = process.env.OD_DATA_DIR;
let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
homeDir = await mkdtemp(path.join(tmpdir(), 'od-media-home-'));
projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-project-'));
process.env.HOME = homeDir;
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
for (const key of OPENAI_ENV_KEYS) {
delete process.env[key];
}
@ -62,6 +64,7 @@ describe('media-config OpenAI OAuth fallback', () => {
} else {
process.env.OD_DATA_DIR = originalDataDir;
}
homedirSpy.mockRestore();
await rm(homeDir, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true });
});
@ -186,7 +189,6 @@ describe('media-config OpenAI OAuth fallback', () => {
let overrideRoot: string;
let originalMediaConfigDir: string | undefined;
let originalDataDir: string | undefined;
let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
overrideRoot = await mkdtemp(path.join(tmpdir(), 'od-media-override-'));
@ -194,13 +196,6 @@ describe('media-config OpenAI OAuth fallback', () => {
originalDataDir = process.env.OD_DATA_DIR;
delete process.env.OD_MEDIA_CONFIG_DIR;
delete process.env.OD_DATA_DIR;
// Stub os.homedir() to point at the per-test fake home so the
// ~/, $HOME, ${HOME} expansion in resolveOverrideDir lands inside
// homeDir on every platform. Without this the production path
// (which now goes through expandHomePrefix -> os.homedir()) would
// expand to USERPROFILE on Windows while the fixture is written
// under homeDir, and the assertion would fail platform-specifically.
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
});
afterEach(async () => {
@ -214,7 +209,6 @@ describe('media-config OpenAI OAuth fallback', () => {
} else {
process.env.OD_DATA_DIR = originalDataDir;
}
homedirSpy.mockRestore();
await rm(overrideRoot, { recursive: true, force: true });
});

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/packaged",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"type": "module",
"main": "./dist/index.mjs",

View file

@ -388,11 +388,11 @@ export function DesignFilesPanel({
.closest('.df-row-menu')
?.getBoundingClientRect();
if (!rect) return;
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
let top: number;
if (spaceBelow >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) {
top = rect.bottom + 4;
@ -404,9 +404,9 @@ export function DesignFilesPanel({
viewportHeight - MENU_ESTIMATED_HEIGHT - MENU_SAFE_PADDING,
);
}
const left = Math.max(MENU_SAFE_PADDING, rect.right - 160);
setMenuPos({
name: f.name,
top,

View file

@ -1893,8 +1893,8 @@ function MediaProvidersSection({
// to the local config for you. Verified against each tool's official
// docs in May 2026.
//
// Important: every snippet uses absolute paths to `node` and the
// daemon's built cli.js, fetched from the daemon at runtime. macOS
// Important: every snippet uses absolute paths to the daemon's current
// Node-compatible runtime and built cli.js, fetched at runtime. macOS
// and Linux ship a system /usr/bin/od (octal-dump) that shadows any
// `od` we might add to PATH, and most Open Design users run from
// source where `od` is not installed globally. The installer panel
@ -1911,6 +1911,7 @@ type McpClientId =
interface McpInstallInfo {
command: string;
args: string[];
env?: Record<string, string>;
daemonUrl: string;
platform: 'darwin' | 'linux' | 'win32' | string;
cliExists: boolean;
@ -1918,6 +1919,12 @@ interface McpInstallInfo {
buildHint: string | null;
}
interface McpStdioServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
}
interface McpClient {
id: McpClientId;
label: string;
@ -1969,8 +1976,26 @@ function utf8Btoa(s: string): string {
return btoa(bin);
}
function buildMcpStdioServerConfig(info: McpInstallInfo): McpStdioServerConfig {
const env = info.env && Object.keys(info.env).length > 0 ? info.env : undefined;
return {
command: info.command,
args: info.args,
...(env ? { env } : {}),
};
}
function buildCodexEnvToml(info: McpInstallInfo): string {
const entries = Object.entries(info.env ?? {});
if (entries.length === 0) return '';
return `
[mcp_servers.open-design.env]
${entries.map(([key, value]) => `${key} = ${JSON.stringify(value)}`).join('\n')}`;
}
function buildSharedMcpJson(info: McpInstallInfo): string {
const inner = { command: info.command, args: info.args };
const inner = buildMcpStdioServerConfig(info);
const innerJson = JSON.stringify(inner, null, 2)
.split('\n')
.map((line, i) => (i === 0 ? line : ` ${line}`))
@ -1996,7 +2021,7 @@ const MCP_CLIENTS: McpClient[] = [
buildMethod: () => 'CLI command',
buildInstruction: () => 'Run this in your terminal.',
buildSnippet: (info) => {
const inner = JSON.stringify({ command: info.command, args: info.args });
const inner = JSON.stringify(buildMcpStdioServerConfig(info));
return `claude mcp add-json --scope user open-design '${inner}'`;
},
buildSnippetLang: () => 'bash',
@ -2025,7 +2050,7 @@ const MCP_CLIENTS: McpClient[] = [
},
buildSnippet: (info) => `[mcp_servers.open-design]
command = ${JSON.stringify(info.command)}
args = ${JSON.stringify(info.args)}`,
args = ${JSON.stringify(info.args)}${buildCodexEnvToml(info)}`,
buildSnippetLang: () => 'toml',
},
{
@ -2037,7 +2062,7 @@ args = ${JSON.stringify(info.args)}`,
buildSnippet: buildSharedMcpJson,
buildSnippetLang: () => 'json',
buildDeeplink: (info) => {
const inner = { command: info.command, args: info.args };
const inner = buildMcpStdioServerConfig(info);
// Cursor expects the inner server-config object base64-encoded
// as ?config=...; the handler decodes it and pops an approval
// dialog before writing to mcp.json. We UTF-8-encode first so
@ -2059,7 +2084,8 @@ args = ${JSON.stringify(info.args)}`,
"open-design": {
"type": "stdio",
"command": ${JSON.stringify(info.command)},
"args": ${JSON.stringify(info.args)}
"args": ${JSON.stringify(info.args)}${info.env && Object.keys(info.env).length > 0 ? `,
"env": ${JSON.stringify(info.env)}` : ''}
}
}
}`,
@ -2085,7 +2111,8 @@ args = ${JSON.stringify(info.args)}`,
"open-design": {
"source": "custom",
"command": ${JSON.stringify(info.command)},
"args": ${JSON.stringify(info.args)}
"args": ${JSON.stringify(info.args)}${info.env && Object.keys(info.env).length > 0 ? `,
"env": ${JSON.stringify(info.env)}` : ''}
}
}
}`,

424
e2e/specs/win.spec.ts Normal file
View file

@ -0,0 +1,424 @@
// @vitest-environment node
import { execFile } from 'node:child_process';
import { readFile, stat } from 'node:fs/promises';
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { describe, expect, test } from 'vitest';
const execFileAsync = promisify(execFile);
const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const workspaceRoot = dirname(e2eRoot);
const toolsPackDir = resolveFromWorkspace(process.env.OD_PACKAGED_E2E_TOOLS_PACK_DIR ?? '.tmp/tools-pack');
const namespace = process.env.OD_PACKAGED_E2E_NAMESPACE ?? 'release-beta-win';
const toolsPackBin = join(workspaceRoot, 'tools', 'pack', 'bin', 'tools-pack.mjs');
const maxInstallDurationMs = Number.parseInt(process.env.OD_PACKAGED_E2E_WIN_MAX_INSTALL_MS ?? '120000', 10);
const installIdentity = resolveInstallIdentity(namespace);
const outputNamespaceRoot = join(toolsPackDir, 'out', 'win', 'namespaces', namespace);
const runtimeNamespaceRoot = join(toolsPackDir, 'runtime', 'win', 'namespaces', namespace);
const screenshotPath = resolveFromWorkspace(
process.env.OD_PACKAGED_E2E_SCREENSHOT_PATH ?? join(toolsPackDir, 'screenshots', `${namespace}.png`),
);
const healthExpression = "fetch('/api/health').then(async response => ({ health: await response.json(), href: location.href, status: response.status, title: document.title }))";
type DesktopStatus = {
state?: string;
title?: string | null;
url?: string | null;
windowVisible?: boolean;
};
type WinInstallResult = {
desktopShortcutExists: boolean;
desktopShortcutPath: string;
installDir: string;
installPayload: {
fileCount: number;
totalBytes: number;
topLevel: Array<{
bytes: number;
fileCount: number;
path: string;
}>;
};
installerPath: string;
namespace: string;
registryEntries: unknown[];
startMenuShortcutExists: boolean;
startMenuShortcutPath: string;
timingPath: string;
uninstallerPath: string;
};
type WinStartResult = {
executablePath: string;
logPath: string;
namespace: string;
pid: number;
source: string;
status: DesktopStatus | null;
};
type WinStopResult = {
namespace: string;
remainingPids: number[];
status: string;
};
type WinCleanupResult = {
namespace: string;
residueObservation?: {
installedExeExists?: boolean;
managedProcessPids?: number[];
productNamespaceRootExists?: boolean;
registryResidues?: string[];
startMenuShortcutExists?: boolean;
uninstallerExists?: boolean;
userDesktopShortcutExists?: boolean;
};
};
type WinUninstallResult = {
namespace: string;
residueObservation?: WinCleanupResult['residueObservation'];
};
type WinInspectResult = {
eval?: {
error?: string;
ok: boolean;
value?: unknown;
};
screenshot?: {
path: string;
};
status: DesktopStatus | null;
};
type LogsResult = {
logs: Record<string, { lines: string[]; logPath: string }>;
namespace: string;
};
type TimingResult = {
action: string;
durationMs: number;
status: string;
};
type HealthEvalValue = {
health: {
ok?: unknown;
service?: unknown;
version?: unknown;
};
href: string;
status: number;
title: string;
};
type SmokeTiming = {
durationMs: number;
step: string;
};
const shouldRunPackagedWinSmoke = process.platform === 'win32' && process.env.OD_PACKAGED_E2E_WIN === '1';
const winDescribe = shouldRunPackagedWinSmoke ? describe : describe.skip;
winDescribe('packaged windows runtime smoke', () => {
let installed = false;
let started = false;
test('installs, starts, inspects with eval and screenshot, stops, and uninstalls the built windows artifact', async () => {
let passed = false;
const timings: SmokeTiming[] = [];
try {
await measureSmokeStep(timings, 'pre-clean uninstall', async () => {
await runToolsPackJson<WinUninstallResult>('uninstall').catch(() => null);
});
const install = await measureSmokeStep(timings, 'install', async () => runToolsPackJson<WinInstallResult>('install'));
installed = true;
expect(install.namespace).toBe(namespace);
expectPathInside(install.installerPath, join(outputNamespaceRoot, 'builder'));
expectPathInside(install.installDir, join(runtimeNamespaceRoot, 'install'));
expectPathInside(install.uninstallerPath, install.installDir);
expect(basename(install.uninstallerPath)).toBe(`Uninstall ${installIdentity.displayName}.exe`);
expect(install.desktopShortcutExists).toBe(true);
expect(install.startMenuShortcutExists).toBe(true);
expect(basename(install.desktopShortcutPath)).toBe(`${installIdentity.displayName}.lnk`);
expect(basename(install.startMenuShortcutPath)).toBe(`${installIdentity.displayName}.lnk`);
expect(install.registryEntries.length).toBeGreaterThan(0);
expect(JSON.stringify(install.registryEntries)).toContain(installIdentity.displayName);
expect(JSON.stringify(install.registryEntries)).toContain(`Open Design-${installIdentity.namespaceToken}`);
expect(install.installPayload.fileCount).toBeGreaterThan(0);
expect(install.installPayload.totalBytes).toBeGreaterThan(0);
expect(install.installPayload.topLevel.length).toBeGreaterThan(0);
const installTiming = await readTiming(install.timingPath);
expect(installTiming.action).toBe('install');
expect(installTiming.status).toBe('success');
if (installTiming.durationMs > maxInstallDurationMs) {
throw new Error(
[
`windows installer exceeded ${maxInstallDurationMs}ms budget: ${installTiming.durationMs}ms`,
`installed files=${install.installPayload.fileCount} bytes=${install.installPayload.totalBytes}`,
`top-level payload=${JSON.stringify(install.installPayload.topLevel.slice(0, 8))}`,
].join('\n'),
);
}
const start = await measureSmokeStep(timings, 'start', async () => runToolsPackJson<WinStartResult>('start'));
started = true;
expect(start.namespace).toBe(namespace);
expect(start.source).toBe('installed');
expectPathInside(start.executablePath, install.installDir);
expectPathInside(start.logPath, join(runtimeNamespaceRoot, 'logs', 'desktop'));
expect(start.pid).toBeGreaterThan(0);
const inspect = await measureSmokeStep(timings, 'wait healthy inspect eval', async () => waitForHealthyDesktop());
expect(inspect.status?.state).toBe('running');
expect(inspect.status?.url).toBe('od://app/');
const value = assertHealthEvalValue(inspect.eval?.value);
expect(value.href).toBe('od://app/');
expect(value.status).toBe(200);
expect(value.health.ok).toBe(true);
expect(value.health.version).toEqual(expect.any(String));
const screenshot = await measureSmokeStep(timings, 'inspect screenshot', async () =>
runToolsPackJson<WinInspectResult>('inspect', ['--path', screenshotPath]),
);
expect(screenshot.screenshot?.path).toBe(screenshotPath);
expect(await fileSizeBytes(screenshotPath)).toBeGreaterThan(0);
const logs = await measureSmokeStep(timings, 'logs', async () => runToolsPackJson<LogsResult>('logs'));
assertLogPathsAndContent(logs);
const stop = await measureSmokeStep(timings, 'stop', async () => runToolsPackJson<WinStopResult>('stop'));
started = false;
expect(stop.namespace).toBe(namespace);
expect(stop.status).not.toBe('partial');
expect(stop.remainingPids).toEqual([]);
const uninstall = await measureSmokeStep(timings, 'uninstall remove data', async () =>
runToolsPackJson<WinUninstallResult>('uninstall', ['--remove-product-user-data']),
);
installed = false;
expect(uninstall.namespace).toBe(namespace);
expect(uninstall.residueObservation?.managedProcessPids ?? []).toEqual([]);
expect(uninstall.residueObservation?.productNamespaceRootExists).toBe(false);
expect(uninstall.residueObservation?.registryResidues ?? []).toEqual([]);
expect(uninstall.residueObservation?.installedExeExists).toBe(false);
expect(uninstall.residueObservation?.uninstallerExists).toBe(false);
expect(uninstall.residueObservation?.startMenuShortcutExists).toBe(false);
expect(uninstall.residueObservation?.userDesktopShortcutExists).toBe(false);
passed = true;
} finally {
if (!passed) {
await printPackagedLogs().catch((error: unknown) => {
console.error('failed to read packaged windows logs after failure', error);
});
}
if (started) {
await runToolsPackJson<WinStopResult>('stop').catch((error: unknown) => {
console.error('failed to stop packaged windows app during cleanup', error);
});
started = false;
}
if (installed) {
await runToolsPackJson<WinUninstallResult>('uninstall').catch((error: unknown) => {
console.error('failed to uninstall packaged windows app during cleanup', error);
});
installed = false;
}
printSmokeTimings(timings);
}
}, 300_000);
});
async function measureSmokeStep<T>(timings: SmokeTiming[], step: string, run: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
try {
return await run();
} finally {
timings.push({ durationMs: Date.now() - startedAt, step });
}
}
function printSmokeTimings(timings: SmokeTiming[]): void {
const totalMs = timings.reduce((sum, timing) => sum + timing.durationMs, 0);
console.info(
[
'[windows smoke timings]',
...timings.map((timing) => `${timing.step}: ${Math.round(timing.durationMs / 100) / 10}s`),
`measured total: ${Math.round(totalMs / 100) / 10}s`,
].join('\n'),
);
}
async function runToolsPackJson<T>(action: string, extraArgs: string[] = []): Promise<T> {
const args = [
toolsPackBin,
'win',
action,
'--dir',
toolsPackDir,
'--namespace',
namespace,
'--json',
...extraArgs,
];
const result = await execFileAsync(process.execPath, args, {
cwd: workspaceRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
}).catch((error: unknown) => {
if (isExecError(error)) {
throw new Error(
[
`tools-pack win ${action} failed`,
`message:\n${error.message}`,
`stdout:\n${error.stdout}`,
`stderr:\n${error.stderr}`,
].join('\n'),
);
}
throw error;
});
try {
return JSON.parse(result.stdout) as T;
} catch (error) {
throw new Error(`tools-pack win ${action} did not print JSON: ${String(error)}\n${result.stdout}`);
}
}
async function waitForHealthyDesktop(): Promise<WinInspectResult> {
const timeoutMs = 90_000;
const startedAt = Date.now();
let lastResult: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const inspect = await runToolsPackJson<WinInspectResult>('inspect', ['--expr', healthExpression]);
lastResult = inspect;
if (inspect.status?.state === 'running' && inspect.eval?.ok === true) {
const value = asHealthEvalValue(inspect.eval.value);
if (value?.status === 200 && value.health.ok === true && typeof value.health.version === 'string') {
return inspect;
}
}
} catch (error) {
lastResult = error;
}
await delay(1000);
}
throw new Error(`packaged windows runtime did not become healthy: ${formatUnknown(lastResult)}`);
}
function assertLogPathsAndContent(result: LogsResult): void {
expect(result.namespace).toBe(namespace);
for (const app of ['desktop', 'web', 'daemon']) {
const entry = result.logs[app];
if (entry == null) {
throw new Error(`expected ${app} log entry`);
}
expectPathInside(entry.logPath, join(runtimeNamespaceRoot, 'logs', app));
}
const combined = Object.values(result.logs)
.flatMap((entry) => entry.lines)
.join('\n');
expect(combined).not.toMatch(/ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
expect(combined).not.toMatch(/packaged runtime failed/i);
expect(combined).not.toMatch(/standalone Next\.js server exited/i);
}
async function printPackagedLogs(): Promise<void> {
const result = await runToolsPackJson<LogsResult>('logs');
for (const [app, entry] of Object.entries(result.logs)) {
console.error(`[${app}] ${entry.logPath}`);
console.error(entry.lines.join('\n') || '(no log lines)');
}
}
function assertHealthEvalValue(value: unknown): HealthEvalValue {
const normalized = asHealthEvalValue(value);
if (normalized == null) {
throw new Error(`unexpected health eval value: ${formatUnknown(value)}`);
}
return normalized;
}
function asHealthEvalValue(value: unknown): HealthEvalValue | null {
if (!isRecord(value)) return null;
if (typeof value.href !== 'string' || typeof value.status !== 'number' || typeof value.title !== 'string') return null;
if (!isRecord(value.health)) return null;
return value as HealthEvalValue;
}
function expectPathInside(filePath: string, expectedRoot: string): void {
const normalizedPath = resolve(filePath);
const normalizedRoot = resolve(expectedRoot);
expect(
normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`),
`${normalizedPath} should be inside ${normalizedRoot}`,
).toBe(true);
}
async function fileSizeBytes(filePath: string): Promise<number> {
return (await stat(filePath)).size;
}
async function readTiming(filePath: string): Promise<TimingResult> {
return JSON.parse(await readFile(filePath, 'utf8')) as TimingResult;
}
function resolveFromWorkspace(filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(workspaceRoot, filePath);
}
function resolveInstallIdentity(value: string): { displayName: string; namespaceToken: string } {
const namespaceToken = value.replace(/[^A-Za-z0-9._-]+/g, '-');
const displayName = /(^|[-_.])beta($|[-_.])/i.test(value)
? 'Open Design Beta'
: value === 'default'
? 'Open Design'
: `Open Design ${namespaceToken}`;
return { displayName, namespaceToken };
}
function delay(ms: number): Promise<void> {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value != null && !Array.isArray(value);
}
function isExecError(value: unknown): value is { message: string; stderr: string; stdout: string } {
return (
isRecord(value) &&
typeof value.message === 'string' &&
typeof value.stdout === 'string' &&
typeof value.stderr === 'string'
);
}
function formatUnknown(value: unknown): string {
if (value instanceof Error) return `${value.name}: ${value.message}`;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View file

@ -333,6 +333,9 @@ importers:
'@electron/notarize':
specifier: 3.1.0
version: 3.1.0
'@electron/rebuild':
specifier: 4.0.4
version: 4.0.4
'@open-design/platform':
specifier: workspace:*
version: link:../../packages/platform

View file

@ -56,8 +56,8 @@ const residualAllowedExactPaths = new Set([
"tools/pack/bin/tools-pack.mjs",
"tools/pack/esbuild.config.mjs",
"tools/pack/resources/mac/notarize.cjs",
// electron-builder hook path; CJS compatibility entry used by tools-pack mac builds.
"tools/pack/resources/mac/web-standalone-after-pack.cjs",
// electron-builder hook path; CJS compatibility entry used by tools-pack desktop builds.
"tools/pack/resources/web-standalone-after-pack.cjs",
]);
const residualAllowedPathPrefixes = [

View file

@ -29,3 +29,4 @@ Follow the root `AGENTS.md` and `tools/AGENTS.md` first. This tool owns the repo
- Do not let namespace-named `.app` installs change data/log/runtime/cache path conventions.
- Use `--portable` for public/release artifacts so packaged config does not bake local tools-pack runtime roots from the build machine.
- Pack resource files used by electron-builder belong under `tools/pack/resources/`; do not point pack logic at Downloads, web public assets, docs assets, or other app-owned resource paths.
- For ordinary Windows NSIS smoke tests, use short namespaces such as `rg`, `smoke`, or `nsis-a`. NSIS extracts deeply nested Next.js standalone files under the namespace-scoped install directory; long namespaces can push installed paths past the traditional Windows 260-character limit even when builder `win-unpacked` output is correct. During merge regression, namespace `regression-merge-nsis` produced an installed path length of 264 characters and missed `next/dist/server/route-matcher-providers/helpers/cached-route-matcher-provider.js` in the installed directory, while the same NSIS smoke passed with namespace `rg`. Use long namespaces only when intentionally testing installer path-length behavior.

View file

@ -50,7 +50,7 @@ namespace paths, and the packaged sidecar launcher passes daemon managed paths v
own default fallback for non-packaged launches, but packaged runtime must not rely on fallback inference from Electron
`userData`, app bundle names, or ports.
The current release slice is mac beta publication. Runtime updater integration and Windows packaging remain later phases.
Runtime updater integration remains a later phase.
Electron-builder resources live under `tools/pack/resources/mac/`. The current logo is staged there as the mac icon/DMG
placeholder so future design-provided assets can replace the resource files without changing packaging code.
@ -59,6 +59,27 @@ Local developer artifacts bake the tools-pack namespace runtime root so `tools-p
them from the repo. Release artifacts use `--portable` so the installed app resolves namespace data/log/runtime/user-data
from the user's Electron `userData` root instead of the build machine's `.tmp` path.
## Windows
Local lifecycle commands:
- `tools-pack win build --to dir` for fast unpacked smoke builds.
- `tools-pack win build --to nsis` for installer builds.
- `tools-pack win build --to all` for both outputs.
- `tools-pack win install`
- `tools-pack win start`
- `tools-pack win inspect --expr "document.title"`
- `tools-pack win logs`
- `tools-pack win stop`
- `tools-pack win cleanup`
- `tools-pack win list`
- `tools-pack win reset`
Build artifacts are namespace-scoped under `.tmp/tools-pack/out/win/namespaces/<namespace>/`.
Packaged runtime state is namespace-scoped under `.tmp/tools-pack/runtime/win/namespaces/<namespace>/`.
`--to dir` may point `built-app.json` at an immutable cached `win-unpacked` executable while keeping
namespace-local config and runtime paths outside that cache entry.
## Linux
Local lifecycle commands:

View file

@ -13,6 +13,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@electron/rebuild": "4.0.4",
"@open-design/platform": "workspace:*",
"@open-design/sidecar": "workspace:*",
"@open-design/sidecar-proto": "workspace:*",

View file

@ -108,9 +108,25 @@ function resolveAppPath(context) {
if (typeof productFilename !== "string" || productFilename.length === 0) {
throw new Error("[tools-pack web-standalone] electron-builder productFilename is missing");
}
if (context.electronPlatformName === "win32") return context.appOutDir;
return path.join(context.appOutDir, `${productFilename}.app`);
}
function resolveResourcesRoot(context, appPath) {
switch (context?.electronPlatformName) {
case "darwin":
return path.join(appPath, "Contents", "Resources");
case "win32":
return path.join(context.appOutDir, "resources");
default:
throw new Error(`[tools-pack web-standalone] unsupported platform: ${context?.electronPlatformName ?? "unknown"}`);
}
}
function resolveRootAppNodeModulesRoot(resourcesRoot) {
return path.join(resourcesRoot, "app", "node_modules");
}
async function sizePathBytes(filePath) {
let metadata;
try {
@ -129,22 +145,22 @@ async function sizePathBytes(filePath) {
return total;
}
async function copyRequired(sourcePath, destinationPath) {
async function copyRequired(sourcePath, destinationPath, options = {}) {
if (!(await pathExists(sourcePath))) {
throw new Error(`[tools-pack web-standalone] required source missing: ${sourcePath}`);
}
await rm(destinationPath, { force: true, recursive: true });
await mkdir(path.dirname(destinationPath), { recursive: true });
await cp(sourcePath, destinationPath, {
dereference: false,
dereference: options.dereference === true,
recursive: true,
verbatimSymlinks: true,
verbatimSymlinks: options.dereference === true ? false : true,
});
}
async function copyOptional(sourcePath, destinationPath) {
async function copyOptional(sourcePath, destinationPath, options = {}) {
if (!(await pathExists(sourcePath))) return false;
await copyRequired(sourcePath, destinationPath);
await copyRequired(sourcePath, destinationPath, options);
return true;
}
@ -195,18 +211,19 @@ async function resolveStandaloneSourceWebRoot(standaloneSourceRoot) {
throw new Error(`[tools-pack web-standalone] standalone server.js not found under ${standaloneSourceRoot}`);
}
async function installStandaloneResource(config, appPath) {
async function installStandaloneResource(config, resourcesRoot, platformName) {
const sourceWebRoot = await resolveStandaloneSourceWebRoot(config.standaloneSourceRoot);
const destinationRoot = path.join(appPath, "Contents", "Resources", config.resourceName);
const destinationRoot = path.join(resourcesRoot, config.resourceName);
const destinationWebRoot = path.join(destinationRoot, "apps", "web");
const copyOptions = { dereference: platformName === "win32" };
await rm(destinationRoot, { force: true, recursive: true });
await mkdir(destinationWebRoot, { recursive: true });
await copyRequired(path.join(config.standaloneSourceRoot, "node_modules"), path.join(destinationRoot, "node_modules"));
await copyRequired(path.join(config.standaloneSourceRoot, "node_modules"), path.join(destinationRoot, "node_modules"), copyOptions);
await copyRequired(path.join(sourceWebRoot, "server.js"), path.join(destinationWebRoot, "server.js"));
await copyOptional(path.join(sourceWebRoot, "package.json"), path.join(destinationWebRoot, "package.json"));
const copiedNestedNodeModules = await copyOptional(path.join(sourceWebRoot, "node_modules"), path.join(destinationWebRoot, "node_modules"));
const copiedNestedNodeModules = await copyOptional(path.join(sourceWebRoot, "node_modules"), path.join(destinationWebRoot, "node_modules"), copyOptions);
const linkedHoistEntries = await linkPnpmPublicHoist(destinationRoot);
await copyRequired(path.join(sourceWebRoot, ".next"), path.join(destinationWebRoot, ".next"));
const copiedStatic = await copyOptional(config.webStaticSourceRoot, path.join(destinationWebRoot, ".next", "static"));
@ -274,6 +291,36 @@ async function pruneCopiedSharp(destinationRoot) {
return removedPaths;
}
async function dedupeCopiedStandaloneNext(destinationRoot, destinationWebRoot, platformName) {
if (platformName !== "win32") return null;
const nodeModulesRoot = path.join(destinationRoot, "node_modules");
const rootNextRoot = path.join(nodeModulesRoot, "next");
const pnpmHoistedNextRoot = path.join(nodeModulesRoot, ".pnpm", "node_modules", "next");
const webNextRoot = path.join(destinationWebRoot, "node_modules", "next");
const removedPaths = [];
if (!(await pathExists(path.join(webNextRoot, "package.json")))) {
throw new Error(`[tools-pack web-standalone] copied standalone app-local Next package missing: ${webNextRoot}`);
}
await removePathAndRecord(
rootNextRoot,
"copied standalone root next public-hoist duplicate",
removedPaths,
);
await removePathAndRecord(
pnpmHoistedNextRoot,
"copied standalone pnpm-hoisted next duplicate superseded by app-local next",
removedPaths,
);
return {
removedPaths,
retainedPath: webNextRoot,
};
}
async function pruneBrokenSymlinks(root, current = root, removedPaths = [], reason = "broken symlink") {
let metadata;
try {
@ -300,9 +347,56 @@ async function pruneBrokenSymlinks(root, current = root, removedPaths = [], reas
return removedPaths;
}
function isForbiddenCopiedEntry(relativePath) {
function isSourceBuildResidue(relativePath) {
const normalized = relativePath.split(path.sep).join("/");
return normalized.endsWith(".map") || normalized.endsWith(".tsbuildinfo");
}
async function pruneMatchingFilesSummary(root, includeRelativePath, reason) {
const summary = {
bytes: 0,
count: 0,
reason,
root,
};
async function visit(current) {
let metadata;
try {
metadata = await lstat(current);
} catch {
return;
}
if (metadata.isDirectory()) {
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
await visit(path.join(current, entry.name));
}
return;
}
const relativePath = path.relative(root, current);
if (relativePath.length === 0 || !includeRelativePath(relativePath)) return;
summary.bytes += metadata.size;
summary.count += 1;
await rm(current, { force: true });
}
await visit(root);
return summary.count > 0 ? [summary] : [];
}
async function pruneSourceBuildResidue(root, reason) {
return await pruneMatchingFilesSummary(root, isSourceBuildResidue, reason);
}
function isForbiddenCopiedEntry(relativePath, platformName) {
const normalized = relativePath.split(path.sep).join("/");
const withRootSlash = `/${normalized}`;
const forbiddenSwc = platformName === "win32"
? withRootSlash.includes("swc-darwin") || withRootSlash.includes("swc-linux")
: withRootSlash.includes("swc-darwin");
return (
withRootSlash.includes("/node_modules/.pnpm/sharp@") ||
withRootSlash.includes("/node_modules/.pnpm/@img+colour@") ||
@ -311,11 +405,11 @@ function isForbiddenCopiedEntry(relativePath) {
withRootSlash.includes("/node_modules/@img/colour") ||
withRootSlash.includes("/node_modules/@img/sharp-") ||
withRootSlash.includes("sharp-libvips") ||
withRootSlash.includes("swc-darwin")
forbiddenSwc
);
}
async function collectClosureStats(root, current = root, stats = { brokenSymlinks: [], forbiddenEntries: [], symlinks: 0 }) {
async function collectClosureStats(root, current = root, stats = { brokenSymlinks: [], forbiddenEntries: [], symlinks: 0 }, platformName = process.platform) {
let metadata;
try {
metadata = await lstat(current);
@ -324,7 +418,7 @@ async function collectClosureStats(root, current = root, stats = { brokenSymlink
}
const relativePath = path.relative(root, current);
if (relativePath.length > 0 && isForbiddenCopiedEntry(relativePath)) {
if (relativePath.length > 0 && isForbiddenCopiedEntry(relativePath, platformName)) {
stats.forbiddenEntries.push(relativePath.split(path.sep).join("/"));
}
@ -342,7 +436,7 @@ async function collectClosureStats(root, current = root, stats = { brokenSymlink
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
await collectClosureStats(root, path.join(current, entry.name), stats);
await collectClosureStats(root, path.join(current, entry.name), stats, platformName);
}
return stats;
}
@ -353,11 +447,12 @@ function assertResolvedInside(root, moduleName, resolvedPath) {
}
}
async function auditCopiedStandalone(config, installResult) {
async function auditCopiedStandalone(config, installResult, platformName) {
const serverPath = path.join(installResult.destinationWebRoot, "server.js");
const staticRoot = path.join(installResult.destinationWebRoot, ".next", "static");
const publicRoot = path.join(installResult.destinationWebRoot, "public");
const nodeModulesRoot = path.join(installResult.destinationRoot, "node_modules");
const webNodeModulesRoot = path.join(installResult.destinationWebRoot, "node_modules");
const requiredPaths = [serverPath, staticRoot, nodeModulesRoot];
if (await pathExists(config.webPublicSourceRoot)) requiredPaths.push(publicRoot);
@ -375,7 +470,7 @@ async function auditCopiedStandalone(config, installResult) {
resolvedModules[moduleName] = resolvedPath;
}
const closureStats = await collectClosureStats(installResult.destinationRoot);
const closureStats = await collectClosureStats(installResult.destinationRoot, installResult.destinationRoot, undefined, platformName);
if (closureStats.brokenSymlinks.length > 0) {
throw new Error(`[tools-pack web-standalone] copied standalone has broken symlinks: ${closureStats.brokenSymlinks.join(", ")}`);
}
@ -393,18 +488,72 @@ async function auditCopiedStandalone(config, installResult) {
resolvedModules,
serverPath,
symlinks: closureStats.symlinks,
webNextBytes: await sizePathBytes(path.join(webNodeModulesRoot, "next")),
webNodeModulesBytes: await sizePathBytes(webNodeModulesRoot),
};
}
async function pruneRootNext(appPath) {
const appNodeModulesRoot = path.join(appPath, "Contents", "Resources", "app", "node_modules");
async function auditCopiedStandaloneNextDedupe(installResult, platformName) {
if (platformName !== "win32") return null;
const serverPath = path.join(installResult.destinationWebRoot, "server.js");
const retainedNextRoot = path.join(installResult.destinationWebRoot, "node_modules", "next");
const retainedNextPackagePath = path.join(retainedNextRoot, "package.json");
const checkedPaths = [
path.join(installResult.destinationRoot, "node_modules", "next"),
path.join(installResult.destinationRoot, "node_modules", ".pnpm", "node_modules", "next"),
];
const remainingPaths = [];
for (const checkedPath of checkedPaths) {
if (await pathExists(checkedPath)) remainingPaths.push(checkedPath);
}
if (remainingPaths.length > 0) {
throw new Error(
`[tools-pack web-standalone] copied standalone next dedupe audit found remaining duplicate paths: ${remainingPaths.join(", ")}`,
);
}
if (!(await pathExists(retainedNextPackagePath))) {
throw new Error(`[tools-pack web-standalone] copied standalone retained app-local next missing: ${retainedNextPackagePath}`);
}
const resolvedNextPackagePath = createRequire(serverPath).resolve("next/package.json");
if (!isWithin(retainedNextRoot, resolvedNextPackagePath)) {
throw new Error(
`[tools-pack web-standalone] copied standalone next resolved outside retained app-local next: ${resolvedNextPackagePath}`,
);
}
return {
checkedPaths,
remainingPaths,
resolvedNextPackagePath,
retainedNextRoot,
};
}
async function pruneRootNext(appNodeModulesRoot, platformName) {
const removedPaths = [];
const nextScopeRoot = path.join(appNodeModulesRoot, "@next");
const nextScopeEntries = await readdir(nextScopeRoot).catch(() => []);
for (const entry of nextScopeEntries) {
if (entry.startsWith("swc-darwin-")) {
await removePathAndRecord(path.join(nextScopeRoot, entry), "root next darwin swc package", removedPaths);
if (platformName === "win32") {
await removePathAndRecord(
path.join(appNodeModulesRoot, "next"),
"root next package superseded by copied standalone resource",
removedPaths,
);
await removePathAndRecord(
path.join(appNodeModulesRoot, "@next"),
"root @next package scope superseded by copied standalone resource",
removedPaths,
);
} else {
const nextScopeRoot = path.join(appNodeModulesRoot, "@next");
const nextScopeEntries = await readdir(nextScopeRoot).catch(() => []);
for (const entry of nextScopeEntries) {
if (platformName === "darwin" && entry.startsWith("swc-darwin-")) {
await removePathAndRecord(path.join(nextScopeRoot, entry), "root next darwin swc package", removedPaths);
}
}
}
@ -417,8 +566,28 @@ async function pruneRootNext(appPath) {
return removedPaths;
}
async function pruneRootSharp(appPath) {
const appNodeModulesRoot = path.join(appPath, "Contents", "Resources", "app", "node_modules");
async function auditRootNextPruned(appNodeModulesRoot, platformName, enabled) {
if (platformName !== "win32" || !enabled) return null;
const checkedPaths = [
path.join(appNodeModulesRoot, "next"),
path.join(appNodeModulesRoot, "@next"),
];
const remainingPaths = [];
for (const checkedPath of checkedPaths) {
if (await pathExists(checkedPath)) remainingPaths.push(checkedPath);
}
if (remainingPaths.length > 0) {
throw new Error(`[tools-pack web-standalone] root next pruning audit found remaining paths: ${remainingPaths.join(", ")}`);
}
return {
checkedPaths,
remainingPaths,
};
}
async function pruneRootSharp(appNodeModulesRoot) {
const pnpmRoot = path.join(appNodeModulesRoot, ".pnpm");
const removedPaths = [];
@ -437,6 +606,39 @@ async function pruneRootSharp(appPath) {
return removedPaths;
}
async function pruneRootWebPackage(appNodeModulesRoot, platformName) {
if (platformName !== "win32") return [];
const webPackageRoot = path.join(appNodeModulesRoot, "@open-design", "web");
const removedPaths = [];
for (const entry of [".next", "app", "next.config.ts", "public", "src"]) {
await removePathAndRecord(
path.join(webPackageRoot, entry),
"root @open-design/web standalone-safe package residue",
removedPaths,
);
}
return removedPaths;
}
async function auditRootWebPackage(appNodeModulesRoot) {
const webPackageRoot = path.join(appNodeModulesRoot, "@open-design", "web");
const packageJsonPath = path.join(webPackageRoot, "package.json");
const sidecarEntryPath = path.join(webPackageRoot, "dist", "sidecar", "index.js");
for (const requiredPath of [packageJsonPath, sidecarEntryPath]) {
if (!(await pathExists(requiredPath))) {
throw new Error(`[tools-pack web-standalone] root @open-design/web audit missing: ${requiredPath}`);
}
}
return {
bytes: await sizePathBytes(webPackageRoot),
packageJsonPath,
retainedDistBytes: await sizePathBytes(path.join(webPackageRoot, "dist")),
sidecarEntryPath,
webPackageRoot,
};
}
async function auditNoBrokenSymlinks(root, label) {
const stats = await collectClosureStats(root);
if (stats.brokenSymlinks.length > 0) {
@ -449,45 +651,83 @@ async function auditNoBrokenSymlinks(root, label) {
}
async function runWebStandaloneAfterPack(context) {
if (context?.electronPlatformName != null && context.electronPlatformName !== "darwin") return;
if (context?.electronPlatformName != null && context.electronPlatformName !== "darwin" && context.electronPlatformName !== "win32") return;
const config = await readHookConfig();
const appPath = resolveAppPath(context);
if (!(await pathExists(appPath))) {
const resourcesRoot = resolveResourcesRoot(context, appPath);
if (context.electronPlatformName === "darwin" && !(await pathExists(appPath))) {
throw new Error(`[tools-pack web-standalone] app bundle not found: ${appPath}`);
}
if (!(await pathExists(resourcesRoot))) {
throw new Error(`[tools-pack web-standalone] resources root not found: ${resourcesRoot}`);
}
const installResult = await installStandaloneResource(config, appPath);
const appNodeModulesRoot = resolveRootAppNodeModulesRoot(resourcesRoot);
const installResult = await installStandaloneResource(config, resourcesRoot, context.electronPlatformName);
const copiedPrune = config.pruneCopiedSharp ? await pruneCopiedSharp(installResult.destinationRoot) : [];
const copiedNextDedupe = await dedupeCopiedStandaloneNext(
installResult.destinationRoot,
installResult.destinationWebRoot,
context.electronPlatformName,
);
const copiedBuildResiduePrune = context.electronPlatformName === "win32"
? await pruneSourceBuildResidue(installResult.destinationRoot, "copied standalone source/build residue")
: [];
const brokenSymlinkPrune = await pruneBrokenSymlinks(
installResult.destinationRoot,
installResult.destinationRoot,
[],
"copied broken symlink",
);
const copiedAudit = await auditCopiedStandalone(config, installResult);
const rootPrune = config.pruneRootNext ? await pruneRootNext(appPath) : [];
const rootSharpPrune = config.pruneRootSharp ? await pruneRootSharp(appPath) : [];
const copiedNextDedupeAudit = await auditCopiedStandaloneNextDedupe(
installResult,
context.electronPlatformName,
);
const copiedAudit = await auditCopiedStandalone(config, installResult, context.electronPlatformName);
const rootPrune = config.pruneRootNext ? await pruneRootNext(appNodeModulesRoot, context.electronPlatformName) : [];
const rootSharpPrune = config.pruneRootSharp ? await pruneRootSharp(appNodeModulesRoot) : [];
const rootWebPackagePrune = await pruneRootWebPackage(appNodeModulesRoot, context.electronPlatformName);
const rootBuildResiduePrune = context.electronPlatformName === "win32"
? await pruneSourceBuildResidue(appNodeModulesRoot, "root app source/build residue")
: [];
const rootWebPackageAudit = context.electronPlatformName === "win32"
? await auditRootWebPackage(appNodeModulesRoot)
: null;
const rootNextPruneAudit = await auditRootNextPruned(
appNodeModulesRoot,
context.electronPlatformName,
config.pruneRootNext,
);
const rootBrokenSymlinkPrune = await pruneBrokenSymlinks(
path.join(appPath, "Contents", "Resources", "app", "node_modules"),
path.join(appPath, "Contents", "Resources", "app", "node_modules"),
appNodeModulesRoot,
appNodeModulesRoot,
[],
"root broken symlink",
);
const rootSymlinkAudit = await auditNoBrokenSymlinks(
path.join(appPath, "Contents", "Resources", "app", "node_modules"),
appNodeModulesRoot,
"root app node_modules",
);
const report = {
appPath,
brokenSymlinkPrune,
copiedAudit,
copiedBuildResiduePrune,
copiedNextDedupe,
copiedNextDedupeAudit,
copiedPrune,
generatedAt: new Date().toISOString(),
platformName: context.electronPlatformName,
resourcesRoot,
rootBuildResiduePrune,
rootBrokenSymlinkPrune,
rootNextPruneAudit,
rootPrune,
rootSharpPrune,
rootSymlinkAudit,
rootWebPackageAudit,
rootWebPackagePrune,
sourceWebRoot: installResult.sourceWebRoot,
version: 1,
};

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,146 @@
7-Zip
~~~~~
License for use and distribution
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7-Zip Copyright (C) 1999-2026 Igor Pavlov.
The licenses for files are:
- 7z.dll:
- The "GNU LGPL" as main license for most of the code
- The "GNU LGPL" with "unRAR license restriction" for some code
- The "BSD 3-clause License" for some code
- The "BSD 2-clause License" for some code
- All other files: the "GNU LGPL".
Redistributions in binary form must reproduce related license information from this file.
Note:
You can use 7-Zip on any computer, including a computer in a commercial
organization. You don't need to register or pay for 7-Zip.
GNU LGPL information
--------------------
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You can receive a copy of the GNU Lesser General Public License from
http://www.gnu.org/
BSD 3-clause License in 7-Zip code
----------------------------------
The "BSD 3-clause License" is used for the following code in 7z.dll
1) LZFSE data decompression.
That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
that also uses the "BSD 3-clause License".
2) ZSTD data decompression.
that code was developed using original zstd decoder code as reference code.
The original zstd decoder code was developed by Facebook Inc,
that also uses the "BSD 3-clause License".
Copyright (c) 2015-2016, Apple Inc. All rights reserved.
Copyright (c) Facebook, Inc. All rights reserved.
Copyright (c) 2023-2026 Igor Pavlov.
Text of the "BSD 3-clause License"
----------------------------------
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
BSD 2-clause License in 7-Zip code
----------------------------------
The "BSD 2-clause License" is used for the XXH64 code in 7-Zip.
XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet.
Copyright (c) 2012-2021 Yann Collet.
Copyright (c) 2023-2026 Igor Pavlov.
Text of the "BSD 2-clause License"
----------------------------------
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
unRAR license restriction
-------------------------
The decompression engine for RAR archives was developed using source
code of unRAR program.
All copyrights to original unRAR code are owned by Alexander Roshal.
The license for original unRAR code has the following restriction:
The unRAR sources cannot be used to re-create the RAR compression algorithm,
which is proprietary. Distribution of modified unRAR sources in separate form
or as a part of other software is permitted, provided that it is clearly
stated in the documentation and source comments that the code may
not be used to develop a RAR (WinRAR) compatible archiver.
--

View file

@ -0,0 +1,21 @@
## Vendored 7-Zip extractor
- Binaries:
- `7z.exe`
- `7z.dll`
- Upstream: 7-Zip 26.00 x64
- Canonical source package: `https://github.com/ip7z/7zip/releases/download/26.00/7z2600-x64.exe`
- Reproducible extraction source: `https://github.com/ip7z/7zip/releases/download/26.00/7z2600-x64.msi`
- Upstream download page: `https://www.7-zip.org/download.html`
- License: `License.txt`
- SHA256 (`7z.exe`): `4A41AA37786C7EAE7451E81C2C97458D5D1AE5A3A8154637A0D5F77ADC05E619`
- SHA256 (`7z.dll`): `BBD705E3B58CA7677C1E9E67473F166A6712DA034DCB567D571FBB67507A443F`
- SHA256 (`License.txt`): `32369594A3A9F7C643D124035120EAA6A7707E75E57C4386EF509F801447BC49`
These binaries are vendored only for the Windows tools-pack installer build.
The installer embeds `7z.exe` and `7z.dll` temporarily to extract the packaged
Open Design `.7z` payload during installation.
These files remain subject to the upstream 7-Zip license and are not relicensed
under this repository's Apache-2.0 license. In particular, see `License.txt` for
the GNU LGPL, BSD, and unRAR restriction notices.

296
tools/pack/src/cache.ts Normal file
View file

@ -0,0 +1,296 @@
import { createHash, randomUUID } from "node:crypto";
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
import { withDirectoryLock } from "./lock.js";
export const CACHE_SCHEMA_VERSION = 1;
export type CacheInvalidation = {
reason: string;
};
export type CacheManifest<TMetadata> = {
createdAt: string;
key: string;
nodeId: string;
outputs: string[];
payloadMetadata: TMetadata;
schemaVersion: number;
};
export type CacheAcquireResult<TMetadata> = CacheManifest<TMetadata> & {
entryPath: string;
};
export type CacheAcquireReport = {
durationMs: number;
entryPath: string;
key: string;
keyHash: string;
materialized: Array<{ from: string; to: string }>;
nodeId: string;
outputs: string[];
reason: string | null;
status: "hit" | "miss" | "stale";
};
export type CacheReport = {
entries: CacheAcquireReport[];
root: string;
};
export type CacheBuildContext = {
entryRoot: string;
};
export type CacheNode<TMetadata> = {
build: (context: CacheBuildContext) => Promise<TMetadata>;
id: string;
invalidate: (context: { entryRoot: string; manifest: CacheManifest<TMetadata> }) => Promise<CacheInvalidation | null>;
key: string;
outputs: string[];
};
export type CacheMaterializeTarget = {
from: string;
to: string;
};
function normalizeRelativePath(path: string): string {
return path.split("\\").join("/");
}
function safePathToken(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
}
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
async function pathExists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch {
return false;
}
}
async function assertOutputsExist(entryRoot: string, outputs: string[]): Promise<CacheInvalidation | null> {
for (const output of outputs) {
if (!(await pathExists(join(entryRoot, output)))) {
return { reason: `missing output: ${output}` };
}
}
return null;
}
async function readManifest<TMetadata>(manifestPath: string): Promise<CacheManifest<TMetadata> | null> {
try {
return JSON.parse(await readFile(manifestPath, "utf8")) as CacheManifest<TMetadata>;
} catch {
return null;
}
}
async function writeManifest<TMetadata>(
manifestPath: string,
manifest: CacheManifest<TMetadata>,
): Promise<void> {
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
}
export class ToolPackCache {
readonly #entries: CacheAcquireReport[] = [];
constructor(readonly root: string) {}
report(): CacheReport {
return {
entries: [...this.#entries],
root: this.root,
};
}
async acquire<TMetadata>({
materialize,
node,
}: {
materialize: CacheMaterializeTarget[];
node: CacheNode<TMetadata>;
}): Promise<CacheAcquireResult<TMetadata>> {
const startedAt = Date.now();
const keyHash = hashText(`${node.id}\n${node.key}`);
const entryPath = join(this.root, "entries", safePathToken(node.id), keyHash);
const manifestPath = join(entryPath, "manifest.json");
const outputs = node.outputs.map(normalizeRelativePath);
let status: CacheAcquireReport["status"] = "hit";
let reason: string | null = null;
const materialized: CacheAcquireReport["materialized"] = [];
const manifest = await withDirectoryLock(join(this.root, "locks"), "global", async () => {
await mkdir(dirname(entryPath), { recursive: true });
const existingManifest = await readManifest<TMetadata>(manifestPath);
const manifestMissing = existingManifest == null;
const schemaInvalid = !manifestMissing && existingManifest.schemaVersion !== CACHE_SCHEMA_VERSION;
const idInvalid = !manifestMissing && existingManifest.nodeId !== node.id;
const keyInvalid = !manifestMissing && existingManifest.key !== node.key;
const outputInvalid = existingManifest == null ? { reason: "missing manifest" } : await assertOutputsExist(entryPath, outputs);
const customInvalid = existingManifest == null || schemaInvalid || idInvalid || keyInvalid || outputInvalid != null
? null
: await node.invalidate({ entryRoot: entryPath, manifest: existingManifest });
const invalidation = manifestMissing
? { reason: "missing manifest" }
: schemaInvalid
? { reason: "schema mismatch" }
: idInvalid
? { reason: "node id mismatch" }
: keyInvalid
? { reason: "key mismatch" }
: outputInvalid ?? customInvalid;
const manifest = existingManifest != null && invalidation == null
? existingManifest
: null;
const nextManifest = manifest ?? await (async () => {
status = existingManifest == null ? "miss" : "stale";
reason = invalidation?.reason ?? "missing manifest";
const stagingPath = join(dirname(entryPath), `${basename(entryPath)}.tmp-${process.pid}-${randomUUID()}`);
await rm(stagingPath, { force: true, recursive: true });
await mkdir(stagingPath, { recursive: true });
try {
const payloadMetadata = await node.build({ entryRoot: stagingPath });
const missingOutput = await assertOutputsExist(stagingPath, outputs);
if (missingOutput != null) throw new Error(`cache node ${node.id} build did not produce ${missingOutput.reason}`);
const builtManifest: CacheManifest<TMetadata> = {
createdAt: new Date().toISOString(),
key: node.key,
nodeId: node.id,
outputs,
payloadMetadata,
schemaVersion: CACHE_SCHEMA_VERSION,
};
await writeManifest(join(stagingPath, "manifest.json"), builtManifest);
await rm(entryPath, { force: true, recursive: true });
await rename(stagingPath, entryPath);
return builtManifest;
} catch (error) {
await rm(stagingPath, { force: true, recursive: true });
throw error;
}
})();
for (const target of materialize) {
const sourcePath = join(entryPath, target.from);
await rm(target.to, { force: true, recursive: true });
await mkdir(dirname(target.to), { recursive: true });
await cp(sourcePath, target.to, { recursive: true });
materialized.push({ from: normalizeRelativePath(target.from), to: target.to });
}
return nextManifest;
});
this.#entries.push({
durationMs: Date.now() - startedAt,
entryPath,
key: node.key,
keyHash,
materialized,
nodeId: node.id,
outputs,
reason,
status,
});
return { ...manifest, entryPath };
}
async readHit<TMetadata>({
materialize,
node,
}: {
materialize: CacheMaterializeTarget[];
node: CacheNode<TMetadata>;
}): Promise<CacheAcquireResult<TMetadata> | null> {
const startedAt = Date.now();
const keyHash = hashText(`${node.id}\n${node.key}`);
const entryPath = join(this.root, "entries", safePathToken(node.id), keyHash);
const manifestPath = join(entryPath, "manifest.json");
const outputs = node.outputs.map(normalizeRelativePath);
const materialized: CacheAcquireReport["materialized"] = [];
const manifest = await withDirectoryLock(join(this.root, "locks"), "global", async () => {
const existingManifest = await readManifest<TMetadata>(manifestPath);
if (existingManifest == null) return null;
if (existingManifest.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
if (existingManifest.nodeId !== node.id) return null;
if (existingManifest.key !== node.key) return null;
if ((await assertOutputsExist(entryPath, outputs)) != null) return null;
if ((await node.invalidate({ entryRoot: entryPath, manifest: existingManifest })) != null) return null;
for (const target of materialize) {
const sourcePath = join(entryPath, target.from);
await rm(target.to, { force: true, recursive: true });
await mkdir(dirname(target.to), { recursive: true });
await cp(sourcePath, target.to, { recursive: true });
materialized.push({ from: normalizeRelativePath(target.from), to: target.to });
}
return existingManifest;
});
if (manifest == null) return null;
this.#entries.push({
durationMs: Date.now() - startedAt,
entryPath,
key: node.key,
keyHash,
materialized,
nodeId: node.id,
outputs,
reason: null,
status: "hit",
});
return { ...manifest, entryPath };
}
}
export async function hashPath(
path: string,
options: { ignoreDirectoryNames?: readonly string[] } = {},
): Promise<string> {
const hash = createHash("sha256");
const ignoredDirectoryNames = new Set(options.ignoreDirectoryNames ?? ["node_modules"]);
async function visit(current: string, root: string): Promise<void> {
const metadata = await lstat(current);
const relativePath = normalizeRelativePath(relative(root, current));
hash.update(relativePath);
if (metadata.isSymbolicLink()) {
hash.update("symlink");
hash.update(await readlink(current));
return;
}
if (!metadata.isDirectory()) {
hash.update("file");
hash.update(await readFile(current));
return;
}
hash.update("dir");
const entries = (await readdir(current)).sort();
for (const entry of entries) {
if (ignoredDirectoryNames.has(entry)) continue;
await visit(join(current, entry), root);
}
}
await visit(path, dirname(path));
return hash.digest("hex");
}
export function hashJson(value: unknown): string {
return hashText(JSON.stringify(value));
}

View file

@ -20,6 +20,7 @@ export type ToolPackMacCompression = "store" | "normal" | "maximum";
export type ToolPackWebOutputMode = "server" | "standalone";
export type ToolPackCliOptions = {
cacheDir?: string;
containerized?: boolean;
dir?: string;
expr?: string;
@ -49,6 +50,7 @@ export type ToolPackRoots = {
namespaceBaseRoot: string;
namespaceRoot: string;
};
cacheRoot: string;
toolPackRoot: string;
};
@ -88,8 +90,9 @@ function resolveToolPackMacCompression(value: string | undefined): ToolPackMacCo
}
function resolveToolPackWebOutputMode(platform: ToolPackPlatform, value: string | undefined): ToolPackWebOutputMode {
// Standalone web output is wired for mac first; other platforms fall back to server mode until their paths are enabled.
if (platform !== "mac") return "server";
// Standalone web output is wired for desktop packaged platforms; Linux stays on
// the existing server output until its AppImage resource path is optimized.
if (platform === "linux") return "server";
if (value == null || value.length === 0) return "standalone";
if (value === "server" || value === "standalone") return value;
throw new Error(`unsupported OD_WEB_OUTPUT_MODE value: ${value}`);
@ -128,6 +131,7 @@ export function resolveToolPackConfig(
namespace: options.namespace ?? SIDECAR_DEFAULTS.namespace,
});
const toolPackRoot = resolve(options.dir ?? join(WORKSPACE_ROOT, ".tmp", "tools-pack"));
const cacheRoot = resolve(options.cacheDir ?? join(toolPackRoot, "cache"));
const outputRoot = join(toolPackRoot, "out");
const outputPlatformRoot = join(outputRoot, platform);
const outputNamespaceRoot = join(outputPlatformRoot, "namespaces", namespace);
@ -153,6 +157,7 @@ export function resolveToolPackConfig(
namespaceBaseRoot: runtimeNamespaceBaseRoot,
namespaceRoot: join(runtimeNamespaceBaseRoot, namespace),
},
cacheRoot,
toolPackRoot,
},
removeData: options.removeData === true,

View file

@ -23,7 +23,7 @@ import {
startPackedWinApp,
stopPackedWinApp,
uninstallPackedWinApp,
} from "./win.js";
} from "./win/index.js";
import {
cleanupPackedLinuxNamespace,
installPackedLinuxApp,
@ -59,6 +59,7 @@ type CacCommand = ReturnType<CAC["command"]>;
function addSharedOptions(command: CacCommand) {
return command
.option("--cache-dir <path>", "tools-pack cache directory")
.option("--dir <path>", "tools-pack root directory")
.option("--json", "print JSON")
.option("--namespace <name>", "runtime namespace")

View file

@ -71,13 +71,19 @@ type DockerUserMapping = {
gid: number;
};
function toDockerMountPath(value: string): string {
return value.replaceAll("\\", "/");
}
export function buildDockerArgs(
config: ToolPackConfig,
user: DockerUserMapping,
): string[] {
const dockerHome = join(config.roots.toolPackRoot, ".docker-home");
const electronCache = join(config.roots.toolPackRoot, ".docker-cache", "electron");
const electronBuilderCache = join(config.roots.toolPackRoot, ".docker-cache", "electron-builder");
const workspaceRoot = toDockerMountPath(config.workspaceRoot);
const toolPackRoot = toDockerMountPath(config.roots.toolPackRoot);
const dockerHome = toDockerMountPath(join(config.roots.toolPackRoot, ".docker-home"));
const electronCache = toDockerMountPath(join(config.roots.toolPackRoot, ".docker-cache", "electron"));
const electronBuilderCache = toDockerMountPath(join(config.roots.toolPackRoot, ".docker-cache", "electron-builder"));
// The tool-pack root is mounted at a fixed container path so the inner build
// can be told where to write output via `--dir /tools-pack`. Without this
@ -127,9 +133,9 @@ export function buildDockerArgs(
"--user",
`${user.uid}:${user.gid}`,
"-v",
`${config.workspaceRoot}:/project`,
`${workspaceRoot}:/project`,
"-v",
`${config.roots.toolPackRoot}:/tools-pack`,
`${toolPackRoot}:/tools-pack`,
"-v",
`${dockerHome}:/home/builder`,
"-v",

45
tools/pack/src/lock.ts Normal file
View file

@ -0,0 +1,45 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
const LOCK_POLL_MS = 100;
const LOCK_TIMEOUT_MS = 10 * 60 * 1000;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function withDirectoryLock<T>(
lockRoot: string,
lockName: string,
callback: () => Promise<T>,
): Promise<T> {
await mkdir(lockRoot, { recursive: true });
const lockPath = join(lockRoot, `${lockName}.lock`);
const startedAt = Date.now();
while (true) {
try {
await mkdir(lockPath);
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
const owner = await readFile(join(lockPath, "owner.json"), "utf8").catch(() => null);
throw new Error(`timed out waiting for lock ${lockPath}${owner == null ? "" : ` owned by ${owner}`}`);
}
await sleep(LOCK_POLL_MS);
}
}
try {
await writeFile(
join(lockPath, "owner.json"),
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`,
"utf8",
);
return await callback();
} finally {
await rm(lockPath, { force: true, recursive: true });
}
}

View file

@ -0,0 +1,41 @@
import { createHash } from "node:crypto";
import { lstat, readFile, readdir, readlink } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
function normalizeRelativePath(path: string): string {
return path.split("\\").join("/");
}
async function readNormalizedFile(filePath: string): Promise<Buffer | string> {
return await readFile(filePath);
}
export async function hashPackageSourcePath(path: string): Promise<string> {
const hash = createHash("sha256");
const ignoredDirectoryNames = new Set([".next", ".od", "dist", "node_modules", "out"]);
async function visit(current: string, root: string): Promise<void> {
const metadata = await lstat(current);
const relativePath = normalizeRelativePath(relative(root, current));
hash.update(relativePath);
if (metadata.isSymbolicLink()) {
hash.update("symlink");
hash.update(await readlink(current));
return;
}
if (!metadata.isDirectory()) {
hash.update("file");
hash.update(await readNormalizedFile(current));
return;
}
hash.update("dir");
const entries = (await readdir(current)).sort();
for (const entry of entries) {
if (ignoredDirectoryNames.has(entry)) continue;
await visit(join(current, entry), root);
}
}
await visit(path, dirname(path));
return hash.digest("hex");
}

View file

@ -35,11 +35,14 @@ export const macResources = {
icon: join(resourcesRoot, "mac", "icon.icns"),
iconPng: join(resourcesRoot, "mac", "icon.png"),
notarizeHook: join(resourcesRoot, "mac", "notarize.cjs"),
webStandaloneAfterPackHook: join(resourcesRoot, "mac", "web-standalone-after-pack.cjs"),
webStandaloneAfterPackHook: join(resourcesRoot, "web-standalone-after-pack.cjs"),
} as const;
export const winResources = {
icon: join(resourcesRoot, "win", "icon.ico"),
sevenZipDll: join(resourcesRoot, "win", "7zip", "7z.dll"),
sevenZipExe: join(resourcesRoot, "win", "7zip", "7z.exe"),
webStandaloneAfterPackHook: join(resourcesRoot, "web-standalone-after-pack.cjs"),
} as const;
export const linuxResources = {

File diff suppressed because it is too large Load diff

273
tools/pack/src/win/app.ts Normal file
View file

@ -0,0 +1,273 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { promisify } from "node:util";
import { rebuild } from "@electron/rebuild";
import { createCommandInvocation, createPackageManagerInvocation } from "@open-design/platform";
import { hashJson, hashPath, ToolPackCache } from "../cache.js";
import type { ToolPackConfig } from "../config.js";
import { hashPackageSourcePath } from "../package-source-hash.js";
import { ensureWorkspaceBuildArtifacts } from "../workspace-build.js";
import {
ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
ELECTRON_REBUILD_MODE,
ELECTRON_REBUILD_NATIVE_MODULES,
INTERNAL_PACKAGES,
PRODUCT_NAME,
} from "./constants.js";
import { readPackagedVersion, writePackagedConfig } from "./manifest.js";
import { pathExists, removeTree } from "./fs.js";
import type {
PackedTarballInfo,
PackedTarballsCacheMetadata,
PackedTarballsCacheResult,
PackagedAppCacheMetadata,
PackagedAppCacheResult,
WinPaths,
} from "./types.js";
const execFileAsync = promisify(execFile);
async function runPnpm(config: ToolPackConfig, args: string[], extraEnv: NodeJS.ProcessEnv = {}): Promise<void> {
const invocation = createPackageManagerInvocation(args, process.env);
await execFileAsync(invocation.command, invocation.args, {
cwd: config.workspaceRoot,
env: { ...process.env, ...extraEnv },
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
}
async function runNpmInstall(appRoot: string): Promise<void> {
const invocation = createCommandInvocation({
args: ["install", "--omit=dev", "--no-package-lock"],
command: process.platform === "win32" ? "npm.cmd" : "npm",
});
await execFileAsync(invocation.command, invocation.args, {
cwd: appRoot,
env: process.env,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
}
async function runElectronRebuild(config: ToolPackConfig, appRoot: string): Promise<void> {
const foundModules = new Set<string>();
const rebuildResult = rebuild({
arch: "x64",
buildFromSource: ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
buildPath: appRoot,
electronVersion: config.electronVersion,
force: true,
mode: ELECTRON_REBUILD_MODE,
onlyModules: [...ELECTRON_REBUILD_NATIVE_MODULES],
platform: "win32",
projectRootPath: appRoot,
});
rebuildResult.lifecycle.on("modules-found", (modules: string[]) => {
for (const moduleName of modules) foundModules.add(moduleName);
process.stderr.write(`[tools-pack] rebuilding Electron ABI modules: ${modules.join(", ") || "none"}\n`);
});
await rebuildResult;
const missingModules = ELECTRON_REBUILD_NATIVE_MODULES.filter((moduleName) => !foundModules.has(moduleName));
if (missingModules.length > 0) {
throw new Error(`Electron ABI rebuild did not discover required native module(s): ${missingModules.join(", ")}`);
}
}
function nativeRebuildOutputPath(appRoot: string): string {
return join(appRoot, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
}
async function validateNativeRebuildOutput(appRoot: string): Promise<string | null> {
const nativePath = nativeRebuildOutputPath(appRoot);
try {
const metadata = await stat(nativePath);
if (metadata.size < 100_000) return `native module output is too small: ${nativePath}`;
return null;
} catch {
return `native module output is missing: ${nativePath}`;
}
}
async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts");
const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null);
await runPnpm(config, ["--filter", "@open-design/contracts", "build"]);
await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]);
await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]);
await runPnpm(config, ["--filter", "@open-design/platform", "build"]);
await runPnpm(config, ["--filter", "@open-design/daemon", "build"]);
try {
await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: config.webOutputMode });
await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]);
} finally {
if (previousWebNextEnv == null) await rm(webNextEnvPath, { force: true });
else await writeFile(webNextEnvPath, previousWebNextEnv, "utf8");
}
await runPnpm(config, ["--filter", "@open-design/desktop", "build"]);
await runPnpm(config, ["--filter", "@open-design/packaged", "build"]);
}
export async function ensureWinWorkspaceBuild(config: ToolPackConfig, cache: ToolPackCache): Promise<void> {
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
await buildWorkspaceArtifacts(config);
});
}
export async function createWorkspaceTarballsCacheKey(config: ToolPackConfig): Promise<string> {
const packageHashes: Record<string, string> = {};
for (const packageInfo of INTERNAL_PACKAGES) {
packageHashes[packageInfo.name] = await hashPackageSourcePath(join(config.workspaceRoot, packageInfo.directory));
}
const rootPackageJson = JSON.parse(await readFile(join(config.workspaceRoot, "package.json"), "utf8")) as {
packageManager?: unknown;
};
return hashJson({
node: "win.workspace-tarballs",
packageHashes,
packageManager: rootPackageJson.packageManager,
pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")),
schemaVersion: 4,
webOutputMode: config.webOutputMode,
});
}
export async function collectWorkspaceTarballs(
config: ToolPackConfig,
paths: WinPaths,
cache: ToolPackCache,
): Promise<PackedTarballsCacheResult> {
const key = await createWorkspaceTarballsCacheKey(config);
const node = {
id: "win.workspace-tarballs",
key,
outputs: ["tarballs"],
invalidate: async () => null,
build: async ({ entryRoot }: { entryRoot: string }): Promise<PackedTarballsCacheMetadata> => {
const tarballsRoot = join(entryRoot, "tarballs");
await mkdir(tarballsRoot, { recursive: true });
const packedTarballs: PackedTarballInfo[] = [];
for (const packageInfo of INTERNAL_PACKAGES) {
const beforeEntries = new Set(await readdir(tarballsRoot));
await runPnpm(config, ["-C", packageInfo.directory, "pack", "--pack-destination", tarballsRoot]);
const newEntries = (await readdir(tarballsRoot)).filter((entry) => !beforeEntries.has(entry));
if (newEntries.length !== 1 || newEntries[0] == null) {
throw new Error(`expected one tarball for ${packageInfo.name}, got ${newEntries.length}`);
}
packedTarballs.push({ fileName: newEntries[0], packageName: packageInfo.name });
}
return { tarballs: packedTarballs };
},
};
const manifest = await cache.acquire({
materialize: [{ from: "tarballs", to: paths.tarballsRoot }],
node,
});
return { key, tarballs: manifest.payloadMetadata.tarballs };
}
function createAssembledAppDependencies(
paths: Pick<WinPaths, "assembledAppRoot" | "tarballsRoot">,
packedTarballs: PackedTarballInfo[],
): Record<string, string> {
const tarballByPackage = Object.fromEntries(packedTarballs.map((entry) => [entry.packageName, entry.fileName] as const));
return Object.fromEntries(
INTERNAL_PACKAGES.map((packageInfo) => {
const tarball = tarballByPackage[packageInfo.name];
if (tarball == null) throw new Error(`missing tarball for ${packageInfo.name}`);
return [packageInfo.name, `file:${relative(paths.assembledAppRoot, join(paths.tarballsRoot, tarball))}`];
}),
);
}
async function writeAssembledAppEntrypoints(
paths: Pick<WinPaths, "assembledAppRoot" | "assembledMainEntryPath" | "assembledPackageJsonPath" | "tarballsRoot">,
packedTarballs: PackedTarballInfo[],
packagedVersion: string,
options: { dependencies?: Record<string, string> } = {},
): Promise<void> {
await mkdir(paths.assembledAppRoot, { recursive: true });
await writeFile(
paths.assembledPackageJsonPath,
`${JSON.stringify(
{
dependencies: options.dependencies ?? createAssembledAppDependencies(paths, packedTarballs),
description: "Open Design packaged runtime",
main: "./main.cjs",
name: "open-design-packaged-app",
private: true,
productName: PRODUCT_NAME,
version: packagedVersion,
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
paths.assembledMainEntryPath,
'import("@open-design/packaged").catch((error) => {\n console.error("packaged entry failed", error);\n process.exit(1);\n});\n',
"utf8",
);
}
export async function createWinPackagedAppCacheKey(
config: ToolPackConfig,
tarballsKey: string,
packedTarballs: PackedTarballInfo[],
): Promise<string> {
return hashJson({
arch: "x64",
electronVersion: config.electronVersion,
modules: ELECTRON_REBUILD_NATIVE_MODULES,
node: "win.packaged-app",
packedTarballs,
platform: "win32",
schemaVersion: 1,
tarballsKey,
webOutputMode: config.webOutputMode,
});
}
export async function prepareWinPackagedApp(
config: ToolPackConfig,
paths: WinPaths,
tarballs: PackedTarballsCacheResult,
cache: ToolPackCache,
): Promise<PackagedAppCacheResult> {
const packagedVersion = await readPackagedVersion(config);
await removeTree(join(config.roots.output.namespaceRoot, "assembled"));
const packedTarballs = tarballs.tarballs;
const key = await createWinPackagedAppCacheKey(config, tarballs.key, packedTarballs);
const node = {
id: "win.packaged-app",
key,
outputs: ["app"],
invalidate: async ({ entryRoot }: { entryRoot: string }) => {
const nativeValidationError = await validateNativeRebuildOutput(join(entryRoot, "app"));
return nativeValidationError == null ? null : { reason: nativeValidationError };
},
build: async ({ entryRoot }: { entryRoot: string }): Promise<PackagedAppCacheMetadata> => {
const appRoot = join(entryRoot, "app");
await writeAssembledAppEntrypoints(
{ ...paths, assembledAppRoot: appRoot, assembledMainEntryPath: join(appRoot, "main.cjs"), assembledPackageJsonPath: join(appRoot, "package.json") },
packedTarballs,
packagedVersion,
);
await runNpmInstall(appRoot);
await runElectronRebuild(config, appRoot);
const nativeValidationError = await validateNativeRebuildOutput(appRoot);
if (nativeValidationError != null) throw new Error(nativeValidationError);
return { packagedVersion };
},
};
const manifest = await cache.acquire({
materialize: [],
node,
});
await writePackagedConfig(config, paths, packagedVersion);
return { appRoot: join(manifest.entryPath, "app"), key, packagedVersion };
}

101
tools/pack/src/win/build.ts Normal file
View file

@ -0,0 +1,101 @@
import { createHash } from "node:crypto";
import { readFile, stat, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { ToolPackCache } from "../cache.js";
import type { ToolPackConfig } from "../config.js";
import {
collectWorkspaceTarballs,
createWinPackagedAppCacheKey,
ensureWinWorkspaceBuild,
prepareWinPackagedApp,
} from "./app.js";
import { PRODUCT_NAME } from "./constants.js";
import { pathExists } from "./fs.js";
import { runElectronBuilder } from "./builder.js";
import {
readBuiltAppManifest,
readPackagedVersion,
} from "./manifest.js";
import { resolveWinPaths } from "./paths.js";
import { collectWinSizeReport } from "./report.js";
import { copyWinIcon, prepareResourceTree } from "./resources.js";
import type { WinPackResult, WinPackTiming, WinPaths } from "./types.js";
async function writeLocalLatestYml(config: ToolPackConfig, paths: WinPaths): Promise<void> {
if (!(await pathExists(paths.setupPath))) return;
const packagedVersion = await readPackagedVersion(config);
const setupPayload = await readFile(paths.setupPath);
const setupMetadata = await stat(paths.setupPath);
const sha512 = createHash("sha512").update(setupPayload).digest("base64");
const setupName = basename(paths.setupPath);
await writeFile(
paths.latestYmlPath,
[
`version: ${JSON.stringify(packagedVersion)}`,
"files:",
` - url: ${JSON.stringify(setupName)}`,
` sha512: ${JSON.stringify(sha512)}`,
` size: ${setupMetadata.size}`,
`path: ${JSON.stringify(setupName)}`,
`sha512: ${JSON.stringify(sha512)}`,
`releaseDate: ${JSON.stringify(new Date().toISOString())}`,
"",
].join("\n"),
"utf8",
);
}
export async function packWin(config: ToolPackConfig): Promise<WinPackResult> {
const paths = resolveWinPaths(config);
const cache = new ToolPackCache(config.roots.cacheRoot);
const timings: WinPackTiming[] = [];
const runPhase = async <T>(phase: string, task: () => Promise<T>): Promise<T> => {
const startedAt = Date.now();
try {
return await task();
} finally {
timings.push({ durationMs: Date.now() - startedAt, phase });
}
};
await runPhase("workspace-build", async () => {
await ensureWinWorkspaceBuild(config, cache);
});
const resourceTree = await runPhase("resource-tree", async () =>
prepareResourceTree(config, paths, cache, { materialize: config.to !== "dir" })
);
await runPhase("win-icon", async () => {
await copyWinIcon(paths);
});
const tarballs = await runPhase("workspace-tarballs", async () => collectWorkspaceTarballs(config, paths, cache));
const packagedAppKey = await createWinPackagedAppCacheKey(config, tarballs.key, tarballs.tarballs);
let packagedAppRoot: string | null = null;
await runPhase("electron-builder", async () => {
await runElectronBuilder(config, paths, cache, packagedAppKey, async () => {
if (packagedAppRoot != null) return packagedAppRoot;
const packagedApp = await prepareWinPackagedApp(config, paths, tarballs, cache);
packagedAppRoot = packagedApp.appRoot;
return packagedAppRoot;
}, resourceTree);
});
await runPhase("latest-yml", async () => {
await writeLocalLatestYml(config, paths);
});
const builtApp = await readBuiltAppManifest(paths);
const sizeReport = await runPhase("size-report", async () => collectWinSizeReport(config, paths, builtApp));
return {
blockmapPath: (await pathExists(paths.blockmapPath)) ? paths.blockmapPath : null,
installerPath: (await pathExists(paths.setupPath)) ? paths.setupPath : null,
latestYmlPath: (await pathExists(paths.latestYmlPath)) ? paths.latestYmlPath : null,
outputRoot: config.roots.output.namespaceRoot,
resourceRoot: builtApp == null ? paths.resourceRoot : join(builtApp.unpackedRoot, "resources", "open-design"),
runtimeNamespaceRoot: config.roots.runtime.namespaceRoot,
cacheReport: cache.report(),
sizeReport,
timings,
to: config.to,
unpackedPath: builtApp?.unpackedRoot ?? ((await pathExists(paths.unpackedRoot)) ? paths.unpackedRoot : null),
webStandaloneHookAuditPath: (await pathExists(paths.webStandaloneHookAuditPath)) ? paths.webStandaloneHookAuditPath : null,
};
}

View file

@ -0,0 +1,312 @@
import { execFile } from "node:child_process";
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { hashJson, hashPath, ToolPackCache } from "../cache.js";
import type { ToolPackConfig } from "../config.js";
import { winResources } from "../resources.js";
import { buildCustomWinNsisInstaller } from "./custom-installer.js";
import {
ELECTRON_BUILDER_ASAR,
ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
ELECTRON_BUILDER_FILE_PATTERNS,
ELECTRON_BUILDER_NODE_GYP_REBUILD,
ELECTRON_BUILDER_NPM_REBUILD,
NSIS_INSTALLER_LANGUAGE_BY_WEB_LOCALE,
PRODUCT_NAME,
WEB_STANDALONE_HOOK_CONFIG_ENV,
WEB_STANDALONE_RESOURCE_NAME,
} from "./constants.js";
import { pathExists, removeTree } from "./fs.js";
import {
readPackagedVersion,
writeBuiltAppManifest,
writePackagedConfig,
} from "./manifest.js";
import { ensureNsisPersianLanguageAlias, writeNsisInclude } from "./nsis.js";
import { sanitizeNamespace } from "./paths.js";
import { resolveWinTargets } from "./report.js";
import type { ResourceTreeResult } from "./resources.js";
import type {
ElectronBuilderDirCacheMetadata,
WinBuiltAppManifest,
WinPaths,
} from "./types.js";
const execFileAsync = promisify(execFile);
async function assertWebStandaloneOutput(config: ToolPackConfig): Promise<void> {
const webRoot = join(config.workspaceRoot, "apps", "web");
const standaloneSourceRoot = join(webRoot, ".next", "standalone");
const candidates = [
join(standaloneSourceRoot, "apps", "web", "server.js"),
join(standaloneSourceRoot, "server.js"),
];
for (const candidate of candidates) {
if (await pathExists(candidate)) return;
}
throw new Error("Next.js standalone server output was not produced under apps/web/.next/standalone");
}
async function writeWebStandaloneHookConfig(config: ToolPackConfig, paths: WinPaths): Promise<string> {
const webRoot = join(config.workspaceRoot, "apps", "web");
await assertWebStandaloneOutput(config);
await mkdir(dirname(paths.webStandaloneHookConfigPath), { recursive: true });
await writeFile(
paths.webStandaloneHookConfigPath,
`${JSON.stringify(
{
auditReportPath: paths.webStandaloneHookAuditPath,
pruneCopiedSharp: true,
pruneRootNext: true,
pruneRootSharp: true,
resourceName: WEB_STANDALONE_RESOURCE_NAME,
standaloneSourceRoot: join(webRoot, ".next", "standalone"),
version: 1,
webPublicSourceRoot: join(webRoot, "public"),
webStaticSourceRoot: join(webRoot, ".next", "static"),
workspaceRoot: config.workspaceRoot,
},
null,
2,
)}\n`,
"utf8",
);
return paths.webStandaloneHookConfigPath;
}
async function runElectronBuilderRaw(config: ToolPackConfig, paths: WinPaths, projectDir: string): Promise<void> {
const namespaceToken = sanitizeNamespace(config.namespace);
const packagedVersion = await readPackagedVersion(config);
const webStandaloneHookConfigPath = config.webOutputMode === "standalone"
? await writeWebStandaloneHookConfig(config, paths)
: null;
const builderConfig = {
appId: "io.open-design.desktop",
afterPack: webStandaloneHookConfigPath == null ? undefined : winResources.webStandaloneAfterPackHook,
asar: ELECTRON_BUILDER_ASAR,
buildDependenciesFromSource: ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
compression: "maximum",
directories: { output: paths.appBuilderOutputRoot },
electronDist: config.electronDistPath,
electronVersion: config.electronVersion,
executableName: PRODUCT_NAME,
extraMetadata: {
main: "./main.cjs",
name: "open-design-packaged-app",
productName: PRODUCT_NAME,
version: packagedVersion,
},
extraResources: [
{ from: paths.resourceRoot, to: "open-design" },
{ from: paths.packagedConfigPath, to: "open-design-config.json" },
],
files: [...ELECTRON_BUILDER_FILE_PATTERNS],
forceCodeSigning: false,
icon: paths.winIconPath,
nodeGypRebuild: ELECTRON_BUILDER_NODE_GYP_REBUILD,
npmRebuild: ELECTRON_BUILDER_NPM_REBUILD,
nsis: {
allowElevation: false,
allowToChangeInstallationDirectory: true,
artifactName: `${PRODUCT_NAME}-${namespaceToken}-setup.\${ext}`,
createDesktopShortcut: true,
createStartMenuShortcut: true,
deleteAppDataOnUninstall: false,
displayLanguageSelector: false,
include: paths.nsisIncludePath,
installerLanguages: Object.values(NSIS_INSTALLER_LANGUAGE_BY_WEB_LOCALE),
language: "1033",
multiLanguageInstaller: true,
oneClick: false,
perMachine: false,
shortcutName: PRODUCT_NAME,
warningsAsErrors: false,
},
productName: PRODUCT_NAME,
publish: [{ provider: "generic", url: "https://updates.invalid/open-design" }],
win: {
artifactName: `${PRODUCT_NAME}-${namespaceToken}.\${ext}`,
icon: paths.winIconPath,
target: resolveWinTargets(config.to).map((target) => ({ arch: ["x64"], target })),
},
};
await removeTree(paths.appBuilderOutputRoot);
await mkdir(dirname(paths.appBuilderConfigPath), { recursive: true });
await writeNsisInclude(config, paths);
await writeFile(paths.appBuilderConfigPath, `${JSON.stringify(builderConfig, null, 2)}\n`, "utf8");
const build = async () => {
await execFileAsync(process.execPath, [
config.electronBuilderCliPath,
"--win",
"--projectDir",
projectDir,
"--config",
paths.appBuilderConfigPath,
"--publish",
"never",
], {
cwd: config.workspaceRoot,
env: {
...process.env,
CSC_IDENTITY_AUTO_DISCOVERY: "false",
...(webStandaloneHookConfigPath == null ? {} : { [WEB_STANDALONE_HOOK_CONFIG_ENV]: webStandaloneHookConfigPath }),
},
});
};
await ensureNsisPersianLanguageAlias(config);
try {
await build();
} catch (error) {
const output = `${(error as { stdout?: unknown }).stdout ?? ""}\n${(error as { stderr?: unknown }).stderr ?? ""}`;
if (output.includes("Persian.nlf") && await ensureNsisPersianLanguageAlias(config)) {
await build();
return;
}
throw error;
}
}
function createCacheLocalWinPaths(paths: WinPaths, entryRoot: string): WinPaths {
return {
...paths,
appBuilderConfigPath: join(entryRoot, "builder-config.json"),
appBuilderOutputRoot: join(entryRoot, "builder"),
nsisIncludePath: join(entryRoot, "nsis", "installer.nsh"),
webStandaloneHookAuditPath: join(entryRoot, "web-standalone-after-pack-audit.json"),
webStandaloneHookConfigPath: join(entryRoot, "web-standalone-after-pack-config.json"),
};
}
function rewriteAuditPaths(value: unknown, fromRoot: string, toRoot: string): unknown {
if (typeof value === "string") return value.split(fromRoot).join(toRoot);
if (Array.isArray(value)) return value.map((entry) => rewriteAuditPaths(entry, fromRoot, toRoot));
if (value == null || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, rewriteAuditPaths(entry, fromRoot, toRoot)]),
);
}
async function materializeCachedElectronBuilderAudit(entryRoot: string, paths: WinPaths): Promise<void> {
if (!(await pathExists(join(entryRoot, "web-standalone-after-pack-audit.json")))) return;
const raw = JSON.parse(await readFile(join(entryRoot, "web-standalone-after-pack-audit.json"), "utf8")) as unknown;
const appPath = typeof (raw as { appPath?: unknown }).appPath === "string"
? (raw as { appPath: string }).appPath
: null;
const sourceBuilderRoot = appPath == null ? join(entryRoot, "builder") : dirname(appPath);
await mkdir(dirname(paths.webStandaloneHookAuditPath), { recursive: true });
await writeFile(
paths.webStandaloneHookAuditPath,
`${JSON.stringify(rewriteAuditPaths(raw, sourceBuilderRoot, paths.appBuilderOutputRoot), null, 2)}\n`,
"utf8",
);
}
export async function materializeCachedUnpackedForInstaller(cachedUnpackedRoot: string, paths: WinPaths): Promise<WinBuiltAppManifest> {
await removeTree(paths.unpackedRoot);
await mkdir(dirname(paths.unpackedRoot), { recursive: true });
await cp(cachedUnpackedRoot, paths.unpackedRoot, { recursive: true });
await cp(paths.packagedConfigPath, join(paths.unpackedRoot, "resources", "open-design-config.json"));
return {
appBuilderOutputRoot: paths.appBuilderOutputRoot,
cacheEntryPath: null,
configPath: paths.packagedConfigPath,
executablePath: paths.unpackedExePath,
source: "namespace",
unpackedRoot: paths.unpackedRoot,
version: 1,
webStandaloneHookAuditPath: (await pathExists(paths.webStandaloneHookAuditPath)) ? paths.webStandaloneHookAuditPath : null,
};
}
export async function runElectronBuilder(
config: ToolPackConfig,
paths: WinPaths,
cache: ToolPackCache,
packagedAppKey: string,
getPackagedAppRoot: () => Promise<string>,
resourceTree: ResourceTreeResult,
): Promise<void> {
const packagedVersion = await readPackagedVersion(config);
const key = hashJson({
afterPackHook: config.webOutputMode === "standalone" ? await hashPath(winResources.webStandaloneAfterPackHook) : null,
asar: ELECTRON_BUILDER_ASAR,
buildDependenciesFromSource: ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
electronBuilderCliPath: config.electronBuilderCliPath,
electronVersion: config.electronVersion,
filePatterns: ELECTRON_BUILDER_FILE_PATTERNS,
node: "win.electron-builder-dir",
nodeGypRebuild: ELECTRON_BUILDER_NODE_GYP_REBUILD,
npmRebuild: ELECTRON_BUILDER_NPM_REBUILD,
packagedAppKey,
packagedConfigSchemaVersion: 1,
portable: config.portable,
platform: "win32",
resourceTreeKey: resourceTree.key,
schemaVersion: 4,
target: "dir",
webOutputMode: config.webOutputMode,
winIcon: await hashPath(winResources.icon),
});
const auditOutput = "web-standalone-after-pack-audit.json";
const node = {
id: "win.electron-builder-dir",
key,
outputs: ["builder", ...(config.webOutputMode === "standalone" ? [auditOutput] : [])],
invalidate: async () => null,
build: async ({ entryRoot }: { entryRoot: string }): Promise<ElectronBuilderDirCacheMetadata> => {
const packagedAppRoot = await getPackagedAppRoot();
await runElectronBuilderRaw(
{ ...config, to: "dir" },
{ ...createCacheLocalWinPaths(paths, entryRoot), resourceRoot: resourceTree.resourceRoot },
packagedAppRoot,
);
return { packagedAppKey, packagedVersion };
},
};
let manifest = await cache.readHit({
materialize: [],
node,
});
if (manifest == null) {
const packagedAppRoot = await getPackagedAppRoot();
manifest = await cache.acquire({
materialize: [],
node: {
...node,
build: async ({ entryRoot }: { entryRoot: string }): Promise<ElectronBuilderDirCacheMetadata> => {
await runElectronBuilderRaw(
{ ...config, to: "dir" },
{ ...createCacheLocalWinPaths(paths, entryRoot), resourceRoot: resourceTree.resourceRoot },
packagedAppRoot,
);
return { packagedAppKey, packagedVersion };
},
},
});
}
const cachedBuilderRoot = join(manifest.entryPath, "builder");
const cachedUnpackedRoot = join(cachedBuilderRoot, "win-unpacked");
const cachedExecutablePath = join(cachedUnpackedRoot, `${PRODUCT_NAME}.exe`);
await removeTree(paths.appBuilderOutputRoot);
await writePackagedConfig(config, paths, packagedVersion);
await materializeCachedElectronBuilderAudit(manifest.entryPath, paths);
await writeBuiltAppManifest(paths, {
appBuilderOutputRoot: cachedBuilderRoot,
cacheEntryPath: manifest.entryPath,
configPath: paths.packagedConfigPath,
executablePath: cachedExecutablePath,
source: "cache",
unpackedRoot: cachedUnpackedRoot,
webStandaloneHookAuditPath: (await pathExists(paths.webStandaloneHookAuditPath)) ? paths.webStandaloneHookAuditPath : null,
});
if (config.to === "nsis" || config.to === "all") {
await buildCustomWinNsisInstaller(config, paths, await materializeCachedUnpackedForInstaller(cachedUnpackedRoot, paths));
}
}

View file

@ -0,0 +1,41 @@
export const PRODUCT_NAME = "Open Design";
export const DESKTOP_LOG_ECHO_ENV = "OD_DESKTOP_LOG_ECHO";
export const WEB_STANDALONE_HOOK_CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG";
export const WEB_STANDALONE_RESOURCE_NAME = "open-design-web-standalone";
export const ELECTRON_BUILDER_ASAR = false;
export const ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE = false;
export const ELECTRON_BUILDER_NODE_GYP_REBUILD = false;
export const ELECTRON_BUILDER_NPM_REBUILD = false;
export const ELECTRON_REBUILD_MODE = "sequential" as const;
export const ELECTRON_REBUILD_NATIVE_MODULES = ["better-sqlite3"] as const;
export const ELECTRON_BUILDER_FILE_PATTERNS = [
"**/*",
"!**/node_modules/.bin",
"!**/node_modules/electron{,/**/*}",
"!**/*.map",
"!**/*.tsbuildinfo",
"!**/.next/cache",
"!**/.next/cache/**",
"!**/node_modules/better-sqlite3/build/Release/obj",
"!**/node_modules/better-sqlite3/build/Release/obj/**",
"!**/node_modules/better-sqlite3/deps",
"!**/node_modules/better-sqlite3/deps/**",
] as const;
export const NSIS_INSTALLER_LANGUAGE_BY_WEB_LOCALE = {
en: "en_US",
fa: "fa_IR",
"pt-BR": "pt_BR",
ru: "ru_RU",
"zh-CN": "zh_CN",
"zh-TW": "zh_TW",
} as const;
export const INTERNAL_PACKAGES = [
{ directory: "packages/contracts", name: "@open-design/contracts" },
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" },
{ directory: "apps/daemon", name: "@open-design/daemon" },
{ directory: "apps/web", name: "@open-design/web" },
{ directory: "apps/desktop", name: "@open-design/desktop" },
{ directory: "apps/packaged", name: "@open-design/packaged" },
] as const;

View file

@ -0,0 +1,559 @@
import { execFile } from "node:child_process";
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import type { ToolPackConfig } from "../config.js";
import { winResources } from "../resources.js";
import { PRODUCT_NAME } from "./constants.js";
import { pathExists } from "./fs.js";
import { resolveWinInstallIdentity } from "./identity.js";
import { readPackagedVersion } from "./manifest.js";
import { ensureNsisPersianLanguageAlias } from "./nsis.js";
import { sanitizeNamespace } from "./paths.js";
import type { WinBuiltAppManifest, WinPaths } from "./types.js";
const execFileAsync = promisify(execFile);
const NSIS_LANGUAGES = [
{ macro: "LANG_ENGLISH", name: "English" },
{ macro: "LANG_SIMPCHINESE", name: "SimpChinese" },
{ macro: "LANG_TRADCHINESE", name: "TradChinese" },
{ macro: "LANG_PORTUGUESEBR", name: "PortugueseBR" },
{ macro: "LANG_RUSSIAN", name: "Russian" },
{ macro: "LANG_PERSIAN", name: "Persian" },
] as const;
function escapeNsisString(value: string): string {
return value.replace(/\$/g, "$$").replace(/"/g, '$\\"').replace(/\r?\n/g, "$\\r$\\n");
}
function createNsisLanguageInserts(): string {
return NSIS_LANGUAGES.map((language) => `!insertmacro MUI_LANGUAGE "${language.name}"`).join("\n");
}
function createNsisLangString(
key: string,
english: string,
translations: Partial<Record<(typeof NSIS_LANGUAGES)[number]["macro"], string>> = {},
): string {
return NSIS_LANGUAGES
.map((language) => {
const value = translations[language.macro] ?? english;
return `LangString ${key} \${${language.macro}} "${escapeNsisString(value)}"`;
})
.join("\n");
}
async function findFirstExistingPath(candidates: string[]): Promise<string | null> {
for (const candidate of candidates) {
if (await pathExists(candidate)) return candidate;
}
return null;
}
async function findElectronBuilderMakensis(config: ToolPackConfig): Promise<string | null> {
const cacheRoots = [
process.env.ELECTRON_BUILDER_CACHE,
process.env.LOCALAPPDATA == null ? undefined : join(process.env.LOCALAPPDATA, "electron-builder", "Cache"),
process.env.APPDATA == null ? undefined : join(process.env.APPDATA, "electron-builder", "Cache"),
join(config.workspaceRoot, "node_modules", ".cache", "electron-builder"),
].filter((entry): entry is string => entry != null && entry.length > 0);
for (const cacheRoot of cacheRoots) {
const direct = await findFirstExistingPath([
join(cacheRoot, "nsis", "nsis-3.0.4.1-nsis-3.0.4.1", "makensis.exe"),
join(cacheRoot, "nsis", "nsis-3.0.4.1-nsis-3.0.4.1", "Bin", "makensis.exe"),
]);
if (direct != null) return direct;
}
return null;
}
async function resolveMakensisCommand(config: ToolPackConfig): Promise<string> {
const cached = await findElectronBuilderMakensis(config);
if (cached != null) return cached;
const candidates = [
"makensis.exe",
"makensis",
"C:\\Program Files (x86)\\NSIS\\makensis.exe",
"C:\\Program Files\\NSIS\\makensis.exe",
];
for (const candidate of candidates) {
try {
await execFileAsync(candidate, ["/VERSION"], { windowsHide: true });
return candidate;
} catch {
// Keep probing known locations.
}
}
throw new Error("makensis is required to build the Windows installer; install NSIS or populate the electron-builder NSIS cache");
}
async function writeInstallerScript(config: ToolPackConfig, paths: WinPaths): Promise<void> {
const identity = resolveWinInstallIdentity(config);
const productName = escapeNsisString(identity.displayName);
const exeName = escapeNsisString(identity.exeName);
const uninstallerName = escapeNsisString(identity.uninstallerName);
const shortcutName = escapeNsisString(identity.shortcutName);
const registryKey = escapeNsisString(identity.registryKey);
const appPathsKey = escapeNsisString(identity.appPathsKey);
const namespace = escapeNsisString(config.namespace);
const localDataRoot = `$APPDATA\\${escapeNsisString(PRODUCT_NAME)}\\namespaces\\${escapeNsisString(sanitizeNamespace(config.namespace))}`;
const nsisLogPath = escapeNsisString(paths.nsisLogPath);
await mkdir(dirname(paths.installerScriptPath), { recursive: true });
const script = `Unicode true
ManifestDPIAware true
RequestExecutionLevel user
!ifndef OUTPUT_EXE
!error "OUTPUT_EXE define is required"
!endif
!ifndef PAYLOAD_7Z
!error "PAYLOAD_7Z define is required"
!endif
!ifndef SEVEN_Z_EXE
!error "SEVEN_Z_EXE define is required"
!endif
!ifndef SEVEN_Z_DLL
!error "SEVEN_Z_DLL define is required"
!endif
!ifndef APP_ICON
!error "APP_ICON define is required"
!endif
!ifndef APP_VERSION
!error "APP_VERSION define is required"
!endif
!include "MUI2.nsh"
!include "LogicLib.nsh"
!include "nsDialogs.nsh"
!include "WinMessages.nsh"
Name "${productName}"
OutFile "\${OUTPUT_EXE}"
InstallDir "$LOCALAPPDATA\\Programs\\${productName}"
InstallDirRegKey HKCU "${registryKey}" "InstallLocation"
Icon "\${APP_ICON}"
UninstallIcon "\${APP_ICON}"
ShowInstDetails show
ShowUninstDetails hide
!define MUI_ABORTWARNING
!define MUI_ICON "\${APP_ICON}"
!define MUI_UNICON "\${APP_ICON}"
Page custom RunningInstancesPage RunningInstancesPageLeave
!insertmacro MUI_PAGE_WELCOME
!define MUI_PAGE_CUSTOMFUNCTION_LEAVE DirectoryPageLeave
!insertmacro MUI_PAGE_DIRECTORY
!undef MUI_PAGE_CUSTOMFUNCTION_LEAVE
!insertmacro MUI_PAGE_INSTFILES
!define MUI_FINISHPAGE_RUN "$INSTDIR\\${exeName}"
!define MUI_FINISHPAGE_RUN_TEXT "$(LaunchApp)"
!define MUI_FINISHPAGE_SHOWREADME
!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(CreateDesktopShortcut)"
!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
UninstPage custom un.UninstallOptionsPage un.UninstallOptionsPageLeave
!insertmacro MUI_UNPAGE_INSTFILES
${createNsisLanguageInserts()}
${createNsisLangString("CreateDesktopShortcut", "Create desktop shortcut", { LANG_SIMPCHINESE: "创建桌面快捷方式" })}
${createNsisLangString("LaunchApp", `Launch ${productName}`, { LANG_SIMPCHINESE: `启动 ${productName}` })}
${createNsisLangString("RemoveDesktopShortcut", "Remove desktop shortcut", { LANG_SIMPCHINESE: "删除桌面快捷方式" })}
${createNsisLangString("RemoveLocalData", "Delete local data for this installation", { LANG_SIMPCHINESE: "删除此安装的本地数据" })}
${createNsisLangString("UninstallOptionsTitle", "Uninstall options", { LANG_SIMPCHINESE: "卸载选项" })}
${createNsisLangString("UninstallOptionsSubtitle", "Choose which local items to remove.", { LANG_SIMPCHINESE: "选择要删除的本地项目。" })}
${createNsisLangString("RunningInstancesTitle", `${productName} is still running`, { LANG_SIMPCHINESE: `${productName} 仍在运行` })}
${createNsisLangString("RunningInstancesSubtitle", "Close it before continuing installation.", { LANG_SIMPCHINESE: "继续安装前需要关闭它。" })}
${createNsisLangString("RunningInstancesMessage", `${productName} must be closed before installation can continue.`, { LANG_SIMPCHINESE: `继续安装前需要关闭 ${productName}` })}
${createNsisLangString("CloseAndContinue", "Close and continue", { LANG_SIMPCHINESE: "关闭并继续" })}
${createNsisLangString("RunningInstancesCloseFailed", `${productName} could not be closed. Close it manually, then try again.`, { LANG_SIMPCHINESE: `无法关闭 ${productName}。请手动关闭后重试。` })}
${createNsisLangString("RunningInstancesSilentAbort", `${productName} is still running. Close it before running the installer silently.`, { LANG_SIMPCHINESE: `${productName} 仍在运行。请先关闭它,再运行静默安装。` })}
${createNsisLangString("ExistingInstallMessage", `${productName} is already installed in the selected folder. Choose OK to overwrite it, or Cancel to stop installation.`, { LANG_SIMPCHINESE: `所选文件夹中已经安装了 ${productName}。选择确定覆盖,或取消安装。` })}
${createNsisLangString("ExistingInstallSilentOverwrite", "Existing installation found; silent install will overwrite it.", { LANG_SIMPCHINESE: "发现已有安装;静默安装将覆盖它。" })}
Var RemoveDesktopShortcutCheckbox
Var RemoveLocalDataCheckbox
Var RemoveDesktopShortcutState
Var RemoveLocalDataState
Var RunningInstancesOutput
Var ExistingInstallLocation
Var LE
Var LT
Var LX
!macro LOG_PATH_STATE EVENT TARGET
StrCpy $LE "\${EVENT}"
StrCpy $LT "\${TARGET}"
Call LogPathState
!macroend
!macro UN_LOG_PATH_STATE EVENT TARGET
StrCpy $LE "\${EVENT}"
StrCpy $LT "\${TARGET}"
Call un.LogPathState
!macroend
Function LogInstallerEvent
Exch $0
Push $1
CreateDirectory "${escapeNsisString(dirname(paths.nsisLogPath))}"
FileOpen $1 "${nsisLogPath}" a
IfErrors done
FileSeek $1 0 END
FileWrite $1 "$0$\\r$\\n"
FileClose $1
done:
Pop $1
Pop $0
FunctionEnd
Function LogPathState
StrCpy $LX 0
IfFileExists "$LT" 0 check_dir
StrCpy $LX 1
Goto write
check_dir:
IfFileExists "$LT\\*.*" 0 write
StrCpy $LX 1
write:
Push "event=$LE target=$LT exists=$LX"
Call LogInstallerEvent
FunctionEnd
Function un.LogInstallerEvent
Exch $0
Push $1
CreateDirectory "${escapeNsisString(dirname(paths.nsisLogPath))}"
FileOpen $1 "${nsisLogPath}" a
IfErrors done
FileSeek $1 0 END
FileWrite $1 "$0$\\r$\\n"
FileClose $1
done:
Pop $1
Pop $0
FunctionEnd
Function un.LogPathState
StrCpy $LX 0
IfFileExists "$LT" 0 check_dir
StrCpy $LX 1
Goto write
check_dir:
IfFileExists "$LT\\*.*" 0 write
StrCpy $LX 1
write:
Push "event=$LE target=$LT exists=$LX"
Call un.LogInstallerEvent
FunctionEnd
Function un.onInit
StrCpy $RemoveDesktopShortcutState "\${BST_CHECKED}"
StrCpy $RemoveLocalDataState 0
FunctionEnd
Function DetectRunningInstances
Push $0
Push $1
nsExec::ExecToStack 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param($$install, $$registered); $$roots = @($$install, $$registered) | Where-Object { $$_ } | ForEach-Object { $$root = $$_.TrimEnd([char]92).ToLowerInvariant(); [pscustomobject]@{ Exact = $$root; Prefix = ($$root + [char]92) } } | Select-Object -Unique Exact, Prefix; $$matches = Get-CimInstance Win32_Process | Where-Object { $$matched = $$false; $$exe = $$_.ExecutablePath; if ($$null -ne $$exe) { $$exe = $$exe.ToLowerInvariant(); foreach ($$root in $$roots) { if ($$root.Exact -and (($$exe -eq $$root.Exact) -or $$exe.StartsWith($$root.Prefix))) { $$matched = $$true; break } } }; $$matched }; if ($$matches) { $$matches | ForEach-Object { [string]$$_.ProcessId + [char]32 + $$_.Name } } }" "$INSTDIR" "$ExistingInstallLocation"'
Pop $0
Pop $1
\${If} $0 == "0"
StrCpy $RunningInstancesOutput $1
\${Else}
StrCpy $RunningInstancesOutput ""
Push "running instance detection failed exit=$0 output=$1"
Call LogInstallerEvent
\${EndIf}
Pop $1
Pop $0
FunctionEnd
Function CloseRunningInstances
Push $0
Push $1
nsExec::ExecToStack 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { param($$install, $$registered); $$roots = @($$install, $$registered) | Where-Object { $$_ } | ForEach-Object { $$root = $$_.TrimEnd([char]92).ToLowerInvariant(); [pscustomobject]@{ Exact = $$root; Prefix = ($$root + [char]92) } } | Select-Object -Unique Exact, Prefix; $$matches = Get-CimInstance Win32_Process | Where-Object { $$matched = $$false; $$exe = $$_.ExecutablePath; if ($$null -ne $$exe) { $$exe = $$exe.ToLowerInvariant(); foreach ($$root in $$roots) { if ($$root.Exact -and (($$exe -eq $$root.Exact) -or $$exe.StartsWith($$root.Prefix))) { $$matched = $$true; break } } }; $$matched }; $$ids = @($$matches | ForEach-Object { $$_.ProcessId }); foreach ($$id in $$ids) { try { [void][System.Diagnostics.Process]::GetProcessById($$id).CloseMainWindow() } catch {} }; Start-Sleep -Milliseconds 1500; foreach ($$id in $$ids) { try { $$p = [System.Diagnostics.Process]::GetProcessById($$id); if (-not $$p.HasExited) { Stop-Process -Id $$id -Force -ErrorAction SilentlyContinue } } catch {} }; if ($$ids) { $$ids -join ([char]32) } }" "$INSTDIR" "$ExistingInstallLocation"'
Pop $0
Pop $1
Push "running instances close exit=$0 output=$1"
Call LogInstallerEvent
Pop $1
Pop $0
FunctionEnd
Function .onInit
SetShellVarContext current
ReadRegStr $ExistingInstallLocation HKCU "${registryKey}" "InstallLocation"
IfSilent silent_check no_existing_install
silent_check:
Call DetectRunningInstances
\${If} $RunningInstancesOutput != ""
Push "install aborted: running instances detected: $RunningInstancesOutput"
Call LogInstallerEvent
Abort "$(RunningInstancesSilentAbort)"
\${EndIf}
IfFileExists "$INSTDIR\\${exeName}" existing_install no_existing_install
existing_install:
IfSilent 0 no_existing_install
Push "$(ExistingInstallSilentOverwrite)"
Call LogInstallerEvent
Goto no_existing_install
cancel_install:
Push "install cancelled before file changes"
Call LogInstallerEvent
Abort
no_existing_install:
FunctionEnd
Function RunningInstancesPage
IfSilent done
Call DetectRunningInstances
\${If} $RunningInstancesOutput == ""
Abort
\${EndIf}
Push "running instances detected before install: $RunningInstancesOutput"
Call LogInstallerEvent
!insertmacro MUI_HEADER_TEXT "$(RunningInstancesTitle)" "$(RunningInstancesSubtitle)"
nsDialogs::Create 1018
Pop $0
\${If} $0 == error
Abort
\${EndIf}
\${NSD_CreateLabel} 0 0 100% 36u "$(RunningInstancesMessage)"
Pop $0
GetDlgItem $0 $HWNDPARENT 1
SendMessage $0 \${WM_SETTEXT} 0 "STR:$(CloseAndContinue)"
GetDlgItem $0 $HWNDPARENT 3
ShowWindow $0 0
nsDialogs::Show
done:
FunctionEnd
Function RunningInstancesPageLeave
Call CloseRunningInstances
Call DetectRunningInstances
\${If} $RunningInstancesOutput != ""
Push "running instances still detected after close: $RunningInstancesOutput"
Call LogInstallerEvent
MessageBox MB_OK|MB_ICONEXCLAMATION "$(RunningInstancesCloseFailed)"
Abort
\${EndIf}
FunctionEnd
Function DirectoryPageLeave
IfSilent done
IfFileExists "$INSTDIR\\${exeName}" existing_install done
existing_install:
MessageBox MB_OKCANCEL|MB_ICONQUESTION "$(ExistingInstallMessage)$\\r$\\n$\\r$\\n$INSTDIR" IDOK done IDCANCEL cancel_install
cancel_install:
Push "install cancelled at existing install confirmation"
Call LogInstallerEvent
Abort
done:
FunctionEnd
Function CreateDesktopShortcut
SetShellVarContext current
SetOutPath "$INSTDIR"
!insertmacro LOG_PATH_STATE "desktop_shortcut_before_create" "$DESKTOP\\${shortcutName}"
CreateShortCut "$DESKTOP\\${shortcutName}" "$INSTDIR\\${exeName}" "" "$INSTDIR\\${exeName}" 0
!insertmacro LOG_PATH_STATE "desktop_shortcut_after_create" "$DESKTOP\\${shortcutName}"
FunctionEnd
Function RemoveInstallDir
!insertmacro LOG_PATH_STATE "install_dir_before_remove" "$INSTDIR"
Push $0
nsExec::ExecToLog 'cmd.exe /d /s /c if exist "$INSTDIR" rmdir /s /q "\\\\?\\$INSTDIR"'
Pop $0
Push "install dir remove exit=$0"
Call LogInstallerEvent
Pop $0
!insertmacro LOG_PATH_STATE "install_dir_after_remove" "$INSTDIR"
FunctionEnd
Function un.UninstallOptionsPage
IfSilent done
!insertmacro MUI_HEADER_TEXT "$(UninstallOptionsTitle)" "$(UninstallOptionsSubtitle)"
nsDialogs::Create 1018
Pop $0
\${If} $0 == error
Abort
\${EndIf}
\${NSD_CreateCheckbox} 0 0 100% 12u "$(RemoveDesktopShortcut)"
Pop $RemoveDesktopShortcutCheckbox
\${NSD_Check} $RemoveDesktopShortcutCheckbox
\${NSD_CreateCheckbox} 0 18u 100% 12u "$(RemoveLocalData)"
Pop $RemoveLocalDataCheckbox
nsDialogs::Show
done:
FunctionEnd
Function un.UninstallOptionsPageLeave
StrCpy $RemoveDesktopShortcutState "\${BST_CHECKED}"
StrCpy $RemoveLocalDataState 0
IfSilent done
\${NSD_GetState} $RemoveDesktopShortcutCheckbox $RemoveDesktopShortcutState
\${NSD_GetState} $RemoveLocalDataCheckbox $RemoveLocalDataState
done:
FunctionEnd
Function un.RemoveInstallDirContents
!insertmacro UN_LOG_PATH_STATE "install_dir_before_remove" "$INSTDIR"
Push $0
nsExec::ExecToLog 'cmd.exe /d /s /c if exist "$INSTDIR" rmdir /s /q "\\\\?\\$INSTDIR"'
Pop $0
Push "install dir fast remove exit=$0"
Call un.LogInstallerEvent
Pop $0
!insertmacro UN_LOG_PATH_STATE "install_dir_after_remove" "$INSTDIR"
FunctionEnd
Function un.RemoveLocalDataRoot
!insertmacro UN_LOG_PATH_STATE "local_data_before_remove" "${localDataRoot}"
Push $0
nsExec::ExecToLog 'cmd.exe /d /s /c if exist "${localDataRoot}" rmdir /s /q "\\\\?\\${localDataRoot}"'
Pop $0
Push "local data remove exit=$0"
Call un.LogInstallerEvent
Pop $0
!insertmacro UN_LOG_PATH_STATE "local_data_after_remove" "${localDataRoot}"
FunctionEnd
Section "Install"
SetShellVarContext current
Push "install section start"
Call LogInstallerEvent
!insertmacro LOG_PATH_STATE "install_dir_before_install" "$INSTDIR"
!insertmacro LOG_PATH_STATE "installed_exe_before_install" "$INSTDIR\\${exeName}"
IfFileExists "$INSTDIR\\${exeName}" 0 prepare_install_dir
Call RemoveInstallDir
prepare_install_dir:
InitPluginsDir
SetOutPath "$PLUGINSDIR"
File "/oname=$PLUGINSDIR\\payload.7z" "\${PAYLOAD_7Z}"
File "/oname=$PLUGINSDIR\\7z.exe" "\${SEVEN_Z_EXE}"
File "/oname=$PLUGINSDIR\\7z.dll" "\${SEVEN_Z_DLL}"
CreateDirectory "$INSTDIR"
Push "payload extraction start"
Call LogInstallerEvent
nsExec::ExecToLog '"$PLUGINSDIR\\7z.exe" x -y "$PLUGINSDIR\\payload.7z" "-o$INSTDIR"'
Pop $0
Push "payload extraction exit=$0"
Call LogInstallerEvent
\${If} $0 != "0"
DetailPrint "7z extraction failed with exit code $0"
Abort
\${EndIf}
!insertmacro LOG_PATH_STATE "install_dir_after_extract" "$INSTDIR"
!insertmacro LOG_PATH_STATE "installed_exe_after_extract" "$INSTDIR\\${exeName}"
WriteUninstaller "$INSTDIR\\${uninstallerName}"
!insertmacro LOG_PATH_STATE "uninstaller_after_write" "$INSTDIR\\${uninstallerName}"
SetOutPath "$INSTDIR"
IfSilent 0 skip_silent_desktop_shortcut
!insertmacro LOG_PATH_STATE "desktop_shortcut_before_create" "$DESKTOP\\${shortcutName}"
CreateShortCut "$DESKTOP\\${shortcutName}" "$INSTDIR\\${exeName}" "" "$INSTDIR\\${exeName}" 0
!insertmacro LOG_PATH_STATE "desktop_shortcut_after_create" "$DESKTOP\\${shortcutName}"
skip_silent_desktop_shortcut:
!insertmacro LOG_PATH_STATE "start_menu_shortcut_before_create" "$SMPROGRAMS\\${shortcutName}"
CreateShortCut "$SMPROGRAMS\\${shortcutName}" "$INSTDIR\\${exeName}" "" "$INSTDIR\\${exeName}" 0
!insertmacro LOG_PATH_STATE "start_menu_shortcut_after_create" "$SMPROGRAMS\\${shortcutName}"
WriteRegStr HKCU "${registryKey}" "DisplayName" "${productName} \${APP_VERSION}"
WriteRegStr HKCU "${registryKey}" "DisplayVersion" "\${APP_VERSION}"
WriteRegStr HKCU "${registryKey}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "${registryKey}" "UninstallString" '"$INSTDIR\\${uninstallerName}" /currentuser'
WriteRegStr HKCU "${registryKey}" "QuietUninstallString" '"$INSTDIR\\${uninstallerName}" /currentuser /S'
WriteRegStr HKCU "${registryKey}" "DisplayIcon" "$INSTDIR\\${exeName},0"
WriteRegStr HKCU "${appPathsKey}" "" "$INSTDIR\\${exeName}"
Push "event=registry_after_write key=${registryKey} appPathsKey=${appPathsKey}"
Call LogInstallerEvent
Push "install section done"
Call LogInstallerEvent
SectionEnd
Section "Uninstall"
SetShellVarContext current
Push "uninstall section start"
Call un.LogInstallerEvent
IfSilent delete_desktop_shortcut check_desktop_shortcut_state
check_desktop_shortcut_state:
\${If} $RemoveDesktopShortcutState == \${BST_CHECKED}
Delete "$DESKTOP\\${shortcutName}"
\${EndIf}
Goto after_desktop_shortcut
delete_desktop_shortcut:
Delete "$DESKTOP\\${shortcutName}"
after_desktop_shortcut:
!insertmacro UN_LOG_PATH_STATE "desktop_shortcut_after_delete" "$DESKTOP\\${shortcutName}"
Delete "$SMPROGRAMS\\${shortcutName}"
!insertmacro UN_LOG_PATH_STATE "start_menu_shortcut_after_delete" "$SMPROGRAMS\\${shortcutName}"
DeleteRegKey HKCU "${registryKey}"
DeleteRegKey HKCU "${appPathsKey}"
Push "event=registry_after_delete key=${registryKey} appPathsKey=${appPathsKey}"
Call un.LogInstallerEvent
\${If} $RemoveLocalDataState == \${BST_CHECKED}
Call un.RemoveLocalDataRoot
\${EndIf}
Call un.RemoveInstallDirContents
Delete "$INSTDIR\\${uninstallerName}"
RMDir "$INSTDIR"
!insertmacro UN_LOG_PATH_STATE "install_dir_after_final_rmdir" "$INSTDIR"
Push "uninstall section done"
Call un.LogInstallerEvent
SectionEnd
`;
await writeFile(paths.installerScriptPath, `\uFEFF${script}`, "utf8");
}
export async function buildCustomWinNsisInstaller(
config: ToolPackConfig,
paths: WinPaths,
builtApp: WinBuiltAppManifest,
): Promise<void> {
if (process.platform !== "win32") throw new Error("Windows installer build must run on Windows");
const makensisCommand = await resolveMakensisCommand(config);
const packagedVersion = await readPackagedVersion(config);
await ensureNsisPersianLanguageAlias(config);
await mkdir(dirname(paths.installerPayloadPath), { recursive: true });
await mkdir(dirname(paths.setupPath), { recursive: true });
await rm(paths.installerPayloadPath, { force: true });
await rm(paths.setupPath, { force: true });
await execFileAsync(winResources.sevenZipExe, ["a", "-t7z", "-mx=1", "-ms=off", paths.installerPayloadPath, ".\\*"], {
cwd: builtApp.unpackedRoot,
windowsHide: true,
});
await stat(paths.installerPayloadPath);
await writeInstallerScript(config, paths);
await execFileAsync(makensisCommand, [
"/V2",
`/DAPP_VERSION=${packagedVersion}`,
`/DOUTPUT_EXE=${paths.setupPath}`,
`/DPAYLOAD_7Z=${paths.installerPayloadPath}`,
`/DSEVEN_Z_EXE=${winResources.sevenZipExe}`,
`/DSEVEN_Z_DLL=${winResources.sevenZipDll}`,
`/DAPP_ICON=${paths.winIconPath}`,
paths.installerScriptPath,
], {
cwd: dirname(paths.installerScriptPath),
windowsHide: true,
});
await stat(paths.setupPath);
}

134
tools/pack/src/win/fs.ts Normal file
View file

@ -0,0 +1,134 @@
import { access, lstat, readdir, rm, stat } from "node:fs/promises";
import { basename, isAbsolute, join, relative, resolve } from "node:path";
export async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
export function toPosixPath(value: string): string {
return value.replaceAll("\\", "/");
}
export async function sizePathBytes(
path: string,
options: { includeFile?: (path: string) => boolean } = {},
): Promise<number> {
const metadata = await lstat(path).catch(() => null);
if (metadata == null) return 0;
if (!metadata.isDirectory()) {
return options.includeFile == null || options.includeFile(toPosixPath(path)) ? metadata.size : 0;
}
const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
let total = 0;
for (const entry of entries) {
total += await sizePathBytes(join(path, entry.name), options);
}
return total;
}
export async function sizeExistingFileBytes(path: string): Promise<number | null> {
const metadata = await stat(path).catch(() => null);
return metadata == null ? null : metadata.size;
}
function normalizeAbsolutePath(path: string): string {
return resolve(path);
}
function isWithinPath(parent: string, child: string): boolean {
const relativePath = relative(parent, child);
return relativePath.length === 0 || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
export class PathSizeIndex {
readonly #childDirectoriesByPath = new Map<string, string[]>();
readonly #fileEntries: Array<{ bytes: number; path: string; posixPath: string }> = [];
readonly #sizeByPath = new Map<string, number>();
private constructor(readonly root: string) {}
static async create(root: string): Promise<PathSizeIndex> {
const index = new PathSizeIndex(normalizeAbsolutePath(root));
await index.#visit(index.root);
return index;
}
async #visit(path: string): Promise<number> {
const normalizedPath = normalizeAbsolutePath(path);
const metadata = await lstat(normalizedPath).catch(() => null);
if (metadata == null) {
this.#sizeByPath.set(normalizedPath, 0);
return 0;
}
if (!metadata.isDirectory()) {
this.#sizeByPath.set(normalizedPath, metadata.size);
this.#fileEntries.push({ bytes: metadata.size, path: normalizedPath, posixPath: toPosixPath(normalizedPath) });
return metadata.size;
}
const entries = await readdir(normalizedPath, { withFileTypes: true }).catch(() => []);
const childDirectories: string[] = [];
const childSizes = await Promise.all(
entries.map(async (entry) => {
const childPath = join(normalizedPath, entry.name);
if (entry.isDirectory()) childDirectories.push(normalizeAbsolutePath(childPath));
return await this.#visit(childPath);
}),
);
const total = childSizes.reduce((sum, childSize) => sum + childSize, 0);
this.#childDirectoriesByPath.set(normalizedPath, childDirectories);
this.#sizeByPath.set(normalizedPath, total);
return total;
}
sizePathBytes(path: string, options: { includeFile?: (path: string) => boolean } = {}): number {
const normalizedPath = normalizeAbsolutePath(path);
if (options.includeFile == null) return this.#sizeByPath.get(normalizedPath) ?? 0;
let total = 0;
for (const entry of this.#fileEntries) {
if (isWithinPath(normalizedPath, entry.path) && options.includeFile(entry.posixPath)) total += entry.bytes;
}
return total;
}
sumChildDirectorySizes(path: string, includeChild: (name: string) => boolean): number {
const normalizedPath = normalizeAbsolutePath(path);
const childDirectories = this.#childDirectoriesByPath.get(normalizedPath) ?? [];
let total = 0;
for (const childPath of childDirectories) {
if (includeChild(basename(childPath))) total += this.#sizeByPath.get(childPath) ?? 0;
}
return total;
}
}
export async function sumChildDirectorySizes(path: string, includeChild: (name: string) => boolean): Promise<number> {
const entries = await readdir(path, { withFileTypes: true }).catch(() => []);
let total = 0;
for (const entry of entries) {
if (!entry.isDirectory() || !includeChild(entry.name)) continue;
total += await sizePathBytes(join(path, entry.name));
}
return total;
}
export async function removeTree(filePath: string): Promise<void> {
await rm(filePath, { force: true, maxRetries: 20, recursive: true, retryDelay: 250 });
}
export async function listDirectories(root: string): Promise<string[]> {
try {
const entries = await readdir(root, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
} catch {
return [];
}
}

View file

@ -0,0 +1,39 @@
import { SIDECAR_DEFAULTS } from "@open-design/sidecar-proto";
import type { ToolPackConfig } from "../config.js";
import { PRODUCT_NAME } from "./constants.js";
export type WinInstallIdentity = {
appPathsKey: string;
displayName: string;
exeName: string;
registryKey: string;
shortcutName: string;
uninstallerName: string;
};
function isBetaNamespace(namespace: string): boolean {
return /(^|[-_.])beta($|[-_.])/i.test(namespace);
}
function sanitizeNamespace(value: string): string {
return value.replace(/[^A-Za-z0-9._-]+/g, "-");
}
export function resolveWinInstallIdentity(config: Pick<ToolPackConfig, "namespace">): WinInstallIdentity {
const namespaceToken = sanitizeNamespace(config.namespace);
const displayName = isBetaNamespace(config.namespace)
? `${PRODUCT_NAME} Beta`
: config.namespace === SIDECAR_DEFAULTS.namespace
? PRODUCT_NAME
: `${PRODUCT_NAME} ${namespaceToken}`;
return {
appPathsKey: `Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${displayName}.exe`,
displayName,
exeName: `${PRODUCT_NAME}.exe`,
registryKey: `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${PRODUCT_NAME}-${namespaceToken}`,
shortcutName: `${displayName}.lnk`,
uninstallerName: `Uninstall ${displayName}.exe`,
};
}

View file

@ -0,0 +1,27 @@
export { packWin } from "./build.js";
export {
cleanupPackedWinNamespace,
installPackedWinApp,
inspectPackedWinApp,
listPackedWinNamespaces,
readPackedWinLogs,
resetPackedWinNamespaces,
startPackedWinApp,
stopPackedWinApp,
uninstallPackedWinApp,
} from "./lifecycle.js";
export type {
WinCleanupResult,
WinInspectResult,
WinInstallResult,
WinListResult,
WinPackResult,
WinPackTiming,
WinRemovalTarget,
WinResetResult,
WinResidueObservation,
WinSizeReport,
WinStartResult,
WinStopResult,
WinUninstallResult,
} from "./types.js";

View file

@ -0,0 +1,429 @@
import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
APP_KEYS,
OPEN_DESIGN_SIDECAR_CONTRACT,
SIDECAR_MESSAGES,
SIDECAR_MODES,
SIDECAR_SOURCES,
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type SidecarStamp,
} from "@open-design/sidecar-proto";
import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
import {
collectProcessTreePids,
createProcessStampArgs,
listProcessSnapshots,
matchesStampedProcess,
readLogTail,
spawnBackgroundProcess,
stopProcesses,
} from "@open-design/platform";
import type { ToolPackConfig } from "../config.js";
import { DESKTOP_LOG_ECHO_ENV } from "./constants.js";
import { listDirectories, pathExists, removeTree } from "./fs.js";
import { readBuiltAppManifest } from "./manifest.js";
import { invokeNsis, runTimed } from "./nsis.js";
import {
createWinRemovalPlan,
resolveWinPaths,
resolveWinProductNamespaceRoot,
resolveWinProductUserDataRoot,
} from "./paths.js";
import { cleanupWinRegistryResidues, queryWinRegistryEntries, resolveWinRegisteredPaths } from "./registry.js";
import type {
WinCleanupResult,
WinInspectResult,
WinInstallResult,
WinInstallPayloadReport,
WinListResult,
WinResetResult,
WinResidueObservation,
WinStartResult,
WinStopResult,
WinUninstallResult,
WinPaths,
} from "./types.js";
const PACKAGED_CONFIG_PATH_ENV = "OD_PACKAGED_CONFIG_PATH";
function desktopStamp(config: ToolPackConfig): SidecarStamp {
return {
app: APP_KEYS.DESKTOP,
ipc: resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: config.namespace }),
mode: SIDECAR_MODES.RUNTIME,
namespace: config.namespace,
source: SIDECAR_SOURCES.TOOLS_PACK,
};
}
function desktopLogPath(config: ToolPackConfig): string {
return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log");
}
function desktopIdentityPath(config: ToolPackConfig): string {
return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json");
}
async function waitForDesktopStatus(config: ToolPackConfig, timeoutMs = 45_000): Promise<DesktopStatusSnapshot | null> {
const stamp = desktopStamp(config);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
return await requestJsonIpc<DesktopStatusSnapshot>(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 1000 });
} catch {
await new Promise((resolveWait) => setTimeout(resolveWait, 200));
}
}
return null;
}
function installArgs(config: ToolPackConfig, paths: WinPaths): string[] {
return [...(config.silent ? ["/S"] : []), `/D=${paths.installDir}`];
}
async function writeJsonMarker(filePath: string, payload: Record<string, unknown>): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
}
async function collectFileTreeStats(root: string): Promise<{ fileCount: number; totalBytes: number }> {
const metadata = await stat(root).catch(() => null);
if (metadata == null) return { fileCount: 0, totalBytes: 0 };
if (!metadata.isDirectory()) return { fileCount: 1, totalBytes: metadata.size };
const children = await readdir(root, { withFileTypes: true }).catch(() => []);
const childStats = await Promise.all(children.map((child) => collectFileTreeStats(join(root, child.name))));
return childStats.reduce(
(total, entry) => ({
fileCount: total.fileCount + entry.fileCount,
totalBytes: total.totalBytes + entry.totalBytes,
}),
{ fileCount: 0, totalBytes: 0 },
);
}
async function collectInstallPayloadReport(paths: WinPaths): Promise<WinInstallPayloadReport> {
const topLevelEntries = await readdir(paths.installDir, { withFileTypes: true }).catch(() => []);
const topLevel = await Promise.all(
topLevelEntries.map(async (entry) => {
const entryPath = join(paths.installDir, entry.name);
const stats = await collectFileTreeStats(entryPath);
return { bytes: stats.totalBytes, fileCount: stats.fileCount, path: entry.name };
}),
);
const totals = topLevel.reduce(
(total, entry) => ({
fileCount: total.fileCount + entry.fileCount,
totalBytes: total.totalBytes + entry.bytes,
}),
{ fileCount: 0, totalBytes: 0 },
);
return {
...totals,
topLevel: topLevel.sort((left, right) => right.bytes - left.bytes || right.fileCount - left.fileCount),
};
}
async function observeWinResidues(config: ToolPackConfig, paths = resolveWinPaths(config)): Promise<WinResidueObservation> {
return {
installDirExists: await pathExists(paths.installDir),
installedExeExists: await pathExists(paths.installedExePath),
managedProcessPids: await findManagedDesktopProcessTree(config),
productNamespaceRootExists: await pathExists(resolveWinProductNamespaceRoot(config)),
productUserDataRootExists: await pathExists(resolveWinProductUserDataRoot()),
publicDesktopShortcutExists: await pathExists(paths.publicDesktopShortcutPath),
registryResidues: (await queryWinRegistryEntries(paths, config)).map((entry) => entry.keyPath),
runtimeNamespaceRootExists: await pathExists(config.roots.runtime.namespaceRoot),
startMenuShortcutExists: await pathExists(paths.startMenuShortcutPath),
uninstallerExists: await pathExists(paths.uninstallerPath),
userDesktopShortcutExists: await pathExists(paths.userDesktopShortcutPath),
};
}
export async function installPackedWinApp(config: ToolPackConfig): Promise<WinInstallResult> {
const paths = resolveWinPaths(config);
const registeredPaths = await resolveWinRegisteredPaths(config, paths);
if (!(await pathExists(paths.setupPath))) throw new Error(`no windows installer found at ${paths.setupPath}; run tools-pack win build first`);
if (await pathExists(registeredPaths.uninstallerPath)) {
await uninstallPackedWinApp(config);
} else {
await removeTree(registeredPaths.installDir);
}
await mkdir(dirname(paths.installDir), { recursive: true });
await runTimed(paths.installTimingPath, "install", async () => {
await invokeNsis(paths, paths.setupPath, installArgs(config, paths), "install");
});
if (!(await pathExists(paths.installedExePath))) throw new Error(`installer completed but executable is missing at ${paths.installedExePath}`);
const registryEntries = await queryWinRegistryEntries(paths, config);
const installPayload = await collectInstallPayloadReport(paths);
await writeJsonMarker(paths.installMarkerPath, {
installedAt: new Date().toISOString(),
installDir: paths.installDir,
installPayload,
namespace: config.namespace,
registryEntries: registryEntries.map((entry) => entry.keyPath),
});
return {
desktopShortcutExists: await pathExists(paths.userDesktopShortcutPath),
desktopShortcutPath: paths.userDesktopShortcutPath,
installDir: paths.installDir,
installerPath: paths.setupPath,
installPayload,
markerPath: paths.installMarkerPath,
namespace: config.namespace,
nsisLogPath: paths.nsisLogPath,
registryEntries,
startMenuShortcutExists: await pathExists(paths.startMenuShortcutPath),
startMenuShortcutPath: paths.startMenuShortcutPath,
timingPath: paths.installTimingPath,
uninstallerPath: paths.uninstallerPath,
};
}
async function resolveStartTarget(config: ToolPackConfig): Promise<{ configPath: string | null; executablePath: string; source: "built" | "installed" }> {
const paths = resolveWinPaths(config);
if (await pathExists(paths.installedExePath)) return { configPath: null, executablePath: paths.installedExePath, source: "installed" };
const builtManifest = await readBuiltAppManifest(paths, { requireExecutable: true });
if (builtManifest != null) return { configPath: builtManifest.configPath, executablePath: builtManifest.executablePath, source: "built" };
if (await pathExists(paths.unpackedExePath)) return { configPath: null, executablePath: paths.unpackedExePath, source: "built" };
throw new Error(`no windows app executable found for namespace=${config.namespace}; run tools-pack win build first or tools-pack win install after building an NSIS installer`);
}
export async function startPackedWinApp(config: ToolPackConfig): Promise<WinStartResult> {
const target = await resolveStartTarget(config);
const stamp = desktopStamp(config);
const logPath = desktopLogPath(config);
await mkdir(dirname(logPath), { recursive: true });
await writeFile(logPath, "", "utf8");
const spawned = await spawnBackgroundProcess({
args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT),
command: target.executablePath,
cwd: dirname(target.executablePath),
env: createSidecarLaunchEnv({
base: join(config.roots.runtime.namespaceRoot, "runtime"),
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
extraEnv: {
...process.env,
[DESKTOP_LOG_ECHO_ENV]: "0",
...(target.configPath == null ? {} : { [PACKAGED_CONFIG_PATH_ENV]: target.configPath }),
},
stamp,
}),
logFd: null,
});
return { executablePath: target.executablePath, logPath, namespace: config.namespace, pid: spawned.pid, source: target.source, status: await waitForDesktopStatus(config) };
}
async function findManagedDesktopProcessTree(config: ToolPackConfig): Promise<number[]> {
const processes = await listProcessSnapshots();
const stampedRootPids = processes
.filter((processInfo) =>
matchesStampedProcess(processInfo, { mode: SIDECAR_MODES.RUNTIME, namespace: config.namespace, source: SIDECAR_SOURCES.TOOLS_PACK }, OPEN_DESIGN_SIDECAR_CONTRACT),
)
.map((processInfo) => processInfo.pid);
return collectProcessTreePids(processes, stampedRootPids);
}
async function waitForNoManagedDesktopProcesses(config: ToolPackConfig, timeoutMs = 6000): Promise<number[]> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const pids = await findManagedDesktopProcessTree(config);
if (pids.length === 0) return [];
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
}
return await findManagedDesktopProcessTree(config);
}
export async function stopPackedWinApp(config: ToolPackConfig): Promise<WinStopResult> {
const stamp = desktopStamp(config);
const before = await findManagedDesktopProcessTree(config);
let gracefulRequested = false;
try {
await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 });
gracefulRequested = true;
} catch {
gracefulRequested = false;
}
const remainingAfterGraceful = gracefulRequested ? await waitForNoManagedDesktopProcesses(config) : before;
if (remainingAfterGraceful.length === 0) {
await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined);
return { gracefulRequested, namespace: config.namespace, remainingPids: [], status: before.length === 0 ? "not-running" : "stopped", stoppedPids: before };
}
const stopped = await stopProcesses(remainingAfterGraceful);
if (stopped.remainingPids.length === 0) await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined);
return {
gracefulRequested,
namespace: config.namespace,
remainingPids: stopped.remainingPids,
status: stopped.remainingPids.length === 0 ? "stopped" : "partial",
stoppedPids: stopped.stoppedPids,
};
}
export async function readPackedWinLogs(config: ToolPackConfig) {
const paths = resolveWinPaths(config);
const entries = await Promise.all(
[APP_KEYS.DESKTOP, APP_KEYS.WEB, APP_KEYS.DAEMON].map(async (app) => {
const logPath = join(config.roots.runtime.namespaceRoot, "logs", app, "latest.log");
return [app, { lines: await readLogTail(logPath, 200), logPath }] as const;
}),
);
return {
logs: {
...Object.fromEntries(entries),
nsis: { lines: await readLogTail(paths.nsisLogPath, 200), logPath: paths.nsisLogPath },
},
namespace: config.namespace,
};
}
export async function uninstallPackedWinApp(config: ToolPackConfig): Promise<WinUninstallResult> {
const paths = resolveWinPaths(config);
const registeredPaths = await resolveWinRegisteredPaths(config, paths);
const stop = await stopPackedWinApp(config);
if (await pathExists(registeredPaths.uninstallerPath)) {
await runTimed(paths.uninstallTimingPath, "uninstall", async () => {
await invokeNsis(paths, registeredPaths.uninstallerPath, config.silent ? ["/S"] : [], "uninstall");
});
}
await removeTree(registeredPaths.installDir);
const registryResiduesRemoved = await cleanupWinRegistryResidues(registeredPaths, config);
const removalPlan = await createWinRemovalPlan(config);
await writeJsonMarker(paths.uninstallMarkerPath, {
namespace: config.namespace,
removalPlan,
registryResiduesRemoved,
uninstalledAt: new Date().toISOString(),
}).catch(() => undefined);
const removedDataRoot = removalPlan.some((target) => target.scope === "data" && target.willRemove && target.exists);
const removedLogsRoot = removalPlan.some((target) => target.scope === "logs" && target.willRemove && target.exists);
const removedSidecarRoot = removalPlan.some((target) => target.scope === "sidecars" && target.willRemove && target.exists);
const removedProductUserDataRoot = removalPlan.some((target) => target.scope === "product-user-data" && target.willRemove && target.exists);
for (const target of removalPlan) {
if (target.willRemove) await removeTree(target.path);
}
return {
markerPath: paths.uninstallMarkerPath,
namespace: config.namespace,
nsisLogPath: paths.nsisLogPath,
registryResiduesRemoved,
removedDataRoot,
removedLogsRoot,
removedProductUserDataRoot,
removedSidecarRoot,
removalPlan,
residueObservation: await observeWinResidues(config, registeredPaths),
stop,
timingPath: paths.uninstallTimingPath,
uninstallerPath: registeredPaths.uninstallerPath,
};
}
export async function cleanupPackedWinNamespace(config: ToolPackConfig): Promise<WinCleanupResult> {
const paths = resolveWinPaths(config);
const registeredPaths = await resolveWinRegisteredPaths(config, paths);
const removalPlan = await createWinRemovalPlan(config);
if (await pathExists(registeredPaths.uninstallerPath)) {
await uninstallPackedWinApp(config);
}
const stop = await stopPackedWinApp(config);
const removedOutputRoot = await pathExists(config.roots.output.namespaceRoot);
const removedRuntimeNamespaceRoot = await pathExists(config.roots.runtime.namespaceRoot);
const removedProductUserDataRoot = removalPlan.some((target) => target.scope === "product-user-data" && target.willRemove && target.exists);
await cleanupWinRegistryResidues(registeredPaths, config);
for (const target of removalPlan) {
if (target.scope === "product-user-data" && target.willRemove) await removeTree(target.path);
}
await removeTree(config.roots.output.namespaceRoot);
await removeTree(config.roots.runtime.namespaceRoot);
return {
namespace: config.namespace,
removedOutputRoot,
removedProductUserDataRoot,
removedRuntimeNamespaceRoot,
removalPlan,
residueObservation: await observeWinResidues(config, registeredPaths),
stop,
};
}
export async function listPackedWinNamespaces(config: ToolPackConfig): Promise<WinListResult> {
const paths = resolveWinPaths(config);
const registeredPaths = await resolveWinRegisteredPaths(config, paths);
const registryEntries = await queryWinRegistryEntries(registeredPaths, config);
const productNamespaceRoot = resolveWinProductNamespaceRoot(config);
const productUserDataRoot = resolveWinProductUserDataRoot();
const builtManifest = await readBuiltAppManifest(paths, { requireExecutable: true });
return {
current: {
builtExecutableExists: builtManifest != null || await pathExists(paths.unpackedExePath),
builtExecutablePath: builtManifest?.executablePath ?? ((await pathExists(paths.unpackedExePath)) ? paths.unpackedExePath : null),
builtManifestPath: paths.builtManifestPath,
installDir: registeredPaths.installDir,
installedExeExists: await pathExists(registeredPaths.installedExePath),
installedExePath: registeredPaths.installedExePath,
namespace: config.namespace,
publicDesktopShortcutExists: await pathExists(paths.publicDesktopShortcutPath),
publicDesktopShortcutPath: paths.publicDesktopShortcutPath,
productNamespaceRoot,
productNamespaceRootExists: await pathExists(productNamespaceRoot),
productUserDataRoot,
productUserDataRootExists: await pathExists(productUserDataRoot),
registryEntries,
registryResidues: registryEntries.map((entry) => entry.keyPath),
removalPlan: await createWinRemovalPlan(config),
runtimeNamespaceRoot: config.roots.runtime.namespaceRoot,
runtimeNamespaceRootExists: await pathExists(config.roots.runtime.namespaceRoot),
setupExists: await pathExists(paths.setupPath),
setupPath: paths.setupPath,
startMenuShortcutExists: await pathExists(paths.startMenuShortcutPath),
startMenuShortcutPath: paths.startMenuShortcutPath,
uninstallerExists: await pathExists(registeredPaths.uninstallerPath),
uninstallerPath: registeredPaths.uninstallerPath,
userDesktopShortcutExists: await pathExists(paths.userDesktopShortcutPath),
userDesktopShortcutPath: paths.userDesktopShortcutPath,
},
outputNamespaces: await listDirectories(join(config.roots.output.platformRoot, "namespaces")),
runtimeNamespaces: await listDirectories(config.roots.runtime.namespaceBaseRoot),
};
}
export async function resetPackedWinNamespaces(config: ToolPackConfig): Promise<WinResetResult> {
const namespaces = [...new Set([...(await listDirectories(join(config.roots.output.platformRoot, "namespaces"))), ...(await listDirectories(config.roots.runtime.namespaceBaseRoot))])].sort();
const results: WinCleanupResult[] = [];
for (const namespace of namespaces) {
results.push(await cleanupPackedWinNamespace({ ...config, namespace, roots: {
...config.roots,
output: { ...config.roots.output, namespaceRoot: join(config.roots.output.platformRoot, "namespaces", namespace) },
runtime: { ...config.roots.runtime, namespaceRoot: join(config.roots.runtime.namespaceBaseRoot, namespace) },
} }));
}
return { namespaces, results };
}
export async function inspectPackedWinApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise<WinInspectResult> {
const stamp = desktopStamp(config);
const status = await requestJsonIpc<DesktopStatusSnapshot>(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 2000 }).catch(() => null);
return {
...(options.expr == null ? {} : {
eval: await requestJsonIpc<DesktopEvalResult>(
stamp.ipc,
{ input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL },
{ timeoutMs: 5000 },
),
}),
...(options.path == null ? {} : {
screenshot: await requestJsonIpc<DesktopScreenshotResult>(
stamp.ipc,
{ input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT },
{ timeoutMs: 10000 },
),
}),
status,
};
}

View file

@ -0,0 +1,67 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { ToolPackConfig } from "../config.js";
import { pathExists } from "./fs.js";
import type { WinBuiltAppManifest, WinPaths } from "./types.js";
export async function readPackagedVersion(config: ToolPackConfig): Promise<string> {
const packageJsonPath = join(config.workspaceRoot, "apps", "packaged", "package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`missing apps/packaged package version in ${packageJsonPath}`);
}
return packageJson.version;
}
function createPackagedConfig(config: ToolPackConfig, packagedVersion: string): Record<string, unknown> {
return {
appVersion: packagedVersion,
namespace: config.namespace,
webOutputMode: config.webOutputMode,
...(config.portable ? {} : { namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }),
};
}
export async function writePackagedConfigFile(
filePath: string,
config: ToolPackConfig,
packagedVersion: string,
): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
await writeFile(
filePath,
`${JSON.stringify(createPackagedConfig(config, packagedVersion), null, 2)}\n`,
"utf8",
);
}
export async function writePackagedConfig(
config: ToolPackConfig,
paths: WinPaths,
packagedVersion: string,
): Promise<void> {
await writePackagedConfigFile(paths.packagedConfigPath, config, packagedVersion);
}
export async function writeBuiltAppManifest(
paths: WinPaths,
manifest: Omit<WinBuiltAppManifest, "version">,
): Promise<void> {
await mkdir(dirname(paths.builtManifestPath), { recursive: true });
await writeFile(paths.builtManifestPath, `${JSON.stringify({ version: 1, ...manifest }, null, 2)}\n`, "utf8");
}
export async function readBuiltAppManifest(
paths: WinPaths,
options: { requireExecutable?: boolean } = {},
): Promise<WinBuiltAppManifest | null> {
try {
const manifest = JSON.parse(await readFile(paths.builtManifestPath, "utf8")) as WinBuiltAppManifest;
if (manifest.version !== 1) return null;
if (options.requireExecutable === true && !(await pathExists(manifest.executablePath))) return null;
return manifest;
} catch {
return null;
}
}

199
tools/pack/src/win/nsis.ts Normal file
View file

@ -0,0 +1,199 @@
import { execFile } from "node:child_process";
import { appendFile, cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import type { ToolPackConfig } from "../config.js";
import { pathExists } from "./fs.js";
import { resolveWinUninstallLocalDataRoot } from "./paths.js";
import type { WinPaths } from "./types.js";
const execFileAsync = promisify(execFile);
function escapeNsisString(value: string): string {
return value.replace(/"/g, '$\\"').replace(/\r?\n/g, "$\\r$\\n");
}
export async function writeNsisInclude(config: ToolPackConfig, paths: WinPaths): Promise<void> {
const localDataRoot = escapeNsisString(resolveWinUninstallLocalDataRoot(config));
await mkdir(dirname(paths.nsisIncludePath), { recursive: true });
await writeFile(
paths.nsisIncludePath,
`!include LogicLib.nsh
!include nsDialogs.nsh
Var /GLOBAL odRemoveLocalData
Var /GLOBAL odRemoveLocalDataCheckbox
Var /GLOBAL odLocalDataRoot
LangString OD_REMOVE_LOCAL_DATA_TITLE 1033 "Remove local data"
LangString OD_REMOVE_LOCAL_DATA_TITLE 2052 "删除本地数据"
LangString OD_REMOVE_LOCAL_DATA_TITLE 1028 "刪除本機資料"
LangString OD_REMOVE_LOCAL_DATA_TITLE 1046 "Remover dados locais"
LangString OD_REMOVE_LOCAL_DATA_TITLE 1049 "Удалить локальные данные"
LangString OD_REMOVE_LOCAL_DATA_TITLE 1065 "حذف داده‌های محلی"
LangString OD_REMOVE_LOCAL_DATA_HINT 1033 "Choose whether the uninstaller should remove Open Design data stored on this computer."
LangString OD_REMOVE_LOCAL_DATA_HINT 2052 "请选择卸载程序是否删除此电脑上保存的 Open Design 数据。"
LangString OD_REMOVE_LOCAL_DATA_HINT 1028 "請選擇解除安裝程式是否刪除此電腦上儲存的 Open Design 資料。"
LangString OD_REMOVE_LOCAL_DATA_HINT 1046 "Escolha se o desinstalador deve remover os dados do Open Design armazenados neste computador."
LangString OD_REMOVE_LOCAL_DATA_HINT 1049 "Выберите, должен ли деинсталлятор удалить данные Open Design, сохраненные на этом компьютере."
LangString OD_REMOVE_LOCAL_DATA_HINT 1065 "انتخاب کنید که حذف‌کننده داده‌های Open Design ذخیره‌شده در این رایانه را حذف کند یا نه."
LangString OD_REMOVE_LOCAL_DATA_CHECKBOX 1033 "Remove local Open Design data:"
LangString OD_REMOVE_LOCAL_DATA_CHECKBOX 2052 "删除本地 Open Design 数据:"
LangString OD_REMOVE_LOCAL_DATA_CHECKBOX 1028 "刪除本機 Open Design 資料:"
LangString OD_REMOVE_LOCAL_DATA_CHECKBOX 1046 "Remover dados locais do Open Design:"
LangString OD_REMOVE_LOCAL_DATA_CHECKBOX 1049 "Удалить локальные данные Open Design:"
LangString OD_REMOVE_LOCAL_DATA_CHECKBOX 1065 "حذف داده‌های محلی Open Design:"
!macro customUnWelcomePage
!insertmacro MUI_UNPAGE_WELCOME
UninstPage custom un.OpenDesignLocalDataPage un.OpenDesignLocalDataPageLeave
!macroend
Function un.OpenDesignLocalDataPage
StrCpy $odRemoveLocalData "1"
StrCpy $odLocalDataRoot "${localDataRoot}"
nsDialogs::Create 1018
Pop $0
\${If} $0 == error
Abort
\${EndIf}
\${NSD_CreateLabel} 0 0 100% 24u "$(OD_REMOVE_LOCAL_DATA_HINT)"
Pop $0
\${NSD_CreateCheckbox} 0 34u 100% 36u "$(OD_REMOVE_LOCAL_DATA_CHECKBOX) $odLocalDataRoot"
Pop $odRemoveLocalDataCheckbox
\${NSD_Check} $odRemoveLocalDataCheckbox
nsDialogs::Show
FunctionEnd
Function un.OpenDesignLocalDataPageLeave
\${NSD_GetState} $odRemoveLocalDataCheckbox $0
\${If} $0 == \${BST_CHECKED}
StrCpy $odRemoveLocalData "1"
\${Else}
StrCpy $odRemoveLocalData "0"
\${EndIf}
FunctionEnd
!macro customUnInstall
\${If} $odLocalDataRoot == ""
StrCpy $odLocalDataRoot "${localDataRoot}"
\${EndIf}
\${If} $odRemoveLocalData != "0"
DetailPrint "Removing local Open Design data: $odLocalDataRoot"
RMDir /r "$odLocalDataRoot"
\${EndIf}
!macroend
`,
"utf8",
);
}
async function listChildDirectories(root: string): Promise<string[]> {
try {
const entries = await readdir(root, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name));
} catch {
return [];
}
}
async function findNsisLanguageDirectories(root: string, depth = 4): Promise<string[]> {
const languageDir = join(root, "Contrib", "Language files");
if (await pathExists(join(languageDir, "Farsi.nlf"))) return [languageDir];
if (depth <= 0) return [];
const children = await listChildDirectories(root);
const nested = await Promise.all(children.map((child) => findNsisLanguageDirectories(child, depth - 1)));
return nested.flat();
}
export async function ensureNsisPersianLanguageAlias(config: ToolPackConfig): Promise<boolean> {
const cacheRoots = [
process.env.ELECTRON_BUILDER_CACHE,
process.env.LOCALAPPDATA == null ? undefined : join(process.env.LOCALAPPDATA, "electron-builder", "Cache"),
process.env.APPDATA == null ? undefined : join(process.env.APPDATA, "electron-builder", "Cache"),
join(config.workspaceRoot, "node_modules", ".cache", "electron-builder"),
process.env["ProgramFiles(x86)"] == null ? undefined : join(process.env["ProgramFiles(x86)"], "NSIS"),
process.env.ProgramFiles == null ? undefined : join(process.env.ProgramFiles, "NSIS"),
"C:\\Program Files (x86)\\NSIS",
"C:\\Program Files\\NSIS",
].filter((entry): entry is string => entry != null && entry.length > 0);
let updated = false;
for (const cacheRoot of cacheRoots) {
for (const languageDir of await findNsisLanguageDirectories(cacheRoot)) {
let updatedLanguageDir = false;
const farsiNlf = join(languageDir, "Farsi.nlf");
const farsiNsh = join(languageDir, "Farsi.nsh");
const persianNlf = join(languageDir, "Persian.nlf");
const persianNsh = join(languageDir, "Persian.nsh");
if ((await pathExists(farsiNlf)) && !(await pathExists(persianNlf))) {
await cp(farsiNlf, persianNlf);
updatedLanguageDir = true;
updated = true;
}
if (await pathExists(farsiNsh)) {
const farsiMessages = await readFile(farsiNsh, "utf8");
const persianMessages = farsiMessages.replace('LANGFILE "Farsi"', 'LANGFILE "Persian"');
const existingPersianMessages = await readFile(persianNsh, "utf8").catch(() => null);
if (existingPersianMessages !== persianMessages) {
await writeFile(persianNsh, persianMessages, "utf8");
updatedLanguageDir = true;
updated = true;
}
}
if (updatedLanguageDir) {
process.stderr.write(`[tools-pack] added NSIS Persian language alias in ${languageDir}\n`);
}
}
}
return updated;
}
export async function appendNsisLog(paths: WinPaths, message: string, meta: Record<string, unknown> = {}): Promise<void> {
await mkdir(dirname(paths.nsisLogPath), { recursive: true });
await appendFile(paths.nsisLogPath, `${JSON.stringify({ message, meta, timestamp: new Date().toISOString() })}\n`, "utf8");
}
export async function runTimed<T>(timingPath: string, action: string, task: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
try {
const result = await task();
await mkdir(dirname(timingPath), { recursive: true });
await writeFile(timingPath, `${JSON.stringify({ action, durationMs: Date.now() - startedAt, status: "success" }, null, 2)}\n`, "utf8");
return result;
} catch (error) {
await mkdir(dirname(timingPath), { recursive: true });
await writeFile(
timingPath,
`${JSON.stringify({ action, durationMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error), status: "failed" }, null, 2)}\n`,
"utf8",
);
throw error;
}
}
export async function invokeNsis(paths: WinPaths, command: string, args: string[], action: "install" | "uninstall"): Promise<void> {
await appendNsisLog(paths, `${action} started`, { args, command });
try {
const directoryArg = args.at(-1);
if (process.platform === "win32" && directoryArg?.startsWith("/D=")) {
await execFileAsync(command, args, { cwd: dirname(command), windowsHide: true, windowsVerbatimArguments: true });
} else {
await execFileAsync(command, args, { cwd: dirname(command), windowsHide: true });
}
await appendNsisLog(paths, `${action} finished`, { code: 0, command });
} catch (error) {
const failure = error as { code?: unknown; stderr?: unknown; stdout?: unknown };
await appendNsisLog(paths, `${action} failed`, {
code: failure.code,
command,
stderr: typeof failure.stderr === "string" ? failure.stderr : undefined,
stdout: typeof failure.stdout === "string" ? failure.stdout : undefined,
});
throw error;
}
}

View file

@ -0,0 +1,84 @@
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { ToolPackConfig } from "../config.js";
import { PRODUCT_NAME } from "./constants.js";
import { pathExists } from "./fs.js";
import { resolveWinInstallIdentity } from "./identity.js";
import type { WinPaths, WinRemovalTarget } from "./types.js";
export function sanitizeNamespace(value: string): string {
return value.replace(/[^A-Za-z0-9._-]+/g, "-");
}
export function resolveWinPaths(config: ToolPackConfig): WinPaths {
const namespaceToken = sanitizeNamespace(config.namespace);
const namespaceRoot = config.roots.output.namespaceRoot;
const installDir = join(config.roots.runtime.namespaceRoot, "install", PRODUCT_NAME);
const identity = resolveWinInstallIdentity(config);
return {
appBuilderConfigPath: join(namespaceRoot, "builder-config.json"),
appBuilderOutputRoot: join(namespaceRoot, "builder"),
assembledAppRoot: join(namespaceRoot, "assembled", "app"),
assembledMainEntryPath: join(namespaceRoot, "assembled", "app", "main.cjs"),
assembledPackageJsonPath: join(namespaceRoot, "assembled", "app", "package.json"),
blockmapPath: join(namespaceRoot, "builder", `${PRODUCT_NAME}-${namespaceToken}-setup.exe.blockmap`),
builtManifestPath: join(namespaceRoot, "built-app.json"),
exePath: join(namespaceRoot, "builder", `${PRODUCT_NAME}-${namespaceToken}.exe`),
installDir,
installedExePath: join(installDir, `${PRODUCT_NAME}.exe`),
installerPayloadPath: join(namespaceRoot, "installer", "payload.7z"),
installerScriptPath: join(namespaceRoot, "installer", "installer.nsi"),
publicDesktopShortcutPath: join(process.env.PUBLIC ?? join(dirname(homedir()), "Public"), "Desktop", identity.shortcutName),
installMarkerPath: join(namespaceRoot, "logs", "install.marker.json"),
installTimingPath: join(namespaceRoot, "logs", "install.timing.json"),
latestYmlPath: join(namespaceRoot, "builder", "latest.yml"),
nsisLogPath: join(namespaceRoot, "logs", "nsis.log"),
nsisIncludePath: join(namespaceRoot, "nsis", "installer.nsh"),
packagedConfigPath: join(namespaceRoot, "open-design-config.json"),
resourceRoot: join(namespaceRoot, "resources", "open-design"),
setupPath: join(namespaceRoot, "builder", `${PRODUCT_NAME}-${namespaceToken}-setup.exe`),
startMenuShortcutPath: join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", identity.shortcutName),
tarballsRoot: join(namespaceRoot, "tarballs"),
userDesktopShortcutPath: join(homedir(), "Desktop", identity.shortcutName),
uninstallMarkerPath: join(namespaceRoot, "logs", "uninstall.marker.json"),
uninstallTimingPath: join(namespaceRoot, "logs", "uninstall.timing.json"),
uninstallerPath: join(installDir, identity.uninstallerName),
webStandaloneHookAuditPath: join(namespaceRoot, "web-standalone-after-pack-audit.json"),
webStandaloneHookConfigPath: join(namespaceRoot, "web-standalone-after-pack-config.json"),
winIconPath: join(namespaceRoot, "resources", "win", "icon.ico"),
unpackedExePath: join(namespaceRoot, "builder", "win-unpacked", `${PRODUCT_NAME}.exe`),
unpackedRoot: join(namespaceRoot, "builder", "win-unpacked"),
};
}
export function resolveWinProductUserDataRoot(): string {
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), PRODUCT_NAME);
}
export function resolveWinUninstallLocalDataRoot(config: ToolPackConfig): string {
return config.portable ? `$APPDATA\\${PRODUCT_NAME}` : config.roots.runtime.namespaceRoot;
}
export function resolveWinProductNamespaceRoot(config: ToolPackConfig): string {
return join(resolveWinProductUserDataRoot(), "namespaces", config.namespace);
}
export function resolveWinLocalDataRoot(config: ToolPackConfig): string {
return resolveWinProductNamespaceRoot(config);
}
export async function createWinRemovalPlan(config: ToolPackConfig): Promise<WinRemovalTarget[]> {
const runtimeRoot = config.roots.runtime.namespaceRoot;
const targets: Array<Omit<WinRemovalTarget, "exists">> = [
{ path: join(runtimeRoot, "data"), scope: "data", willRemove: config.removeData },
{ path: join(runtimeRoot, "logs"), scope: "logs", willRemove: config.removeLogs },
{ path: join(runtimeRoot, "runtime"), scope: "sidecars", willRemove: config.removeSidecars },
{
path: resolveWinLocalDataRoot(config),
scope: "product-user-data",
willRemove: config.removeProductUserData,
},
];
return await Promise.all(targets.map(async (target) => ({ ...target, exists: await pathExists(target.path) })));
}

View file

@ -0,0 +1,181 @@
import { execFile } from "node:child_process";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import type { ToolPackConfig } from "../config.js";
import { pathExists } from "./fs.js";
import { resolveWinInstallIdentity } from "./identity.js";
import type { WinPaths, WindowsUninstallRegistryEntry } from "./types.js";
const execFileAsync = promisify(execFile);
function normalizeRegistryPath(value: string | null | undefined): string {
return (value ?? "").replace(/[\\/]+$/, "").toLowerCase();
}
export function stripRegistryQuotedValue(value: string | null | undefined): string {
const trimmed = (value ?? "").trim();
if (trimmed.startsWith('"')) {
const closingQuote = trimmed.indexOf('"', 1);
if (closingQuote > 0) return trimmed.slice(1, closingQuote);
}
return trimmed;
}
function createEmptyRegistryEntry(keyPath: string): WindowsUninstallRegistryEntry {
return {
displayIcon: null,
displayName: null,
displayVersion: null,
installLocation: null,
keyPath,
publisher: null,
quietUninstallString: null,
uninstallString: null,
};
}
function normalizeRegistryKeyPath(value: string): string {
return value
.replace(/^HKCU\\/i, "HKEY_CURRENT_USER\\")
.replace(/^HKLM\\/i, "HKEY_LOCAL_MACHINE\\")
.toLowerCase();
}
function namespaceRegistryKeyPath(config: Pick<ToolPackConfig, "namespace">): string {
return normalizeRegistryKeyPath(`HKCU\\${resolveWinInstallIdentity(config).registryKey}`);
}
async function execReg(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> {
return await execFileAsync("reg.exe", args, { cwd, env: process.env, windowsHide: true });
}
function registryEntryMatches(
paths: WinPaths,
entry: WindowsUninstallRegistryEntry,
config?: Pick<ToolPackConfig, "namespace">,
): boolean {
if (config != null && normalizeRegistryKeyPath(entry.keyPath) === namespaceRegistryKeyPath(config)) return true;
const targetInstallDir = normalizeRegistryPath(paths.installDir);
const targetUninstaller = normalizeRegistryPath(paths.uninstallerPath);
const installLocation = normalizeRegistryPath(entry.installLocation);
const displayIcon = normalizeRegistryPath(stripRegistryQuotedValue(entry.displayIcon));
const uninstallString = normalizeRegistryPath(stripRegistryQuotedValue(entry.uninstallString));
const quietUninstallString = normalizeRegistryPath(stripRegistryQuotedValue(entry.quietUninstallString));
return (
installLocation === targetInstallDir ||
displayIcon.includes(normalizeRegistryPath(paths.installedExePath)) ||
uninstallString.includes(targetUninstaller) ||
quietUninstallString.includes(targetUninstaller)
);
}
export async function queryWinRegistryEntries(
paths: WinPaths,
config?: Pick<ToolPackConfig, "namespace">,
): Promise<WindowsUninstallRegistryEntry[]> {
const roots = [
"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
"HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
"HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
];
const entries: WindowsUninstallRegistryEntry[] = [];
for (const root of roots) {
let stdout = "";
try {
({ stdout } = await execReg(["query", root, "/s"], await pathExists(paths.appBuilderOutputRoot) ? paths.appBuilderOutputRoot : process.cwd()));
} catch {
continue;
}
let current: WindowsUninstallRegistryEntry | null = null;
const collect = () => {
if (current != null && registryEntryMatches(paths, current, config)) entries.push(current);
};
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (line.length === 0) continue;
if (line.startsWith("HKEY_")) {
collect();
current = createEmptyRegistryEntry(line);
continue;
}
if (current == null) continue;
const [name, , ...valueParts] = line.trim().split(/\s{2,}/);
if (name == null || valueParts.length === 0) continue;
const value = valueParts.join(" ");
if (name === "DisplayIcon") current.displayIcon = value;
else if (name === "DisplayName") current.displayName = value;
else if (name === "DisplayVersion") current.displayVersion = value;
else if (name === "InstallLocation") current.installLocation = value;
else if (name === "Publisher") current.publisher = value;
else if (name === "QuietUninstallString") current.quietUninstallString = value;
else if (name === "UninstallString") current.uninstallString = value;
}
collect();
}
return entries;
}
export async function queryWinNamespaceRegistryEntry(
config: Pick<ToolPackConfig, "namespace">,
paths: WinPaths,
): Promise<WindowsUninstallRegistryEntry | null> {
const identity = resolveWinInstallIdentity(config);
let stdout = "";
try {
({ stdout } = await execReg(
["query", `HKCU\\${identity.registryKey}`],
await pathExists(paths.appBuilderOutputRoot) ? paths.appBuilderOutputRoot : process.cwd(),
));
} catch {
return null;
}
const entry = createEmptyRegistryEntry(`HKEY_CURRENT_USER\\${identity.registryKey}`);
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (line.length === 0 || line.startsWith("HKEY_")) continue;
const [name, , ...valueParts] = line.trim().split(/\s{2,}/);
if (name == null || valueParts.length === 0) continue;
const value = valueParts.join(" ");
if (name === "DisplayIcon") entry.displayIcon = value;
else if (name === "DisplayName") entry.displayName = value;
else if (name === "DisplayVersion") entry.displayVersion = value;
else if (name === "InstallLocation") entry.installLocation = value;
else if (name === "Publisher") entry.publisher = value;
else if (name === "QuietUninstallString") entry.quietUninstallString = value;
else if (name === "UninstallString") entry.uninstallString = value;
}
return entry;
}
export async function resolveWinRegisteredPaths(config: ToolPackConfig, paths: WinPaths): Promise<WinPaths> {
const entry = await queryWinNamespaceRegistryEntry(config, paths);
if (entry == null) return paths;
const identity = resolveWinInstallIdentity(config);
const uninstallerFromRegistry = stripRegistryQuotedValue(entry.quietUninstallString) || stripRegistryQuotedValue(entry.uninstallString);
const installDir = stripRegistryQuotedValue(entry.installLocation) || (uninstallerFromRegistry.length > 0 ? dirname(uninstallerFromRegistry) : paths.installDir);
const uninstallerPath = uninstallerFromRegistry.length > 0 ? uninstallerFromRegistry : join(installDir, identity.uninstallerName);
return {
...paths,
installDir,
installedExePath: join(installDir, identity.exeName),
uninstallerPath,
};
}
export async function cleanupWinRegistryResidues(
paths: WinPaths,
config?: Pick<ToolPackConfig, "namespace">,
): Promise<string[]> {
const entries = await queryWinRegistryEntries(paths, config);
const removed: string[] = [];
for (const entry of entries) {
try {
await execReg(["delete", entry.keyPath, "/f"], await pathExists(paths.appBuilderOutputRoot) ? paths.appBuilderOutputRoot : process.cwd());
removed.push(entry.keyPath);
} catch {
// HKLM residues may require elevation; keep observing them instead of hiding failure.
}
}
return removed;
}

View file

@ -0,0 +1,124 @@
import { join } from "node:path";
import type { ToolPackConfig } from "../config.js";
import {
ELECTRON_BUILDER_ASAR,
ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
ELECTRON_BUILDER_FILE_PATTERNS,
ELECTRON_BUILDER_NODE_GYP_REBUILD,
ELECTRON_BUILDER_NPM_REBUILD,
ELECTRON_REBUILD_MODE,
ELECTRON_REBUILD_NATIVE_MODULES,
WEB_STANDALONE_RESOURCE_NAME,
} from "./constants.js";
import { PathSizeIndex, pathExists, sizeExistingFileBytes } from "./fs.js";
import type { WinBuiltAppManifest, WinPaths, WinSizeReport } from "./types.js";
function isBetterSqlite3SourceResidue(path: string): boolean {
return (
path.includes("/node_modules/better-sqlite3/deps/") ||
path.includes("/node_modules/better-sqlite3/build/Release/obj/")
);
}
export function resolveWinTargets(to: ToolPackConfig["to"]): Array<"dir" | "nsis"> {
switch (to) {
case "dir":
return ["dir"];
case "all":
return ["dir", "nsis"];
case "nsis":
return ["nsis"];
default:
throw new Error(`unsupported win target: ${to}`);
}
}
export async function collectWinSizeReport(
config: ToolPackConfig,
paths: WinPaths,
builtApp: WinBuiltAppManifest | null,
): Promise<WinSizeReport> {
const unpackedRoot = builtApp?.unpackedRoot ?? paths.unpackedRoot;
const sizeIndex = await PathSizeIndex.create(unpackedRoot);
const namespaceSizeIndex = await PathSizeIndex.create(config.roots.output.namespaceRoot);
const appResourcesRoot = join(unpackedRoot, "resources");
const appNodeModulesRoot = join(appResourcesRoot, "app", "node_modules");
const copiedStandaloneRoot = join(appResourcesRoot, WEB_STANDALONE_RESOURCE_NAME);
const copiedStandaloneNodeModulesRoot = join(copiedStandaloneRoot, "node_modules");
const copiedStandaloneWebNodeModulesRoot = join(copiedStandaloneRoot, "apps", "web", "node_modules");
const electronLocalesRoot = join(unpackedRoot, "locales");
const rootWebPackageRoot = join(appNodeModulesRoot, "@open-design", "web");
return {
builder: {
asar: ELECTRON_BUILDER_ASAR,
buildDependenciesFromSource: ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
filePatterns: ELECTRON_BUILDER_FILE_PATTERNS,
nativeRebuild: {
buildFromSource: ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
mode: ELECTRON_REBUILD_MODE,
modules: ELECTRON_REBUILD_NATIVE_MODULES,
},
nodeGypRebuild: ELECTRON_BUILDER_NODE_GYP_REBUILD,
npmRebuild: ELECTRON_BUILDER_NPM_REBUILD,
targets: resolveWinTargets(config.to),
webOutputMode: config.webOutputMode,
},
generatedAt: new Date().toISOString(),
installerBytes: await sizeExistingFileBytes(paths.setupPath),
outputRootBytes: namespaceSizeIndex.sizePathBytes(config.roots.output.namespaceRoot),
resourceRootBytes: sizeIndex.sizePathBytes(join(appResourcesRoot, "open-design")),
runtimeNamespaceRoot: config.roots.runtime.namespaceRoot,
topLevel: {
appResourcesBytes: sizeIndex.sizePathBytes(join(appResourcesRoot, "app")),
copiedStandaloneBytes: sizeIndex.sizePathBytes(copiedStandaloneRoot),
electronLocalesBytes: sizeIndex.sizePathBytes(electronLocalesRoot),
resourcesBytes: sizeIndex.sizePathBytes(appResourcesRoot),
},
tracked: {
appNodeModulesBytes: sizeIndex.sizePathBytes(appNodeModulesRoot),
betterSqlite3Bytes: sizeIndex.sizePathBytes(join(appNodeModulesRoot, "better-sqlite3")),
betterSqlite3SourceResidueBytes: sizeIndex.sizePathBytes(unpackedRoot, {
includeFile: isBetterSqlite3SourceResidue,
}),
bundledNodeBytes: sizeIndex.sizePathBytes(join(paths.resourceRoot, "bin", "node.exe")),
copiedStandaloneNextBytes:
sizeIndex.sizePathBytes(join(copiedStandaloneNodeModulesRoot, "next")) +
sizeIndex.sizePathBytes(join(copiedStandaloneWebNodeModulesRoot, "next")),
copiedStandaloneNextSwcBytes:
sizeIndex.sumChildDirectorySizes(join(copiedStandaloneNodeModulesRoot, "@next"), (name) => name.startsWith("swc-win32-")) +
sizeIndex.sumChildDirectorySizes(join(copiedStandaloneWebNodeModulesRoot, "@next"), (name) => name.startsWith("swc-win32-")),
copiedStandaloneNodeModulesBytes: sizeIndex.sizePathBytes(copiedStandaloneNodeModulesRoot),
copiedStandalonePnpmHoistedNextBytes: sizeIndex.sizePathBytes(
join(copiedStandaloneNodeModulesRoot, ".pnpm", "node_modules", "next"),
),
copiedStandaloneSharpLibvipsBytes: sizeIndex.sizePathBytes(
join(copiedStandaloneNodeModulesRoot, "@img", "sharp-libvips-win32-x64"),
),
copiedStandaloneSourcemapBytes: sizeIndex.sizePathBytes(copiedStandaloneRoot, {
includeFile: (path) => path.endsWith(".map"),
}),
copiedStandaloneTsbuildInfoBytes: sizeIndex.sizePathBytes(copiedStandaloneRoot, {
includeFile: (path) => path.endsWith(".tsbuildinfo"),
}),
copiedStandaloneWebNextBytes: sizeIndex.sizePathBytes(join(copiedStandaloneWebNodeModulesRoot, "next")),
copiedStandaloneWebNodeModulesBytes: sizeIndex.sizePathBytes(copiedStandaloneWebNodeModulesRoot),
electronLocalesBytes: sizeIndex.sizePathBytes(electronLocalesRoot),
markdownBytes: sizeIndex.sizePathBytes(unpackedRoot, { includeFile: (path) => path.endsWith(".md") }),
nextBytes: sizeIndex.sizePathBytes(join(appNodeModulesRoot, "next")),
nextSwcBytes: sizeIndex.sumChildDirectorySizes(join(appNodeModulesRoot, "@next"), (name) => name.startsWith("swc-win32-")),
sharpLibvipsBytes: sizeIndex.sizePathBytes(join(appNodeModulesRoot, "@img", "sharp-libvips-win32-x64")),
sourcemapBytes: sizeIndex.sizePathBytes(unpackedRoot, { includeFile: (path) => path.endsWith(".map") }),
tsbuildInfoBytes: sizeIndex.sizePathBytes(unpackedRoot, { includeFile: (path) => path.endsWith(".tsbuildinfo") }),
webCopiedStandaloneBytes: sizeIndex.sizePathBytes(copiedStandaloneRoot),
webNextCacheBytes: sizeIndex.sizePathBytes(join(rootWebPackageRoot, ".next", "cache")),
webPackageAppBytes: sizeIndex.sizePathBytes(join(rootWebPackageRoot, "app")),
webPackageBytes: sizeIndex.sizePathBytes(rootWebPackageRoot),
webPackageDistBytes: sizeIndex.sizePathBytes(join(rootWebPackageRoot, "dist")),
webPackagePublicBytes: sizeIndex.sizePathBytes(join(rootWebPackageRoot, "public")),
webPackageSrcBytes: sizeIndex.sizePathBytes(join(rootWebPackageRoot, "src")),
webPackageStandaloneBytes: sizeIndex.sizePathBytes(join(rootWebPackageRoot, ".next", "standalone")),
},
unpackedBytes: (await pathExists(unpackedRoot)) ? sizeIndex.sizePathBytes(unpackedRoot) : null,
};
}

View file

@ -0,0 +1,62 @@
import { cp, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { hashJson, hashPath, ToolPackCache } from "../cache.js";
import type { ToolPackConfig } from "../config.js";
import { copyBundledResourceTrees, winResources } from "../resources.js";
import type { WinPaths, ResourceTreeCacheMetadata } from "./types.js";
async function createResourceTreeCacheKey(config: ToolPackConfig): Promise<string> {
return hashJson({
assetsCommunityPets: await hashPath(join(config.workspaceRoot, "assets", "community-pets")),
assetsFrames: await hashPath(join(config.workspaceRoot, "assets", "frames")),
craft: await hashPath(join(config.workspaceRoot, "craft")),
designSystems: await hashPath(join(config.workspaceRoot, "design-systems")),
node: "win.resource-tree",
promptTemplates: await hashPath(join(config.workspaceRoot, "prompt-templates")),
schemaVersion: 1,
skills: await hashPath(join(config.workspaceRoot, "skills")),
});
}
export type ResourceTreeResult = {
key: string;
resourceRoot: string;
};
export async function prepareResourceTree(
config: ToolPackConfig,
paths: WinPaths,
cache: ToolPackCache,
options: { materialize: boolean },
): Promise<ResourceTreeResult> {
const key = await createResourceTreeCacheKey(config);
const node = {
id: "win.resource-tree",
key,
outputs: ["open-design"],
invalidate: async () => null,
build: async ({ entryRoot }: { entryRoot: string }): Promise<ResourceTreeCacheMetadata> => {
const resourceRoot = join(entryRoot, "open-design");
await mkdir(resourceRoot, { recursive: true });
await copyBundledResourceTrees({
workspaceRoot: config.workspaceRoot,
resourceRoot,
});
return { resourceName: "open-design" };
},
};
const manifest = await cache.acquire({
materialize: options.materialize ? [{ from: "open-design", to: paths.resourceRoot }] : [],
node,
});
return {
key,
resourceRoot: options.materialize ? paths.resourceRoot : join(manifest.entryPath, "open-design"),
};
}
export async function copyWinIcon(paths: WinPaths): Promise<void> {
await mkdir(dirname(paths.winIconPath), { recursive: true });
await cp(winResources.icon, paths.winIconPath);
}

305
tools/pack/src/win/types.ts Normal file
View file

@ -0,0 +1,305 @@
import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot } from "@open-design/sidecar-proto";
import type { CacheReport } from "../cache.js";
import type { ToolPackConfig } from "../config.js";
import type { INTERNAL_PACKAGES } from "./constants.js";
export type PackedTarballInfo = {
fileName: string;
packageName: (typeof INTERNAL_PACKAGES)[number]["name"];
};
export type PackedTarballsCacheMetadata = {
tarballs: PackedTarballInfo[];
};
export type PackedTarballsCacheResult = PackedTarballsCacheMetadata & {
key: string;
};
export type PackagedAppCacheMetadata = {
packagedVersion: string;
};
export type PackagedAppCacheResult = PackagedAppCacheMetadata & {
appRoot: string;
key: string;
};
export type ElectronBuilderDirCacheMetadata = {
packagedAppKey: string;
packagedVersion: string;
};
export type ResourceTreeCacheMetadata = {
resourceName: "open-design";
};
export type WinBuiltAppManifest = {
appBuilderOutputRoot: string;
cacheEntryPath: string | null;
configPath: string;
executablePath: string;
source: "cache" | "namespace";
unpackedRoot: string;
version: 1;
webStandaloneHookAuditPath: string | null;
};
export type WinPaths = {
appBuilderConfigPath: string;
appBuilderOutputRoot: string;
assembledAppRoot: string;
assembledMainEntryPath: string;
assembledPackageJsonPath: string;
blockmapPath: string;
builtManifestPath: string;
exePath: string;
installDir: string;
installedExePath: string;
installerPayloadPath: string;
installerScriptPath: string;
publicDesktopShortcutPath: string;
latestYmlPath: string;
installMarkerPath: string;
installTimingPath: string;
nsisLogPath: string;
nsisIncludePath: string;
packagedConfigPath: string;
resourceRoot: string;
setupPath: string;
startMenuShortcutPath: string;
tarballsRoot: string;
userDesktopShortcutPath: string;
uninstallMarkerPath: string;
uninstallTimingPath: string;
uninstallerPath: string;
webStandaloneHookAuditPath: string;
webStandaloneHookConfigPath: string;
winIconPath: string;
unpackedExePath: string;
unpackedRoot: string;
};
export type WinPackResult = {
blockmapPath: string | null;
installerPath: string | null;
latestYmlPath: string | null;
outputRoot: string;
resourceRoot: string;
runtimeNamespaceRoot: string;
cacheReport: CacheReport;
sizeReport: WinSizeReport;
timings: WinPackTiming[];
to: ToolPackConfig["to"];
unpackedPath: string | null;
webStandaloneHookAuditPath: string | null;
};
export type WinPackTiming = {
durationMs: number;
phase: string;
};
export type WinSizeReport = {
builder: {
asar: boolean;
buildDependenciesFromSource: boolean;
filePatterns: readonly string[];
nativeRebuild: {
buildFromSource: boolean;
mode: "parallel" | "sequential";
modules: readonly string[];
};
nodeGypRebuild: boolean;
npmRebuild: boolean;
targets: Array<"dir" | "nsis">;
webOutputMode: ToolPackConfig["webOutputMode"];
};
generatedAt: string;
installerBytes: number | null;
outputRootBytes: number;
resourceRootBytes: number;
runtimeNamespaceRoot: string;
topLevel: {
appResourcesBytes: number;
copiedStandaloneBytes: number;
electronLocalesBytes: number;
resourcesBytes: number;
};
tracked: {
appNodeModulesBytes: number;
betterSqlite3Bytes: number;
betterSqlite3SourceResidueBytes: number;
bundledNodeBytes: number;
copiedStandaloneNextBytes: number;
copiedStandaloneNextSwcBytes: number;
copiedStandaloneNodeModulesBytes: number;
copiedStandalonePnpmHoistedNextBytes: number;
copiedStandaloneSharpLibvipsBytes: number;
copiedStandaloneSourcemapBytes: number;
copiedStandaloneTsbuildInfoBytes: number;
copiedStandaloneWebNextBytes: number;
copiedStandaloneWebNodeModulesBytes: number;
electronLocalesBytes: number;
markdownBytes: number;
nextBytes: number;
nextSwcBytes: number;
sharpLibvipsBytes: number;
sourcemapBytes: number;
tsbuildInfoBytes: number;
webCopiedStandaloneBytes: number;
webNextCacheBytes: number;
webPackageAppBytes: number;
webPackageBytes: number;
webPackageDistBytes: number;
webPackagePublicBytes: number;
webPackageSrcBytes: number;
webPackageStandaloneBytes: number;
};
unpackedBytes: number | null;
};
export type WinInstallResult = {
desktopShortcutExists: boolean;
desktopShortcutPath: string;
installDir: string;
installerPath: string;
installPayload: WinInstallPayloadReport;
markerPath: string;
namespace: string;
nsisLogPath: string;
registryEntries: WindowsUninstallRegistryEntry[];
startMenuShortcutExists: boolean;
startMenuShortcutPath: string;
timingPath: string;
uninstallerPath: string;
};
export type WinInstallPayloadReport = {
fileCount: number;
totalBytes: number;
topLevel: Array<{
bytes: number;
fileCount: number;
path: string;
}>;
};
export type WinStartResult = {
executablePath: string;
logPath: string;
namespace: string;
pid: number;
source: "built" | "installed";
status: DesktopStatusSnapshot | null;
};
export type WinStopResult = {
gracefulRequested: boolean;
namespace: string;
remainingPids: number[];
status: "not-running" | "partial" | "stopped";
stoppedPids: number[];
};
export type WinUninstallResult = {
markerPath: string;
namespace: string;
nsisLogPath: string;
registryResiduesRemoved: string[];
removedDataRoot: boolean;
removedLogsRoot: boolean;
removedProductUserDataRoot: boolean;
removedSidecarRoot: boolean;
removalPlan: WinRemovalTarget[];
residueObservation: WinResidueObservation;
stop: WinStopResult;
timingPath: string;
uninstallerPath: string;
};
export type WinCleanupResult = {
namespace: string;
removedOutputRoot: boolean;
removedProductUserDataRoot: boolean;
removedRuntimeNamespaceRoot: boolean;
removalPlan: WinRemovalTarget[];
residueObservation: WinResidueObservation;
stop: WinStopResult;
};
export type WindowsUninstallRegistryEntry = {
displayIcon: string | null;
displayName: string | null;
displayVersion: string | null;
installLocation: string | null;
keyPath: string;
publisher: string | null;
quietUninstallString: string | null;
uninstallString: string | null;
};
export type WinResidueObservation = {
installDirExists: boolean;
installedExeExists: boolean;
managedProcessPids: number[];
productNamespaceRootExists: boolean;
productUserDataRootExists: boolean;
publicDesktopShortcutExists: boolean;
registryResidues: string[];
runtimeNamespaceRootExists: boolean;
startMenuShortcutExists: boolean;
uninstallerExists: boolean;
userDesktopShortcutExists: boolean;
};
export type WinRemovalTarget = {
exists: boolean;
path: string;
scope: "data" | "logs" | "product-user-data" | "sidecars";
willRemove: boolean;
};
export type WinListResult = {
current: {
builtExecutableExists: boolean;
builtExecutablePath: string | null;
builtManifestPath: string;
installDir: string;
publicDesktopShortcutExists: boolean;
publicDesktopShortcutPath: string;
installedExeExists: boolean;
installedExePath: string;
namespace: string;
registryEntries: WindowsUninstallRegistryEntry[];
registryResidues: string[];
productNamespaceRoot: string;
productNamespaceRootExists: boolean;
productUserDataRoot: string;
productUserDataRootExists: boolean;
removalPlan: WinRemovalTarget[];
runtimeNamespaceRoot: string;
runtimeNamespaceRootExists: boolean;
setupExists: boolean;
setupPath: string;
startMenuShortcutExists: boolean;
startMenuShortcutPath: string;
uninstallerExists: boolean;
uninstallerPath: string;
userDesktopShortcutExists: boolean;
userDesktopShortcutPath: string;
};
outputNamespaces: string[];
runtimeNamespaces: string[];
};
export type WinResetResult = {
namespaces: string[];
results: WinCleanupResult[];
};
export type WinInspectResult = {
eval?: DesktopEvalResult;
screenshot?: DesktopScreenshotResult;
status: DesktopStatusSnapshot | null;
};

View file

@ -0,0 +1,196 @@
import { createHash } from "node:crypto";
import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { hashJson, hashPath, ToolPackCache } from "./cache.js";
import type { ToolPackConfig } from "./config.js";
import { hashPackageSourcePath } from "./package-source-hash.js";
const WORKSPACE_BUILD_PACKAGES = [
{ directory: "packages/contracts", name: "@open-design/contracts" },
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },
{ directory: "packages/sidecar", name: "@open-design/sidecar" },
{ directory: "packages/platform", name: "@open-design/platform" },
{ directory: "apps/daemon", name: "@open-design/daemon" },
{ directory: "apps/web", name: "@open-design/web" },
{ directory: "apps/desktop", name: "@open-design/desktop" },
{ directory: "apps/packaged", name: "@open-design/packaged" },
] as const;
const BUILD_COMMANDS = [
{ args: ["--filter", "@open-design/contracts", "build"] },
{ args: ["--filter", "@open-design/sidecar-proto", "build"] },
{ args: ["--filter", "@open-design/sidecar", "build"] },
{ args: ["--filter", "@open-design/platform", "build"] },
{ args: ["--filter", "@open-design/daemon", "build"] },
{ args: ["--filter", "@open-design/web", "build"], env: ["OD_WEB_OUTPUT_MODE"] },
{ args: ["--filter", "@open-design/web", "build:sidecar"] },
{ args: ["--filter", "@open-design/desktop", "build"] },
{ args: ["--filter", "@open-design/packaged", "build"] },
] as const;
type WorkspaceBuildMetadata = {
builtAt: string;
outputFiles: string[];
};
type WorkspaceBuildArtifact = {
cachePath: string;
workspacePath: string;
};
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function hashText(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
async function readPackageManager(workspaceRoot: string): Promise<unknown> {
const rootPackageJson = JSON.parse(await readFile(join(workspaceRoot, "package.json"), "utf8")) as {
packageManager?: unknown;
};
return rootPackageJson.packageManager;
}
async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise<string> {
const packageHashes: Record<string, string> = {};
for (const packageInfo of WORKSPACE_BUILD_PACKAGES) {
packageHashes[packageInfo.name] = await hashPackageSourcePath(join(config.workspaceRoot, packageInfo.directory));
}
return hashJson({
buildCommands: BUILD_COMMANDS,
node: "win.workspace-build",
nodeVersion: process.version,
packageHashes,
packageManager: await readPackageManager(config.workspaceRoot),
platform: config.platform,
pnpmLock: await hashPath(join(config.workspaceRoot, "pnpm-lock.yaml")),
schemaVersion: 4,
webOutputMode: config.webOutputMode,
});
}
function workspaceBuildOutputFiles(config: ToolPackConfig): string[] {
const webStandaloneServerCandidates = [
"apps/web/.next/standalone/apps/web/server.js",
"apps/web/.next/standalone/server.js",
];
return [
"packages/contracts/dist/index.mjs",
"packages/contracts/dist/index.d.ts",
"packages/sidecar-proto/dist/index.mjs",
"packages/sidecar-proto/dist/index.d.ts",
"packages/sidecar/dist/index.mjs",
"packages/sidecar/dist/index.d.ts",
"packages/platform/dist/index.mjs",
"packages/platform/dist/index.d.ts",
"apps/daemon/dist/cli.js",
"apps/daemon/dist/cli.d.ts",
"apps/daemon/dist/sidecar/index.js",
"apps/web/dist/sidecar/index.js",
"apps/web/dist/sidecar/index.d.ts",
...(config.webOutputMode === "standalone" ? [webStandaloneServerCandidates.join("|")] : ["apps/web/.next/BUILD_ID"]),
"apps/desktop/dist/main/index.js",
"apps/desktop/dist/main/index.d.ts",
"apps/packaged/dist/index.mjs",
"apps/packaged/dist/index.d.ts",
];
}
function workspaceBuildArtifacts(config: ToolPackConfig): WorkspaceBuildArtifact[] {
const artifacts = [
"packages/contracts/dist",
"packages/sidecar-proto/dist",
"packages/sidecar/dist",
"packages/platform/dist",
"apps/daemon/dist",
"apps/web/dist",
"apps/desktop/dist",
"apps/packaged/dist",
];
if (config.webOutputMode === "standalone") {
artifacts.push("apps/web/.next/standalone", "apps/web/.next/static");
} else {
artifacts.push("apps/web/.next/BUILD_ID");
}
return artifacts.map((workspacePath) => ({
cachePath: join("outputs", ...workspacePath.split("/")),
workspacePath,
}));
}
async function copyWorkspaceBuildArtifactsToCache(config: ToolPackConfig, entryRoot: string): Promise<void> {
for (const artifact of workspaceBuildArtifacts(config)) {
const targetPath = join(entryRoot, artifact.cachePath);
await mkdir(dirname(targetPath), { recursive: true });
await cp(join(config.workspaceRoot, artifact.workspacePath), targetPath, { recursive: true });
}
}
async function missingWorkspaceBuildOutput(config: ToolPackConfig): Promise<string | null> {
for (const output of workspaceBuildOutputFiles(config)) {
const candidates = output.split("|");
const exists = await Promise.any(
candidates.map(async (candidate) => {
if (!(await pathExists(join(config.workspaceRoot, candidate)))) throw new Error(candidate);
return true;
}),
).catch(() => false);
if (!exists) return output;
}
return null;
}
export async function ensureWorkspaceBuildArtifacts(
config: ToolPackConfig,
cache: ToolPackCache,
build: () => Promise<void>,
): Promise<void> {
const key = await createWorkspaceBuildCacheKey(config);
const artifacts = workspaceBuildArtifacts(config);
await cache.acquire<WorkspaceBuildMetadata>({
materialize: artifacts.map((artifact) => ({
from: artifact.cachePath,
to: join(config.workspaceRoot, artifact.workspacePath),
})),
node: {
id: "win.workspace-build",
key,
outputs: ["stamp.json", ...artifacts.map((artifact) => artifact.cachePath)],
invalidate: async () => null,
build: async ({ entryRoot }) => {
await build();
const missingOutput = await missingWorkspaceBuildOutput(config);
if (missingOutput != null) {
throw new Error(`workspace build completed but output is missing: ${missingOutput}`);
}
await copyWorkspaceBuildArtifactsToCache(config, entryRoot);
const outputFiles = workspaceBuildOutputFiles(config);
await mkdir(entryRoot, { recursive: true });
await writeFile(
join(entryRoot, "stamp.json"),
`${JSON.stringify(
{
builtAt: new Date().toISOString(),
keyHash: hashText(key),
outputFiles,
webOutputMode: config.webOutputMode,
},
null,
2,
)}\n`,
"utf8",
);
return { builtAt: new Date().toISOString(), outputFiles };
},
},
});
}

View file

@ -0,0 +1,125 @@
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { ToolPackCache } from "../src/cache.js";
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
describe("ToolPackCache", () => {
it("builds once, materializes copies, and reports cache hits", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-cache-"));
const cacheRoot = join(root, "cache");
const firstOut = join(root, "first", "payload");
const secondOut = join(root, "second", "payload");
let builds = 0;
const cache = new ToolPackCache(cacheRoot);
const node = {
id: "test.node",
key: "key-1",
outputs: ["payload"],
invalidate: async () => null,
build: async ({ entryRoot }: { entryRoot: string }) => {
builds += 1;
await mkdir(join(entryRoot, "payload"), { recursive: true });
await writeFile(join(entryRoot, "payload", "value.txt"), `build-${builds}\n`, "utf8");
return { builds };
},
};
try {
const firstManifest = await cache.acquire({ materialize: [{ from: "payload", to: firstOut }], node });
await writeFile(join(firstOut, "value.txt"), "mutated\n", "utf8");
await cache.acquire({ materialize: [{ from: "payload", to: secondOut }], node });
expect(builds).toBe(1);
expect(firstManifest.entryPath).toContain("test.node");
expect(await readFile(join(secondOut, "value.txt"), "utf8")).toBe("build-1\n");
expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]);
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("rebuilds stale entries when declared outputs are missing", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-cache-stale-"));
const cacheRoot = join(root, "cache");
const out = join(root, "out", "payload");
let builds = 0;
const cache = new ToolPackCache(cacheRoot);
const node = {
id: "test.stale",
key: "key-1",
outputs: ["payload"],
invalidate: async () => null,
build: async ({ entryRoot }: { entryRoot: string }) => {
builds += 1;
if (builds === 1) {
await mkdir(join(entryRoot, "payload"), { recursive: true });
await writeFile(join(entryRoot, "payload", "value.txt"), "first\n", "utf8");
return { builds };
}
await mkdir(join(entryRoot, "payload"), { recursive: true });
await writeFile(join(entryRoot, "payload", "value.txt"), "second\n", "utf8");
return { builds };
},
};
try {
await cache.acquire({ materialize: [{ from: "payload", to: out }], node });
const entryPath = cache.report().entries[0]?.entryPath;
expect(entryPath).toBeDefined();
await rm(join(entryPath!, "payload"), { force: true, recursive: true });
await cache.acquire({ materialize: [{ from: "payload", to: out }], node });
expect(builds).toBe(2);
expect(await readFile(join(out, "value.txt"), "utf8")).toBe("second\n");
expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "stale"]);
expect(await pathExists(join(cacheRoot, "locks", "global.lock"))).toBe(false);
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("reads existing hits without building", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-cache-read-hit-"));
const cacheRoot = join(root, "cache");
const out = join(root, "out", "payload");
let builds = 0;
const cache = new ToolPackCache(cacheRoot);
const node = {
id: "test.read-hit",
key: "key-1",
outputs: ["payload"],
invalidate: async () => null,
build: async ({ entryRoot }: { entryRoot: string }) => {
builds += 1;
await mkdir(join(entryRoot, "payload"), { recursive: true });
await writeFile(join(entryRoot, "payload", "value.txt"), `build-${builds}\n`, "utf8");
return { builds };
},
};
try {
expect(await cache.readHit({ materialize: [{ from: "payload", to: out }], node })).toBeNull();
await cache.acquire({ materialize: [], node });
const hit = await cache.readHit({ materialize: [{ from: "payload", to: out }], node });
expect(hit?.payloadMetadata.builds).toBe(1);
expect(builds).toBe(1);
expect(await readFile(join(out, "value.txt"), "utf8")).toBe("build-1\n");
expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]);
} finally {
await rm(root, { force: true, recursive: true });
}
});
});

View file

@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { posix } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
@ -37,6 +38,7 @@ function makeConfig(): ToolPackConfig {
namespaceBaseRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces",
namespaceRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces/default",
},
cacheRoot: "/work/.tmp/tools-pack/cache",
toolPackRoot: "/work/.tmp/tools-pack",
},
silent: true,
@ -65,10 +67,10 @@ describe("buildDockerArgs", () => {
it("mounts docker home and electron caches under .tmp/tools-pack/.docker-*", () => {
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
expect(args).toContain(`${join("/work/.tmp/tools-pack", ".docker-home")}:/home/builder`);
expect(args).toContain(`${join("/work/.tmp/tools-pack", ".docker-cache", "electron")}:/home/builder/.cache/electron`);
expect(args).toContain(`${posix.join("/work/.tmp/tools-pack", ".docker-home")}:/home/builder`);
expect(args).toContain(`${posix.join("/work/.tmp/tools-pack", ".docker-cache", "electron")}:/home/builder/.cache/electron`);
expect(args).toContain(
`${join("/work/.tmp/tools-pack", ".docker-cache", "electron-builder")}:/home/builder/.cache/electron-builder`,
`${posix.join("/work/.tmp/tools-pack", ".docker-cache", "electron-builder")}:/home/builder/.cache/electron-builder`,
);
});

View file

@ -1,6 +1,6 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os, { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@ -32,6 +32,7 @@ function makeConfig(root: string, overrides: Partial<ToolPackConfig> = {}): Tool
namespaceBaseRoot: join(root, ".tmp", "tools-pack", "runtime", "mac", "namespaces"),
namespaceRoot: join(root, ".tmp", "tools-pack", "runtime", "mac", "namespaces", "local-test"),
},
cacheRoot: join(root, ".tmp", "tools-pack", "cache"),
toolPackRoot: join(root, ".tmp", "tools-pack"),
},
silent: true,
@ -57,8 +58,8 @@ describe("resolveSeededAppConfigPaths", () => {
it("uses workspace .od by default", () => {
const config = makeConfig("/work");
expect(resolveSeededAppConfigPaths(config)).toEqual({
sourcePath: "/work/.od/app-config.json",
targetPath: "/work/.tmp/tools-pack/runtime/mac/namespaces/local-test/data/app-config.json",
sourcePath: join("/work", ".od", "app-config.json"),
targetPath: join("/work", ".tmp", "tools-pack", "runtime", "mac", "namespaces", "local-test", "data", "app-config.json"),
});
});
@ -66,8 +67,8 @@ describe("resolveSeededAppConfigPaths", () => {
process.env.OD_DATA_DIR = "/custom/data";
const config = makeConfig("/work");
expect(resolveSeededAppConfigPaths(config)).toEqual({
sourcePath: "/custom/data/app-config.json",
targetPath: "/work/.tmp/tools-pack/runtime/mac/namespaces/local-test/data/app-config.json",
sourcePath: join("/custom/data", "app-config.json"),
targetPath: join("/work", ".tmp", "tools-pack", "runtime", "mac", "namespaces", "local-test", "data", "app-config.json"),
});
});
@ -75,8 +76,8 @@ describe("resolveSeededAppConfigPaths", () => {
process.env.OD_DATA_DIR = "e2e/ui/.od-data";
const config = makeConfig("/work");
expect(resolveSeededAppConfigPaths(config)).toEqual({
sourcePath: "/work/e2e/ui/.od-data/app-config.json",
targetPath: "/work/.tmp/tools-pack/runtime/mac/namespaces/local-test/data/app-config.json",
sourcePath: resolve("/work", "e2e", "ui", ".od-data", "app-config.json"),
targetPath: join("/work", ".tmp", "tools-pack", "runtime", "mac", "namespaces", "local-test", "data", "app-config.json"),
});
});
@ -85,7 +86,7 @@ describe("resolveSeededAppConfigPaths", () => {
const config = makeConfig("/work");
expect(resolveSeededAppConfigPaths(config)).toEqual({
sourcePath: join(os.homedir(), ".open-design", "app-config.json"),
targetPath: "/work/.tmp/tools-pack/runtime/mac/namespaces/local-test/data/app-config.json",
targetPath: join("/work", ".tmp", "tools-pack", "runtime", "mac", "namespaces", "local-test", "data", "app-config.json"),
});
});
});

View file

@ -0,0 +1,27 @@
import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { hashPackageSourcePath } from "../src/package-source-hash.js";
describe("hashPackageSourcePath", () => {
it("includes package versions in the source hash", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-package-source-hash-"));
const packageRoot = join(root, "apps", "packaged");
try {
await mkdir(join(packageRoot, "src"), { recursive: true });
await writeFile(join(packageRoot, "src", "index.ts"), "export const value = 1;\n", "utf8");
await writeFile(join(packageRoot, "package.json"), `${JSON.stringify({ name: "@open-design/packaged", version: "1.0.0" }, null, 2)}\n`, "utf8");
const firstHash = await hashPackageSourcePath(packageRoot);
await writeFile(join(packageRoot, "package.json"), `${JSON.stringify({ name: "@open-design/packaged", version: "1.0.1" }, null, 2)}\n`, "utf8");
const secondHash = await hashPackageSourcePath(packageRoot);
expect(secondHash).not.toBe(firstHash);
} finally {
await rm(root, { force: true, recursive: true });
}
});
});

View file

@ -0,0 +1,176 @@
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path, { join } from "node:path";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const runWebStandaloneAfterPack = require("../resources/web-standalone-after-pack.cjs") as (context: unknown) => Promise<void>;
const CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG";
async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function writePackage(packageRoot: string, packageName: string): Promise<void> {
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(packageRoot, "package.json"),
`${JSON.stringify({ name: packageName, version: "0.0.0" }, null, 2)}\n`,
"utf8",
);
await writeFile(join(packageRoot, "index.js"), "module.exports = {};\n", "utf8");
}
async function writeRootWebPackage(resourcesRoot: string): Promise<void> {
const webPackageRoot = join(resourcesRoot, "app", "node_modules", "@open-design", "web");
await mkdir(join(webPackageRoot, "dist", "sidecar"), { recursive: true });
await writeFile(join(webPackageRoot, "package.json"), "{\"name\":\"@open-design/web\"}\n", "utf8");
await writeFile(join(webPackageRoot, "dist", "sidecar", "index.js"), "module.exports = {};\n", "utf8");
}
async function writeStandaloneFixture(
workspaceRoot: string,
options: { includeHoistedNext: boolean; includeWebNext: boolean },
): Promise<string> {
const standaloneRoot = join(workspaceRoot, "apps", "web", ".next", "standalone");
const sourceWebRoot = join(standaloneRoot, "apps", "web");
const hoistRoot = join(standaloneRoot, "node_modules", ".pnpm", "node_modules");
if (options.includeHoistedNext) {
await writePackage(join(hoistRoot, "next"), "next");
}
await writePackage(join(hoistRoot, "react"), "react");
await writePackage(join(hoistRoot, "react-dom"), "react-dom");
await writePackage(join(hoistRoot, "styled-jsx"), "styled-jsx");
await mkdir(join(sourceWebRoot, ".next", "static"), { recursive: true });
await writeFile(join(sourceWebRoot, "server.js"), "module.exports = {};\n", "utf8");
await writeFile(join(sourceWebRoot, ".next", "BUILD_ID"), "fixture\n", "utf8");
if (options.includeWebNext) {
await writePackage(join(sourceWebRoot, "node_modules", "next"), "next");
}
await mkdir(join(workspaceRoot, "apps", "web", ".next", "static"), { recursive: true });
await writeFile(join(workspaceRoot, "apps", "web", ".next", "static", "client.js"), "client();\n", "utf8");
return standaloneRoot;
}
async function runFixture(options: { includeHoistedNext?: boolean; includeWebNext: boolean }): Promise<{
appOutDir: string;
auditReportPath: string;
destinationRoot: string;
root: string;
}> {
const root = await mkdtemp(join(tmpdir(), "open-design-web-standalone-hook-"));
const workspaceRoot = join(root, "workspace");
const standaloneSourceRoot = await writeStandaloneFixture(workspaceRoot, {
includeHoistedNext: options.includeHoistedNext ?? true,
includeWebNext: options.includeWebNext,
});
const appOutDir = join(root, "builder", "win-unpacked");
const resourcesRoot = join(appOutDir, "resources");
const auditReportPath = join(root, "audit.json");
const configPath = join(root, "config.json");
const oldConfigEnv = process.env[CONFIG_ENV];
await mkdir(resourcesRoot, { recursive: true });
await writeRootWebPackage(resourcesRoot);
await writeFile(
configPath,
`${JSON.stringify(
{
auditReportPath,
pruneCopiedSharp: false,
pruneRootNext: false,
pruneRootSharp: false,
resourceName: "open-design-web-standalone",
standaloneSourceRoot,
version: 1,
webPublicSourceRoot: join(workspaceRoot, "apps", "web", "public"),
webStaticSourceRoot: join(workspaceRoot, "apps", "web", ".next", "static"),
workspaceRoot,
},
null,
2,
)}\n`,
"utf8",
);
process.env[CONFIG_ENV] = configPath;
try {
await runWebStandaloneAfterPack({
appOutDir,
electronPlatformName: "win32",
packager: { appInfo: { productFilename: "Open Design" } },
});
} catch (error) {
await rm(root, { force: true, recursive: true });
throw error;
} finally {
if (oldConfigEnv == null) {
delete process.env[CONFIG_ENV];
} else {
process.env[CONFIG_ENV] = oldConfigEnv;
}
}
return {
appOutDir,
auditReportPath,
destinationRoot: join(resourcesRoot, "open-design-web-standalone"),
root,
};
}
describe("web standalone afterPack hook", () => {
it("deduplicates win32 copied standalone Next while retaining the app-local Next package", async () => {
const fixture = await runFixture({ includeWebNext: true });
try {
expect(await pathExists(join(fixture.destinationRoot, "node_modules", "next"))).toBe(false);
expect(await pathExists(join(fixture.destinationRoot, "node_modules", ".pnpm", "node_modules", "next"))).toBe(false);
expect(await pathExists(join(fixture.destinationRoot, "apps", "web", "node_modules", "next", "package.json"))).toBe(true);
const report = JSON.parse(await readFile(fixture.auditReportPath, "utf8")) as {
copiedAudit: { resolvedModules: Record<string, string>; brokenSymlinks: string[] };
copiedNextDedupe: { removedPaths: Array<{ reason: string }>; retainedPath: string };
copiedNextDedupeAudit: { resolvedNextPackagePath: string; remainingPaths: string[] };
};
const resolvedNextPath = report.copiedNextDedupeAudit.resolvedNextPackagePath.split(path.sep).join("/");
expect(report.copiedNextDedupe.removedPaths.map((entry) => entry.reason)).toEqual([
"copied standalone root next public-hoist duplicate",
"copied standalone pnpm-hoisted next duplicate superseded by app-local next",
]);
expect(report.copiedNextDedupe.retainedPath.split(path.sep).join("/")).toMatch(
/apps\/web\/node_modules\/next$/,
);
expect(report.copiedNextDedupeAudit.remainingPaths).toEqual([]);
expect(resolvedNextPath).toMatch(
/open-design-web-standalone\/apps\/web\/node_modules\/next\/package\.json$/,
);
expect(report.copiedAudit.brokenSymlinks).toEqual([]);
expect(report.copiedAudit.resolvedModules["next/package.json"].split(path.sep).join("/")).toMatch(
/open-design-web-standalone\/apps\/web\/node_modules\/next\/package\.json$/,
);
} finally {
await rm(fixture.root, { force: true, recursive: true });
}
});
it("fails the win32 Next dedupe when no copied Next package exists", async () => {
await expect(runFixture({ includeHoistedNext: false, includeWebNext: false })).rejects.toThrow(
/copied standalone app-local Next package missing/,
);
});
});

View file

@ -0,0 +1,81 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { ToolPackConfig } from "../src/config.js";
import { createWorkspaceTarballsCacheKey } from "../src/win/app.js";
const PACKAGE_DIRS = [
"packages/contracts",
"packages/sidecar-proto",
"packages/sidecar",
"packages/platform",
"apps/daemon",
"apps/web",
"apps/desktop",
"apps/packaged",
] as const;
async function writeWorkspace(root: string): Promise<void> {
await writeFile(join(root, "package.json"), `${JSON.stringify({ packageManager: "pnpm@10.33.2" }, null, 2)}\n`, "utf8");
await writeFile(join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
for (const directory of PACKAGE_DIRS) {
await mkdir(join(root, directory, "src"), { recursive: true });
await writeFile(join(root, directory, "package.json"), `${JSON.stringify({ name: directory, version: "0.0.0" }, null, 2)}\n`, "utf8");
await writeFile(join(root, directory, "src", "index.ts"), "export const value = 1;\n", "utf8");
}
}
function createConfig(root: string, webOutputMode: ToolPackConfig["webOutputMode"]): ToolPackConfig {
return {
containerized: false,
electronBuilderCliPath: "electron-builder",
electronDistPath: "electron-dist",
electronVersion: "41.3.0",
macCompression: "normal",
namespace: "test",
platform: "win",
portable: false,
removeData: false,
removeLogs: false,
removeProductUserData: false,
removeSidecars: false,
roots: {
cacheRoot: join(root, ".cache"),
output: {
appBuilderRoot: join(root, ".tmp", "builder"),
namespaceRoot: join(root, ".tmp", "out", "win", "namespaces", "test"),
platformRoot: join(root, ".tmp", "out", "win"),
root: join(root, ".tmp", "out"),
},
runtime: {
namespaceBaseRoot: join(root, ".tmp", "runtime", "win", "namespaces"),
namespaceRoot: join(root, ".tmp", "runtime", "win", "namespaces", "test"),
},
toolPackRoot: join(root, ".tmp", "tools-pack"),
},
signed: false,
silent: true,
to: "dir",
webOutputMode,
workspaceRoot: root,
};
}
describe("createWorkspaceTarballsCacheKey", () => {
it("invalidates when the web output mode changes", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-win-app-"));
try {
await writeWorkspace(root);
await expect(createWorkspaceTarballsCacheKey(createConfig(root, "server"))).resolves.not.toBe(
await createWorkspaceTarballsCacheKey(createConfig(root, "standalone")),
);
} finally {
await rm(root, { force: true, recursive: true });
}
});
});

View file

@ -0,0 +1,77 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { materializeCachedUnpackedForInstaller } from "../src/win/builder.js";
import type { WinPaths } from "../src/win/types.js";
function createPaths(root: string): WinPaths {
const namespaceRoot = join(root, "namespaces", "second");
return {
appBuilderConfigPath: join(namespaceRoot, "builder-config.json"),
appBuilderOutputRoot: join(namespaceRoot, "builder"),
assembledAppRoot: join(namespaceRoot, "assembled", "app"),
assembledMainEntryPath: join(namespaceRoot, "assembled", "app", "main.cjs"),
assembledPackageJsonPath: join(namespaceRoot, "assembled", "app", "package.json"),
blockmapPath: join(namespaceRoot, "builder", "Open Design-second-setup.exe.blockmap"),
builtManifestPath: join(namespaceRoot, "built-app.json"),
exePath: join(namespaceRoot, "builder", "Open Design-second.exe"),
installDir: join(namespaceRoot, "runtime", "install", "Open Design"),
installedExePath: join(namespaceRoot, "runtime", "install", "Open Design", "Open Design.exe"),
installerPayloadPath: join(namespaceRoot, "installer", "payload.7z"),
installerScriptPath: join(namespaceRoot, "installer", "installer.nsi"),
publicDesktopShortcutPath: join(namespaceRoot, "desktop", "public.lnk"),
latestYmlPath: join(namespaceRoot, "builder", "latest.yml"),
installMarkerPath: join(namespaceRoot, "logs", "install.marker.json"),
installTimingPath: join(namespaceRoot, "logs", "install.timing.json"),
nsisLogPath: join(namespaceRoot, "logs", "nsis.log"),
nsisIncludePath: join(namespaceRoot, "nsis", "installer.nsh"),
packagedConfigPath: join(namespaceRoot, "open-design-config.json"),
resourceRoot: join(namespaceRoot, "resources", "open-design"),
setupPath: join(namespaceRoot, "builder", "Open Design-second-setup.exe"),
startMenuShortcutPath: join(namespaceRoot, "start-menu.lnk"),
tarballsRoot: join(namespaceRoot, "tarballs"),
userDesktopShortcutPath: join(namespaceRoot, "desktop", "user.lnk"),
uninstallMarkerPath: join(namespaceRoot, "logs", "uninstall.marker.json"),
uninstallTimingPath: join(namespaceRoot, "logs", "uninstall.timing.json"),
uninstallerPath: join(namespaceRoot, "runtime", "install", "Open Design", "Uninstall.exe"),
webStandaloneHookAuditPath: join(namespaceRoot, "web-standalone-after-pack-audit.json"),
webStandaloneHookConfigPath: join(namespaceRoot, "web-standalone-after-pack-config.json"),
winIconPath: join(namespaceRoot, "resources", "win", "icon.ico"),
unpackedExePath: join(namespaceRoot, "builder", "win-unpacked", "Open Design.exe"),
unpackedRoot: join(namespaceRoot, "builder", "win-unpacked"),
};
}
describe("materializeCachedUnpackedForInstaller", () => {
it("overwrites cached packaged config with the current namespace config", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-win-builder-"));
const cachedUnpackedRoot = join(root, "cache", "builder", "win-unpacked");
const paths = createPaths(root);
try {
await mkdir(join(cachedUnpackedRoot, "resources"), { recursive: true });
await writeFile(join(cachedUnpackedRoot, "Open Design.exe"), "exe\n", "utf8");
await writeFile(
join(cachedUnpackedRoot, "resources", "open-design-config.json"),
`${JSON.stringify({ namespace: "first", version: 1 })}\n`,
"utf8",
);
await mkdir(join(paths.packagedConfigPath, ".."), { recursive: true });
await writeFile(paths.packagedConfigPath, `${JSON.stringify({ namespace: "second", version: 1 })}\n`, "utf8");
const manifest = await materializeCachedUnpackedForInstaller(cachedUnpackedRoot, paths);
expect(manifest.source).toBe("namespace");
expect(manifest.unpackedRoot).toBe(paths.unpackedRoot);
await expect(readFile(join(paths.unpackedRoot, "Open Design.exe"), "utf8")).resolves.toBe("exe\n");
await expect(readFile(join(paths.unpackedRoot, "resources", "open-design-config.json"), "utf8")).resolves.toContain(
'"namespace":"second"',
);
} finally {
await rm(root, { force: true, recursive: true });
}
});
});

View file

@ -0,0 +1,41 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { PathSizeIndex } from "../src/win/fs.js";
describe("PathSizeIndex", () => {
it("indexes directory sizes and filtered file totals in a single tree", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-win-size-index-"));
try {
await mkdir(join(root, "app", "node_modules", "@next", "swc-win32-x64"), { recursive: true });
await mkdir(join(root, "app", "node_modules", "@next", "swc-linux-x64"), { recursive: true });
await writeFile(join(root, "app", "main.js"), "main\n", "utf8");
await writeFile(join(root, "app", "main.js.map"), "map-data\n", "utf8");
await writeFile(join(root, "app", "node_modules", "@next", "swc-win32-x64", "next-swc.node"), "win-swc\n", "utf8");
await writeFile(join(root, "app", "node_modules", "@next", "swc-linux-x64", "next-swc.node"), "linux-swc\n", "utf8");
const index = await PathSizeIndex.create(root);
expect(index.sizePathBytes(join(root, "missing"))).toBe(0);
expect(index.sizePathBytes(join(root, "app", "main.js"))).toBe(Buffer.byteLength("main\n"));
expect(index.sizePathBytes(join(root, "app"), { includeFile: (path) => path.endsWith(".map") })).toBe(
Buffer.byteLength("map-data\n"),
);
expect(index.sumChildDirectorySizes(join(root, "app", "node_modules", "@next"), (name) => name.startsWith("swc-win32-"))).toBe(
Buffer.byteLength("win-swc\n"),
);
expect(index.sizePathBytes(root)).toBe(
Buffer.byteLength("main\n") +
Buffer.byteLength("map-data\n") +
Buffer.byteLength("win-swc\n") +
Buffer.byteLength("linux-swc\n"),
);
} finally {
await rm(root, { force: true, recursive: true });
}
});
});

View file

@ -0,0 +1,148 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { ToolPackCache } from "../src/cache.js";
import type { ToolPackConfig } from "../src/config.js";
import { ensureWorkspaceBuildArtifacts } from "../src/workspace-build.js";
const PACKAGE_DIRS = [
"packages/contracts",
"packages/sidecar-proto",
"packages/sidecar",
"packages/platform",
"apps/daemon",
"apps/web",
"apps/desktop",
"apps/packaged",
] as const;
const OUTPUT_FILES = [
"packages/contracts/dist/index.mjs",
"packages/contracts/dist/index.d.ts",
"packages/sidecar-proto/dist/index.mjs",
"packages/sidecar-proto/dist/index.d.ts",
"packages/sidecar/dist/index.mjs",
"packages/sidecar/dist/index.d.ts",
"packages/platform/dist/index.mjs",
"packages/platform/dist/index.d.ts",
"apps/daemon/dist/cli.js",
"apps/daemon/dist/cli.d.ts",
"apps/daemon/dist/sidecar/index.js",
"apps/web/dist/sidecar/index.js",
"apps/web/dist/sidecar/index.d.ts",
"apps/web/.next/standalone/apps/web/server.js",
"apps/web/.next/static/chunk.js",
"apps/desktop/dist/main/index.js",
"apps/desktop/dist/main/index.d.ts",
"apps/packaged/dist/index.mjs",
"apps/packaged/dist/index.d.ts",
] as const;
async function writeWorkspace(root: string): Promise<void> {
await writeFile(join(root, "package.json"), `${JSON.stringify({ packageManager: "pnpm@10.33.2" }, null, 2)}\n`, "utf8");
await writeFile(join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8");
for (const directory of PACKAGE_DIRS) {
await mkdir(join(root, directory, "src"), { recursive: true });
await writeFile(join(root, directory, "package.json"), `${JSON.stringify({ name: directory }, null, 2)}\n`, "utf8");
await writeFile(join(root, directory, "src", "index.ts"), "export const value = 1;\n", "utf8");
}
}
async function writeOutputs(root: string, value: string): Promise<void> {
for (const file of OUTPUT_FILES) {
await mkdir(join(root, file, ".."), { recursive: true });
await writeFile(join(root, file), `${value}\n`, "utf8");
}
}
function createConfig(root: string, cacheRoot: string): ToolPackConfig {
return {
containerized: false,
electronBuilderCliPath: "electron-builder",
electronDistPath: "electron-dist",
electronVersion: "41.3.0",
macCompression: "normal",
namespace: "test",
platform: "win",
portable: false,
removeData: false,
removeLogs: false,
removeProductUserData: false,
removeSidecars: false,
roots: {
cacheRoot,
output: {
appBuilderRoot: join(root, ".tmp", "builder"),
namespaceRoot: join(root, ".tmp", "out", "win", "namespaces", "test"),
platformRoot: join(root, ".tmp", "out", "win"),
root: join(root, ".tmp", "out"),
},
runtime: {
namespaceBaseRoot: join(root, ".tmp", "runtime", "win", "namespaces"),
namespaceRoot: join(root, ".tmp", "runtime", "win", "namespaces", "test"),
},
toolPackRoot: join(root, ".tmp", "tools-pack"),
},
signed: false,
silent: true,
to: "dir",
webOutputMode: "standalone",
workspaceRoot: root,
};
}
describe("ensureWorkspaceBuildArtifacts", () => {
it("builds once and skips when the key and outputs are still valid", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-"));
const cache = new ToolPackCache(join(root, ".cache"));
const config = createConfig(root, cache.root);
let builds = 0;
try {
await writeWorkspace(root);
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
builds += 1;
await writeOutputs(root, `build-${builds}`);
});
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
builds += 1;
await writeOutputs(root, `build-${builds}`);
});
expect(builds).toBe(1);
expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]);
expect(await readFile(join(root, "apps/packaged/dist/index.mjs"), "utf8")).toBe("build-1\n");
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("materializes cached outputs when an expected workspace output is missing", async () => {
const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-stale-"));
const cache = new ToolPackCache(join(root, ".cache"));
const config = createConfig(root, cache.root);
let builds = 0;
try {
await writeWorkspace(root);
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
builds += 1;
await writeOutputs(root, `build-${builds}`);
});
await rm(join(root, "apps/web/dist/sidecar/index.js"), { force: true });
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
builds += 1;
await writeOutputs(root, `build-${builds}`);
});
expect(builds).toBe(1);
expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]);
expect(await readFile(join(root, "apps/web/dist/sidecar/index.js"), "utf8")).toBe("build-1\n");
} finally {
await rm(root, { force: true, recursive: true });
}
});
});