feat(desktop): add Windows runtime and NSIS packaging

Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
QCYTSN committed 2026-08-08 19:55:12 +08:00
1 parent 2b1b499b42
commit f5cb88cf49
9 files changed
+8536 -10

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
name: Desktop Windows
on:
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/desktop-windows.yml"
- "agent/**"
- "desktop/electron/**"
- "frontend/**"
- "pyproject.toml"
permissions:
contents: read
jobs:
package:
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: pip
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
cache-dependency-path: |
frontend/package-lock.json
desktop/electron/package-lock.json
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Install desktop dependencies
working-directory: desktop/electron
run: npm ci
- name: Assemble minimal Python runtime
working-directory: desktop/electron
shell: powershell
run: .\scripts\build-backend.ps1 -BuildPython "${{ env.pythonLocation }}\python.exe" -Clean
- name: Verify backend lifecycle
working-directory: desktop/electron
run: npm run smoke:lifecycle
- name: Build NSIS installer
working-directory: desktop/electron
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: npm run installer:win
- name: Verify installer and record checksum
working-directory: desktop/electron
shell: powershell
run: |
$installer = Get-ChildItem -LiteralPath release -Filter 'Vibe-Trading-Desktop-Unofficial-*-x64.exe' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $installer) { throw 'Installer not found' }
$hash = (Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $($installer.Name)" | Set-Content -LiteralPath release\SHA256SUMS.txt -Encoding ascii
"### Unsigned Windows packaging check" | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
"" | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
"- File: ``$($installer.Name)``" | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
"- SHA-256: ``$hash``" | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
"- Status: built for review only; not uploaded or published" |
Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
+3
View File
@@ -104,6 +104,9 @@ AGENTS.md
# Desktop shell build outputs
desktop/electron/node_modules/
desktop/electron/dist/
desktop/electron/.cache/
desktop/electron/release/
desktop/electron/runtime/
# Alpha Zoo wiki artifacts (generated, not source)
wiki/alpha-library/manifest.json
+14 -10
View File
@@ -1,9 +1,10 @@
# Vibe-Trading Desktop (Unofficial Community Build)
This directory contains a source-only Electron host for the existing
Vibe-Trading FastAPI and React application. It does not contain an installer,
an embedded Python runtime, credential storage, an updater, or changes to the
agent, provider, session, frontend model UX, or messaging-channel code.
This directory contains an Electron host for the existing Vibe-Trading FastAPI
and React application. The Windows packaging layer can assemble an unsigned
NSIS installer with an isolated Python runtime and encrypted credential
storage. It does not contain an updater, optional IM adapters, personal
WeChat pairing, or changes to the agent loop and provider behavior.
## What the shell does
@@ -28,7 +29,8 @@ agent, provider, session, frontend model UX, or messaging-channel code.
See [THREAT_MODEL.md](THREAT_MODEL.md) for the trust boundary and residual
risks. See [REVIEW_NOTES.md](REVIEW_NOTES.md) for the file inventory and
validation ledger.
validation ledger. See [WINDOWS_PACKAGING.md](WINDOWS_PACKAGING.md) for the
packaging-specific boundary and build commands.
## Run from source
@@ -72,15 +74,17 @@ npm start
## Review scope
This first change intentionally excludes:
The desktop lifecycle shell and Windows packaging are kept as separate review
layers. The packaging layer adds Electron Builder, an unsigned NSIS review
artifact, a checksum-pinned CPython 3.12.10 base runtime, and `safeStorage`
credential migration.
It intentionally excludes:
- Electron Builder, NSIS, release workflows, and embedded Python;
- `safeStorage`, credential migration, and settings UI changes;
- update checks or release-feed configuration;
- provider/model discovery and response metadata;
- optional IM adapters and personal WeChat pairing;
- changes outside `desktop/`, except generated-output entries in `.gitignore`
and the Windows desktop job in `.github/workflows/test.yml`.
- broker-specific optional packages.
The desktop shell remains unofficial. No release or distribution ownership is
implied by this source directory.
+104
View File
@@ -0,0 +1,104 @@
# Windows packaging review notes
This layer is stacked on the desktop lifecycle shell and adds only:
- an NSIS x64 installer;
- a checksum-pinned CPython 3.12.10 embedded runtime;
- the existing production frontend and the upstream hash-locked base Python
dependencies;
- the verified native DLL subset required for WeasyPrint PDF output;
- Electron `safeStorage` for LLM, Tushare, and QVeris credentials.
## Deliberate exclusions
- No updater or release feed is included.
- No optional IM/channel extra is installed.
- Personal WeChat/Weixin QR pairing is not included.
- No broker-specific optional dependency is installed.
- The review workflow produces an unsigned artifact only; it does not publish a release.
Users can install an optional adapter later from a source/developer environment.
The desktop runtime does not expose a package installer UI in this change.
## Build
From a complete checkout of the current upstream source:
```powershell
cd frontend
npm ci
npm run build
cd ..\desktop\electron
npm ci
npm run runtime:win -- -Clean
npm run smoke:lifecycle
npm run installer:win
```
The installer and checksum are written under `desktop/electron/release/`.
Python dependencies are installed from
`desktop/electron/requirements-windows-lock.txt` with `--require-hashes`; the
checked-out Vibe-Trading package is then installed with `--no-deps` so
packaging cannot silently resolve versions outside that lock.
The repository-root lock is generated for the upstream Linux environment and
contains platform-specific dependencies such as `uvloop`. It cannot be reused
for the Windows runtime. Regenerate the Windows lock only on Windows with
Python 3.12, from the base requirements (not an optional IM extra):
```powershell
python -m pip install pip-tools
python -m piptools compile --generate-hashes --allow-unsafe --resolver=backtracking --output-file=desktop/electron/requirements-windows-lock.txt agent/requirements.txt
```
## Credential boundary
The renderer can request a write only for an allowlisted credential name.
Encryption and decryption happen in the Electron main process. On Windows,
Electron `safeStorage` uses the operating-system encryption facility for the
current user. Decrypted values are injected only into the child backend
environment and are not returned to the renderer.
On first startup, supported secrets in `~/.vibe-trading/.env` and the QVeris
API key in `~/.vibe-trading/qveris.json` are migrated into encrypted storage.
The plaintext fields are then removed. Backend settings writes in desktop
secure mode explicitly blank known credential fields instead of writing the
injected values back to dotenv.
## Unsigned limitation
Local and pull-request artifacts are unsigned. Windows SmartScreen may warn
when they are launched. Code signing and release ownership remain with the
community publisher unless HKUDS explicitly takes them over later.
## Dependency-audit note
`npm audit --omit=dev` reports zero production dependency vulnerabilities.
Electron Builder is a build-time-only development dependency and its current
transitive tree reports high-severity audit findings in glob/minimatch-related
tooling. Those packages are not copied into the packaged application, but the
review workflow still treats the lockfile as trusted build input. This should
be re-audited whenever Electron Builder publishes a repaired dependency tree.
## Local validation record
Windows host validation on 2026-07-28:
- [x] all 183 installed third-party Python distributions exactly match the
committed Windows lock; the checked-out `vibe-trading-ai` package is the only
additional distribution;
- [x] embedded Python reports version 3.12.10;
- [x] backend, CLI, and WeasyPrint PDF imports succeed;
- [x] DingTalk, Discord, Telegram, Neonize/WeChat, and QR-code modules are
absent;
- [x] authenticated random-port startup and graceful shutdown succeed with no
residual embedded Python process;
- [x] the packaged application loads the frontend and backend from an isolated
empty Windows user profile and closes its owned process tree cleanly;
- [x] the generated installer is reported as `NotSigned` by Windows
Authenticode inspection.
The assembled backend is 801.4 MiB before installer compression. A clean
Windows VM re-run remains a release/review gate; this host record is not a
substitute for that independent environment check.
+3420
View File
File diff suppressed because it is too large. Load diff
+60
View File
@@ -12,12 +12,72 @@
"test:locales": "npm run build && node scripts/test-locales.mjs",
"test:backend-resolution": "npm run build && node scripts/test-backend-resolution.mjs",
"smoke:parent-death": "npm run build && node scripts/smoke-parent-death.mjs",
"prepare:electron": "node scripts/prepare-electron.mjs",
"runtime:win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/build-backend.ps1",
"smoke:lifecycle": "npm run build && node scripts/test-locales.mjs && node scripts/test-backend-resolution.mjs && node scripts/smoke-lifecycle.mjs && node scripts/smoke-parent-death.mjs",
"pack:win": "npm run build && npm run prepare:electron && electron-builder --publish never --win dir",
"installer:win": "npm run build && npm run prepare:electron && electron-builder --publish never --win nsis",
"sbom": "npm sbom --sbom-format cyclonedx"
},
"devDependencies": {
"@types/node": "^24.10.0",
"electron": "43.1.1",
"electron-builder": "26.15.3",
"typescript": "^5.9.3"
},
"build": {
"appId": "io.github.vibetrading.community.desktop",
"productName": "Vibe-Trading Desktop (Unofficial Community Build)",
"compression": "normal",
"artifactName": "Vibe-Trading-Desktop-Unofficial-${version}-${arch}.${ext}",
"asar": true,
"electronLanguages": [
"ar",
"en-GB",
"en-US",
"ja",
"ko",
"zh-CN"
],
"electronDist": ".cache/electron-dist",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "runtime/backend",
"to": "backend"
},
{
"from": "../../LICENSE",
"to": "licenses/Vibe-Trading-LICENSE.txt"
},
{
"from": "../../NOTICE",
"to": "licenses/Vibe-Trading-NOTICE.txt"
}
],
"win": {
"icon": "../../assets/icon.png",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"useZip": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}
File diff suppressed because it is too large. Load diff
+314
View File
@@ -0,0 +1,314 @@
[CmdletBinding()]
param(
[string]$BuildPython = '',
[switch]$Clean
)
$ErrorActionPreference = 'Stop'
$electronRoot = Split-Path -Parent $PSScriptRoot
$repoRoot = (Resolve-Path -LiteralPath (Join-Path $electronRoot '..\..')).Path
$runtimeRoot = Join-Path $electronRoot 'runtime\backend'
$cacheRoot = Join-Path $electronRoot '.cache\python'
# Python 3.12.10 is the final 3.12 release with official Windows binary
# installers. Later 3.12 security releases are source-only.
$pythonVersion = '3.12.10'
$archiveName = "python-$pythonVersion-embed-amd64.zip"
$archivePath = Join-Path $cacheRoot $archiveName
$archiveUrl = "https://www.python.org/ftp/python/$pythonVersion/$archiveName"
$expectedSha256 = '4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3'
$gtkCacheRoot = Join-Path $electronRoot '.cache\gtk'
$gtkInstallerName = 'gtk3-runtime-3.24.31-2022-01-04-ts-win64.exe'
$gtkInstallerPath = Join-Path $gtkCacheRoot $gtkInstallerName
$gtkInstallerUrl = "https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/releases/download/2022-01-04/$gtkInstallerName"
$gtkInstallerSha256 = 'd05e1488ca0e6ffaabb579bbeb82113c099152ca4260ebc63084b0dd174d4558'
$gtkInstallRoot = Join-Path $gtkCacheRoot 'runtime'
if (-not $BuildPython) {
$repoVenvPython = Join-Path $repoRoot '.venv\Scripts\python.exe'
if (Test-Path -LiteralPath $repoVenvPython) {
$BuildPython = $repoVenvPython
}
else {
$BuildPython = (Get-Command python.exe -ErrorAction Stop).Source
}
}
if (-not (Test-Path -LiteralPath $BuildPython)) {
throw "Build Python was not found: $BuildPython"
}
$frontendDist = Join-Path $repoRoot 'frontend\dist'
if (-not (Test-Path -LiteralPath (Join-Path $frontendDist 'index.html'))) {
throw 'frontend/dist is missing. Build the production frontend before assembling the runtime.'
}
$requirementsLock = Join-Path $electronRoot 'requirements-windows-lock.txt'
if (-not (Test-Path -LiteralPath $requirementsLock)) {
throw 'desktop/electron/requirements-windows-lock.txt is missing. Windows packaging requires its platform-specific hash-locked dependencies.'
}
New-Item -ItemType Directory -Path $cacheRoot -Force | Out-Null
if (-not (Test-Path -LiteralPath $archivePath)) {
$curl = Join-Path $env:SystemRoot 'System32\curl.exe'
if (-not (Test-Path -LiteralPath $curl)) {
throw 'Windows curl.exe is unavailable.'
}
& $curl --fail --location --retry 3 --output $archivePath $archiveUrl
if ($LASTEXITCODE -ne 0) {
throw 'Python embeddable package download failed.'
}
}
$actualSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant()
if ($actualSha256 -ne $expectedSha256) {
throw "Python archive checksum mismatch. Expected $expectedSha256, got $actualSha256"
}
if ($Clean -and (Test-Path -LiteralPath $runtimeRoot)) {
$resolvedRuntime = (Resolve-Path -LiteralPath $runtimeRoot).Path
$allowedRoot = (Resolve-Path -LiteralPath $electronRoot).Path + '\runtime\'
if (-not $resolvedRuntime.StartsWith($allowedRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to clean unexpected runtime path: $resolvedRuntime"
}
Remove-Item -LiteralPath $resolvedRuntime -Recurse -Force
}
New-Item -ItemType Directory -Path $runtimeRoot -Force | Out-Null
Expand-Archive -LiteralPath $archivePath -DestinationPath $runtimeRoot -Force
$pthPath = Join-Path $runtimeRoot 'python312._pth'
$pthContent = @(
'python312.zip'
'.'
'Lib\site-packages'
'import site'
) -join [Environment]::NewLine
[IO.File]::WriteAllText(
$pthPath,
$pthContent + [Environment]::NewLine,
[Text.UTF8Encoding]::new($false)
)
$sitePackages = Join-Path $runtimeRoot 'Lib\site-packages'
New-Item -ItemType Directory -Path $sitePackages -Force | Out-Null
# An interrupted setuptools wheel build can leave these ignored directories in
# the source tree. Reusing them causes WinError 183 when the same dist-info
# directory is created again, so remove only these exact, non-reparse paths.
$packagingArtifacts = @(
(Join-Path $repoRoot 'build'),
(Join-Path $repoRoot 'agent\vibe_trading_ai.egg-info')
)
function Remove-PackagingArtifact {
param([Parameter(Mandatory)][string]$ArtifactPath)
if (-not (Test-Path -LiteralPath $ArtifactPath)) {
return
}
$item = Get-Item -LiteralPath $ArtifactPath -Force
if ($item.Attributes.HasFlag([IO.FileAttributes]::ReparsePoint)) {
throw "Refusing to remove packaging artifact through a reparse point: $ArtifactPath"
}
$expectedPath = [IO.Path]::GetFullPath($ArtifactPath)
if (-not $item.FullName.Equals($expectedPath, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove unexpected packaging artifact: $($item.FullName)"
}
Remove-Item -LiteralPath $item.FullName -Recurse -Force
}
foreach ($artifact in $packagingArtifacts) {
Remove-PackagingArtifact -ArtifactPath $artifact
}
# Install the Windows/Python 3.12 dependency lock with hash verification. The
# lock is generated from agent/requirements.txt on Windows and deliberately
# excludes optional channel extras such as [channels], [weixin], [telegram],
# and [discord].
& $BuildPython -m pip install `
--disable-pip-version-check `
--require-hashes `
--target $sitePackages `
--requirement $requirementsLock
if ($LASTEXITCODE -ne 0) {
throw 'Installing hash-locked base dependencies into the embedded runtime failed.'
}
# Install only this checked-out project after its dependency closure has been
# verified above. --no-deps prevents project metadata from re-resolving newer
# packages outside the committed lock.
$installExitCode = 1
try {
& $BuildPython -m pip install `
--disable-pip-version-check `
--no-deps `
--upgrade `
--target $sitePackages `
$repoRoot
$installExitCode = $LASTEXITCODE
}
finally {
foreach ($artifact in $packagingArtifacts) {
Remove-PackagingArtifact -ArtifactPath $artifact
}
}
if ($installExitCode -ne 0) {
throw 'Installing Vibe-Trading into the embedded runtime failed.'
}
# Dependency wheels often include test suites that are never imported at
# runtime. Remove only directories named exactly test/tests.
$sitePackagesResolved = (Resolve-Path -LiteralPath $sitePackages).Path
$testDirectories = @(
Get-ChildItem -LiteralPath $sitePackages -Recurse -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -in @('test', 'tests') } |
Sort-Object { $_.FullName.Length }
)
$pruneRoots = [System.Collections.Generic.List[System.IO.DirectoryInfo]]::new()
foreach ($directory in $testDirectories) {
$coveredByParent = $false
foreach ($parent in $pruneRoots) {
if ($directory.FullName.StartsWith($parent.FullName + '\', [StringComparison]::OrdinalIgnoreCase)) {
$coveredByParent = $true
break
}
}
if (-not $coveredByParent) {
$pruneRoots.Add($directory)
}
}
$reclaimedBytes = 0L
$removedFiles = 0
foreach ($directory in $pruneRoots) {
$resolved = (Resolve-Path -LiteralPath $directory.FullName).Path
if (-not $resolved.StartsWith($sitePackagesResolved + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to prune unexpected test directory: $resolved"
}
$files = @(Get-ChildItem -LiteralPath $resolved -Recurse -File -Force -ErrorAction SilentlyContinue)
$reclaimedBytes += ($files | Measure-Object -Property Length -Sum).Sum
$removedFiles += $files.Count
Remove-Item -LiteralPath $resolved -Recurse -Force
}
Write-Host ("Pruned {0} packaged test files ({1:N1} MB)" -f $removedFiles, ($reclaimedBytes / 1MB))
# WeasyPrint needs Pango/Cairo/GLib on Windows. Copy only the verified
# rendering dependency closure, not the GTK UI or developer payloads.
New-Item -ItemType Directory -Path $gtkCacheRoot -Force | Out-Null
if (-not (Test-Path -LiteralPath $gtkInstallerPath)) {
Invoke-WebRequest -UseBasicParsing -Uri $gtkInstallerUrl -OutFile $gtkInstallerPath
}
$actualGtkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $gtkInstallerPath).Hash.ToLowerInvariant()
if ($actualGtkSha256 -ne $gtkInstallerSha256) {
throw "GTK runtime checksum mismatch. Expected $gtkInstallerSha256, got $actualGtkSha256"
}
$gtkGObject = Join-Path $gtkInstallRoot 'bin\libgobject-2.0-0.dll'
if (-not (Test-Path -LiteralPath $gtkGObject)) {
New-Item -ItemType Directory -Path $gtkInstallRoot -Force | Out-Null
$gtkInstall = Start-Process `
-FilePath $gtkInstallerPath `
-ArgumentList @('/S', "/D=$gtkInstallRoot") `
-Wait `
-PassThru
if ($gtkInstall.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $gtkGObject)) {
throw "GTK runtime extraction failed (exit code $($gtkInstall.ExitCode))."
}
}
$runtimeGtk = Join-Path $runtimeRoot 'gtk'
New-Item -ItemType Directory -Path (Join-Path $runtimeGtk 'bin') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $runtimeGtk 'etc') -Force | Out-Null
$gtkRuntimeDlls = @(
'libbrotlicommon.dll', 'libbrotlidec.dll', 'libbz2-1.dll', 'libdatrie-1.dll',
'libexpat-1.dll', 'libffi-7.dll', 'libfontconfig-1.dll', 'libfreetype-6.dll',
'libfribidi-0.dll', 'libgcc_s_seh-1.dll', 'libgio-2.0-0.dll', 'libglib-2.0-0.dll',
'libgmodule-2.0-0.dll', 'libgobject-2.0-0.dll', 'libgraphite2.dll',
'libharfbuzz-0.dll', 'libiconv-2.dll', 'libintl-8.dll', 'libpango-1.0-0.dll',
'libpangoft2-1.0-0.dll', 'libpcre-1.dll', 'libpng16-16.dll', 'libstdc++-6.dll',
'libthai-0.dll', 'libwinpthread-1.dll', 'zlib1.dll'
)
foreach ($gtkDll in $gtkRuntimeDlls) {
Copy-Item `
-LiteralPath (Join-Path $gtkInstallRoot "bin\$gtkDll") `
-Destination (Join-Path $runtimeGtk 'bin') `
-Force
}
Copy-Item `
-LiteralPath (Join-Path $gtkInstallRoot 'etc\fonts') `
-Destination (Join-Path $runtimeGtk 'etc') `
-Recurse `
-Force
$runtimeFrontend = Join-Path $runtimeRoot 'Lib\frontend\dist'
New-Item -ItemType Directory -Path (Split-Path -Parent $runtimeFrontend) -Force | Out-Null
Copy-Item `
-LiteralPath $frontendDist `
-Destination (Split-Path -Parent $runtimeFrontend) `
-Recurse `
-Force
Copy-Item `
-LiteralPath (Join-Path $repoRoot 'LICENSE') `
-Destination (Join-Path $runtimeRoot 'Vibe-Trading-LICENSE.txt') `
-Force
Copy-Item `
-LiteralPath (Join-Path $repoRoot 'NOTICE') `
-Destination (Join-Path $runtimeRoot 'Vibe-Trading-NOTICE.txt') `
-Force
$runtimePython = Join-Path $runtimeRoot 'python.exe'
& {
$gtkBin = Join-Path $runtimeRoot 'gtk\bin'
$previousPath = $env:PATH
$previousDllDirectories = $env:WEASYPRINT_DLL_DIRECTORIES
try {
$env:PATH = $gtkBin + [IO.Path]::PathSeparator + $env:PATH
$env:WEASYPRINT_DLL_DIRECTORIES = $gtkBin
$smokeScript = @'
import importlib.util
import api_server
import cli
from weasyprint import HTML
assert len(HTML(string="<p>PDF smoke</p>").write_pdf()) > 1000
for optional_module in (
"dingtalk_stream",
"discord",
"telegram",
"neonize",
"qrcode",
):
assert importlib.util.find_spec(optional_module) is None, optional_module
print("embedded backend, PDF, and minimal-adapter checks OK")
'@
$smokeScript | & $runtimePython -
}
finally {
$env:PATH = $previousPath
$env:WEASYPRINT_DLL_DIRECTORIES = $previousDllDirectories
}
}
if ($LASTEXITCODE -ne 0) {
throw 'Embedded backend import smoke test failed.'
}
$inventoryScript = @'
import importlib.metadata
import json
packages = sorted(
(
{"name": dist.metadata["Name"], "version": dist.version}
for dist in importlib.metadata.distributions()
if dist.metadata["Name"]
),
key=lambda item: item["name"].lower(),
)
print(json.dumps({"format": "python-importlib-metadata", "packages": packages}, indent=2))
'@
$inventoryJson = $inventoryScript | & $runtimePython -
$inventoryJson |
Set-Content -LiteralPath (Join-Path $runtimeRoot 'python-dependency-inventory.json') -Encoding utf8
$size = (
Get-ChildItem -LiteralPath $runtimeRoot -Recurse -File |
Measure-Object -Property Length -Sum
).Sum
Write-Host ("Backend runtime ready: {0} ({1:N1} MB)" -f $runtimeRoot, ($size / 1MB))
@@ -0,0 +1,74 @@
import { createHash } from "node:crypto";
import { copyFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { downloadArtifact } from "@electron/get";
const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const electronPackage = JSON.parse(
await readFile(path.join(electronRoot, "node_modules", "electron", "package.json"), "utf8"),
);
const checksums = JSON.parse(
await readFile(path.join(electronRoot, "node_modules", "electron", "checksums.json"), "utf8"),
);
const version = electronPackage.version;
const archiveName = `electron-v${version}-win32-x64.zip`;
const expected = checksums[archiveName];
if (!expected) throw new Error(`Official checksum is missing for ${archiveName}`);
const targetDirectory = path.join(electronRoot, ".cache", "electron-dist");
const target = path.join(targetDirectory, archiveName);
await mkdir(targetDirectory, { recursive: true });
if (await sha256(target) !== expected) {
let lastError;
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
let lastReportedPercent = -10;
const downloaded = await downloadArtifact({
version,
artifactName: "electron",
platform: "win32",
arch: "x64",
downloadOptions: {
signal: AbortSignal.timeout(180_000),
getProgressCallback: async ({ percent }) => {
const currentPercent = Math.min(100, Math.floor(percent * 10) * 10);
if (currentPercent >= lastReportedPercent + 10) {
lastReportedPercent = currentPercent;
console.log(`Electron download attempt ${attempt}/4: ${currentPercent}%`);
}
},
},
});
await copyFile(downloaded, target);
lastError = undefined;
break;
} catch (error) {
lastError = error;
if (attempt < 4) {
const delayMs = attempt * 2_000;
console.warn(
`Electron download attempt ${attempt}/4 failed; retrying in ${delayMs / 1_000}s`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
if (lastError) throw lastError;
}
const actual = await sha256(target);
if (actual !== expected) {
throw new Error(`Electron checksum mismatch: expected ${expected}, got ${actual}`);
}
console.log(`Electron archive ready and verified: ${target}`);
async function sha256(file) {
try {
return createHash("sha256").update(await readFile(file)).digest("hex");
} catch (error) {
if (error && error.code === "ENOENT") return undefined;
throw error;
}
}