feat(desktop): add Windows runtime and NSIS packaging
Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
1 parent
2b1b499b42
commit
f5cb88cf49
9 files changed
+8536
-10
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
Generated
+3420
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user