mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
38eb78a382
commit
6efac8887e
58 changed files with 5542 additions and 1417 deletions
230
.github/workflows/release-beta.yml
vendored
230
.github/workflows/release-beta.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@open-design/packaged",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
424
e2e/specs/win.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
BIN
tools/pack/resources/win/7zip/7z.dll
Normal file
BIN
tools/pack/resources/win/7zip/7z.dll
Normal file
Binary file not shown.
BIN
tools/pack/resources/win/7zip/7z.exe
Normal file
BIN
tools/pack/resources/win/7zip/7z.exe
Normal file
Binary file not shown.
146
tools/pack/resources/win/7zip/License.txt
Normal file
146
tools/pack/resources/win/7zip/License.txt
Normal 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.
|
||||
|
||||
--
|
||||
21
tools/pack/resources/win/7zip/README.md
Normal file
21
tools/pack/resources/win/7zip/README.md
Normal 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
296
tools/pack/src/cache.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
45
tools/pack/src/lock.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
41
tools/pack/src/package-source-hash.ts
Normal file
41
tools/pack/src/package-source-hash.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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
273
tools/pack/src/win/app.ts
Normal 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
101
tools/pack/src/win/build.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
312
tools/pack/src/win/builder.ts
Normal file
312
tools/pack/src/win/builder.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
41
tools/pack/src/win/constants.ts
Normal file
41
tools/pack/src/win/constants.ts
Normal 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;
|
||||
559
tools/pack/src/win/custom-installer.ts
Normal file
559
tools/pack/src/win/custom-installer.ts
Normal 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
134
tools/pack/src/win/fs.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
39
tools/pack/src/win/identity.ts
Normal file
39
tools/pack/src/win/identity.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
27
tools/pack/src/win/index.ts
Normal file
27
tools/pack/src/win/index.ts
Normal 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";
|
||||
429
tools/pack/src/win/lifecycle.ts
Normal file
429
tools/pack/src/win/lifecycle.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
67
tools/pack/src/win/manifest.ts
Normal file
67
tools/pack/src/win/manifest.ts
Normal 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
199
tools/pack/src/win/nsis.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
tools/pack/src/win/paths.ts
Normal file
84
tools/pack/src/win/paths.ts
Normal 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) })));
|
||||
}
|
||||
181
tools/pack/src/win/registry.ts
Normal file
181
tools/pack/src/win/registry.ts
Normal 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;
|
||||
}
|
||||
124
tools/pack/src/win/report.ts
Normal file
124
tools/pack/src/win/report.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
62
tools/pack/src/win/resources.ts
Normal file
62
tools/pack/src/win/resources.ts
Normal 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
305
tools/pack/src/win/types.ts
Normal 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;
|
||||
};
|
||||
196
tools/pack/src/workspace-build.ts
Normal file
196
tools/pack/src/workspace-build.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
125
tools/pack/tests/cache.test.ts
Normal file
125
tools/pack/tests/cache.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
27
tools/pack/tests/package-source-hash.test.ts
Normal file
27
tools/pack/tests/package-source-hash.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
176
tools/pack/tests/web-standalone-after-pack.test.ts
Normal file
176
tools/pack/tests/web-standalone-after-pack.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
81
tools/pack/tests/win-app.test.ts
Normal file
81
tools/pack/tests/win-app.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
77
tools/pack/tests/win-builder.test.ts
Normal file
77
tools/pack/tests/win-builder.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
41
tools/pack/tests/win-size-index.test.ts
Normal file
41
tools/pack/tests/win-size-index.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
148
tools/pack/tests/workspace-build.test.ts
Normal file
148
tools/pack/tests/workspace-build.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue