Merge pull request #1015 from QCYTSN/agent/windows-packaging

feat(desktop): add Windows packaging and secure credential storage
This commit is contained in:
Haozhe Wu authored and GitHub committed 2026-08-09 18:19:22 +08:00
commit 42e24ab634
31 files changed
+9592 -34

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
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:
# The checksum-pinned legacy GTK runtime extractor crashes on the Server
# 2025 image even though the resulting runtime works on current Windows.
# Keep packaging reproducible on the clean Server 2022 image until the
# native PDF dependency is replaced with a newer archive format.
runs-on: windows-2022
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: "22"
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: Verify encrypted credential storage
working-directory: desktop/electron
run: npm run smoke:credentials
- 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:review
- name: Verify packaged startup and parent-death cleanup
working-directory: desktop/electron
shell: powershell
run: |
$env:VIBE_TRADING_DESKTOP_ELECTRON_EXECUTABLE = (
Resolve-Path 'release\win-unpacked\Vibe-Trading Desktop (Unofficial Community Build).exe'
).Path
node scripts/smoke-parent-death.mjs
- 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
+26 -2
View File
@@ -17,7 +17,7 @@ import httpx
from fastapi import Depends, FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from src.config.accessor import reset_env_config
from src.config.accessor import get_env_value, reset_env_config
# Agent root (agent/) — resolved from this file's location (agent/src/api/).
_AGENT_DIR = Path(__file__).resolve().parent.parent.parent
@@ -152,6 +152,19 @@ LLM_API_KEY_PLACEHOLDERS = {"", "sk-or-v1-your-key-here", "sk-xxx", "xxx", "gsk_
TUSHARE_TOKEN_PLACEHOLDERS = {"", "your-tushare-token"}
def _desktop_secure_credential_names() -> set[str]:
"""Return secrets owned by the desktop host when secure storage is active."""
names = {"TUSHARE_TOKEN", "QVERIS_API_KEY"}
names.update(
provider.api_key_env for provider in LLM_PROVIDERS if provider.api_key_env
)
return names
def _desktop_secure_credentials_enabled() -> bool:
return get_env_value("VIBE_TRADING_DESKTOP_SECURE_CREDENTIALS") == "1"
# ---------------------------------------------------------------------------
# Host access helpers (late-binding for test monkeypatch compat)
# ---------------------------------------------------------------------------
@@ -206,6 +219,13 @@ def _read_settings_env_values() -> Dict[str, str]:
else:
values = {}
if _desktop_secure_credentials_enabled():
for name in _desktop_secure_credential_names():
runtime_value = get_env_value(name)
if runtime_value:
values[name] = runtime_value
else:
values.pop(name, None)
return values
@@ -429,6 +449,10 @@ def _persist_settings_updates(updates: Dict[str, str]) -> Dict[str, str]:
merged = dict(updates)
if not target.exists() and legacy != target and legacy.exists():
merged = {**host._read_env_values(legacy), **updates}
if _desktop_secure_credentials_enabled():
# Empty known secret keys in dotenv while preserving the decrypted
# environment values injected by Electron for this process.
merged.update({name: "" for name in _desktop_secure_credential_names()})
try:
host._write_env_values(target, merged)
except OSError as exc:
@@ -439,7 +463,7 @@ def _persist_settings_updates(updates: Dict[str, str]) -> Dict[str, str]:
"~/.vibe-trading/.env"
),
) from exc
return host._read_env_values(target)
return _read_settings_env_values()
# ---------------------------------------------------------------------------
+17 -1
View File
@@ -22,7 +22,13 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.config.env_schema import EnvConfig
__all__ = ["get_env_config", "reset_env_config", "_parse_bool", "get_env_or"]
__all__ = [
"get_env_config",
"reset_env_config",
"_parse_bool",
"get_env_or",
"get_env_value",
]
# ---------------------------------------------------------------------------
# Module-level singleton state
@@ -130,3 +136,13 @@ def get_env_or(primary: str, fallback: str, default: str = "") -> str:
if value:
return value
return default
def get_env_value(name: str, default: str = "") -> str:
"""Read a dynamically named environment value through the config layer.
Most application settings should use typed :class:`EnvConfig` fields.
This narrow accessor exists for data-driven registries whose environment
aliases are loaded at runtime, such as provider credential metadata.
"""
return os.getenv(name, default)
+16
View File
@@ -10,6 +10,7 @@ Covers:
- Thread safety
- _parse_bool utility
- get_env_or utility
- get_env_value utility
- _parse_env_bool (EnvBool BeforeValidator)
"""
@@ -23,6 +24,7 @@ from src.config.accessor import (
_parse_bool,
get_env_config,
get_env_or,
get_env_value,
reset_env_config,
)
from src.config.env_schema import (
@@ -474,6 +476,20 @@ class TestGetEnvOr:
assert get_env_or("PRIMARY", "FALLBACK", "default") == "default"
class TestGetEnvValue:
"""Verify dynamic registry keys still go through the config layer."""
def test_reads_configured_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DYNAMIC_PROVIDER_API_KEY", "secret")
assert get_env_value("DYNAMIC_PROVIDER_API_KEY") == "secret"
def test_returns_default_for_missing_value(
self, monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("DYNAMIC_PROVIDER_API_KEY", raising=False)
assert get_env_value("DYNAMIC_PROVIDER_API_KEY", "fallback") == "fallback"
# ===================================================================
# TestSubModelDirectConstruction
# ===================================================================
+42
View File
@@ -256,6 +256,31 @@ def test_update_deepseek_settings_uses_exact_reported_payload(
assert "DEEPSEEK_API_KEY=sk-deepseek-test" in env_text
assert "DEEPSEEK_BASE_URL=https://api.deepseek.com/v1" in env_text
def test_desktop_secure_mode_never_persists_injected_llm_key(
client: TestClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("VIBE_TRADING_DESKTOP_SECURE_CREDENTIALS", "1")
monkeypatch.setenv("DEEPSEEK_API_KEY", "dpapi-decrypted-key")
response = client.put(
"/settings/llm",
json={
"provider": "deepseek",
"model_name": "deepseek-chat",
"base_url": "https://api.deepseek.com/v1",
},
)
assert response.status_code == 200
assert response.json()["api_key_configured"] is True
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
assert "dpapi-decrypted-key" not in env_text
assert "DEEPSEEK_API_KEY=" in env_text
@pytest.mark.parametrize(
("provider", "api_key_env", "base_url_env", "base_url"),
[
@@ -506,6 +531,23 @@ def test_update_data_source_settings_persists_tushare_token(
assert "TUSHARE_TOKEN=ts-secret-token" in env_text
def test_desktop_secure_mode_never_persists_injected_tushare_token(
client: TestClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("VIBE_TRADING_DESKTOP_SECURE_CREDENTIALS", "1")
monkeypatch.setenv("TUSHARE_TOKEN", "dpapi-tushare-token")
response = client.put("/settings/data-sources", json={})
assert response.status_code == 200
assert response.json()["tushare_token_configured"] is True
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
assert "dpapi-tushare-token" not in env_text
assert "TUSHARE_TOKEN=" in env_text
def test_settings_writes_reject_remote_dev_mode_clients(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
+16 -10
View File
@@ -1,9 +1,12 @@
# 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 review artifact or require and verify Authenticode signing for a
publishable installer. Both use an isolated Python runtime and encrypted
credential storage. This layer 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 +31,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 +76,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.
+47 -12
View File
@@ -3,9 +3,11 @@
## Scope
This document covers the Electron main process, its sandboxed renderer, and the
single Vibe-Trading Python process started by the shell. Packaging, credential
storage, auto-update, optional messaging adapters, broker configuration, and
code signing are outside this change.
single Vibe-Trading Python process started by the shell. The Windows packaging
layer additionally covers local credential encryption, migration, and
injection into that owned Python process. Auto-update, optional messaging
adapters, personal WeChat pairing, broker configuration, and release ownership
remain outside this change.
## Assets
@@ -13,6 +15,7 @@ code signing are outside this change.
- Local Vibe-Trading sessions, reports, configuration, and research data.
- Any credentials already present in the environment inherited by the Python
process.
- LLM, Tushare, and QVeris credentials managed by the desktop host.
- The integrity of the executable selected as the backend.
- The ability to invoke authenticated local API routes.
@@ -21,20 +24,23 @@ code signing are outside this change.
```text
Electron main process
|-- owns random API secret
|-- owns safeStorage encryption/decryption
|-- selects backend executable and starts watchdog
|-- injects Authorization on one loopback origin
|
+--> sandboxed renderer
| no Node.js, isolated context, deny-by-default permissions
| can use the authenticated local API through normal page requests
| can set or clear allowlisted credentials, but cannot read them
|
+--> backend parent-death watchdog
|-- inherits the launch secret only to pass it to Python
|-- inherits the launch secret and decrypted credentials only to pass them to Python
|-- exits without spawning Python if the parent is already gone
|-- monitors Electron IPC disconnect and parent PID liveness
+--> Python backend
binds 127.0.0.1:<random-port>
receives API_AUTH_KEY through its child environment
receives decrypted credentials through its child environment
```
The renderer is trusted to perform the same application actions as the web UI,
@@ -62,8 +68,9 @@ therefore security-significant.
- `nodeIntegration` is disabled.
- `contextIsolation` and Chromium sandboxing are enabled.
- The preload exposes only status, error, retry, log-folder, and backend-restart
operations.
- The preload exposes status, error, retry, log-folder, backend-restart, and
allowlisted credential-write operations. Credential values are never
returned to the renderer.
- Browser permission checks and requests are denied by default.
- New windows are denied; safe HTTP(S) links are opened in the system browser.
- In-window navigation is restricted to the active local backend origin.
@@ -105,6 +112,24 @@ therefore security-significant.
then independently verifies that both the Python PID and loopback listener
are gone.
### Credential storage
- Credential names are checked against a fixed allowlist in the Electron main
process.
- Values are encrypted and decrypted only through Electron `safeStorage`.
- The on-disk JSON file contains only Base64-encoded ciphertext and the list of
configured names.
- Writes use a same-directory temporary file followed by rename.
- Existing supported values in `~/.vibe-trading/.env` and QVeris configuration
are migrated once; the corresponding plaintext field is removed after the
encrypted store has been persisted.
- Decrypted values are injected only into the owned backend child environment.
- Desktop secure mode prevents settings API writes from copying injected
secrets back into dotenv.
- The credential smoke test uses an isolated temporary profile and verifies
migration, persistence, plaintext removal, allowlisting, replacement, and
clearing.
## Residual risks
- Renderer script injection can exercise the authenticated API even though it
@@ -119,16 +144,26 @@ therefore security-significant.
- The `PATH` fallback trusts the desktop process environment and normal Windows
executable-path integrity. A process able to alter that environment can
select the backend that receives the launch secret.
- The child inherits the desktop process environment. Secure credential
isolation is deferred to the packaging/credential-storage review.
- A compromised renderer can overwrite or clear an allowlisted credential even
though it cannot retrieve the current value.
- `safeStorage` protects data at rest for the current Windows user; it does not
protect against malware, a debugger, or another process already executing
with equivalent user privileges.
- Migration cannot erase plaintext that may remain in backups, filesystem
history, crash dumps, or external logs.
- The child inherits both the desktop process environment and decrypted
credentials required by the backend.
- Forceful tree termination can interrupt in-progress local work after the
graceful shutdown timeout.
- This source-only change does not provide code signing, installer reputation,
update authenticity, or an official HKUDS release channel.
- The review installer is deliberately unsigned and unpublished. This change
does not provide a signing identity, installer reputation, update
authenticity, or an official HKUDS release channel.
## Non-goals
- Protecting against a fully compromised Windows account or administrator.
- Enabling remote API access.
- Managing broker, exchange, IM, or model-provider credentials.
- Installing or updating the application.
- Managing broker, exchange, or IM credentials.
- Exposing credential values back to the renderer.
- Publishing releases, choosing a signing identity, or updating the
application.
+152
View File
@@ -0,0 +1,152 @@
# 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 smoke:credentials
npm run runtime:win -- -Clean
npm run smoke:lifecycle
npm run installer:win:review
```
The installer and checksum are written under `desktop/electron/release/`.
The `installer:win:review` artifact is deliberately unsigned and must never be
published to users. The command strips all supported signing credentials,
disables certificate auto-discovery, and fails unless both the application
executable and installer report `NotSigned`. Both review and signed builds use
Electron Builder's supported compression-level override at level 7 to bound
build time without changing installed build-tool code.
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.
There is intentionally no generic `installer:win` command. A publishable build
must use:
```powershell
$env:WIN_CSC_LINK = 'C:\secure-location\publisher-certificate.pfx'
$env:WIN_CSC_KEY_PASSWORD = '<provided outside source control>'
npm run installer:win:signed
```
The signed command fails closed when either signing input is missing and then
enables Electron Builder's `forceCodeSigning` gate. It then verifies that
Windows reports both the packaged application executable and the resulting
installer's Authenticode status as `Valid`. The certificate and password must
come from CI secrets or an equivalent external secret store; they must never
be committed. The generic `CSC_LINK` and `CSC_KEY_PASSWORD` names remain
supported as fallbacks. This PR does not add a release workflow or upload an
artifact.
## 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.
## CI build-host note
The packaging job is pinned to GitHub's clean `windows-2022` image. The
checksum-pinned GTK runtime installer used solely to extract WeasyPrint's
native DLL subset exits with an access violation on the Server 2025 runner,
although the extracted runtime and packaged application work on current
Windows 10/11 hosts. Moving that native dependency to a newer, directly
extractable upstream archive is a follow-up; silently dropping PDF support or
accepting an unverified binary is not.
## Local validation record
Windows host validation on 2026-08-08 against the current upstream
`vibe-trading-ai` 0.1.13 source:
- [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] `pip check` confirms that the locked runtime satisfies the checked-out
project dependency metadata;
- [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 exact packaged application loads the current production frontend and
embedded backend, then removes the Python child and loopback listener when
only the Electron main PID is force-terminated;
- [x] safeStorage migration, plaintext removal, allowlisting, replacement, and
clearing succeed against an isolated temporary Windows profile;
- [ ] the exact current branch's packaged application loads the frontend and
backend on a clean Windows VM and closes its owned process tree cleanly;
- [x] the generated installer is reported as `NotSigned` by Windows
Authenticode inspection;
- [x] the unsigned review installer is 324.3 MiB and its generated
`SHA256SUMS.txt` matches an independent SHA-256 calculation
(`173f353fcb8517e9caaf3c715222d619c010bc615823df5e2ee414ace297783b`).
The assembled backend is 804.9 MiB before installer compression. An earlier
prototype passed the isolated-profile startup/shutdown check, but that result
predates the current-main rebuild and is deliberately not credited above. 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
+62
View File
@@ -12,12 +12,74 @@
"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:credentials": "npm run build && electron scripts/smoke-credentials.cjs",
"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:review": "npm run build && npm run prepare:electron && node scripts/build-review-installer.mjs",
"installer:win:signed": "node scripts/build-signed-installer.mjs",
"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
+319
View File
@@ -0,0 +1,319 @@
[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'
& $BuildPython -m pip --python $runtimePython check
if ($LASTEXITCODE -ne 0) {
throw 'The embedded runtime does not satisfy the checked-out project dependency metadata.'
}
& {
$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,142 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const signaturePowerShell = resolveSignaturePowerShell();
const unsignedEnvironment = { ...process.env };
for (const name of [
"CSC_LINK",
"CSC_KEY_PASSWORD",
"WIN_CSC_LINK",
"WIN_CSC_KEY_PASSWORD",
"AZURE_TENANT_ID",
"AZURE_CLIENT_ID",
"AZURE_CLIENT_SECRET",
]) {
delete unsignedEnvironment[name];
}
unsignedEnvironment.CSC_IDENTITY_AUTO_DISCOVERY = "false";
unsignedEnvironment.ELECTRON_BUILDER_COMPRESSION_LEVEL = "7";
run(
process.execPath,
[
path.join(electronRoot, "node_modules", "electron-builder", "cli.js"),
"--publish",
"never",
"--win",
"nsis",
],
{ env: unsignedEnvironment },
);
const releaseDirectory = path.join(electronRoot, "release");
const installers = (await readdir(releaseDirectory, { withFileTypes: true }))
.filter(
(entry) =>
entry.isFile() &&
/^Vibe-Trading-Desktop-Unofficial-.*-x64\.exe$/u.test(entry.name),
)
.map((entry) => path.join(releaseDirectory, entry.name));
const unpackedDirectory = path.join(releaseDirectory, "win-unpacked");
const applicationExecutables = (await readdir(unpackedDirectory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".exe"))
.map((entry) => path.join(unpackedDirectory, entry.name));
if (installers.length !== 1 || applicationExecutables.length !== 1) {
throw new Error(
`Expected one installer and one top-level application executable; found ` +
`${installers.length} and ${applicationExecutables.length}.`,
);
}
for (const artifact of [...applicationExecutables, ...installers]) {
const status = signatureStatus(artifact);
if (status !== "NotSigned") {
throw new Error(
`Review artifact unexpectedly has Authenticode status '${status}': ${artifact}`,
);
}
console.log(`Unsigned review boundary verified: ${path.basename(artifact)}`);
}
const installerHash = await sha256(installers[0]);
await writeFile(
path.join(releaseDirectory, "SHA256SUMS.txt"),
`${installerHash} ${path.basename(installers[0])}\n`,
"ascii",
);
console.log(`Unsigned review installer ready; do not publish: ${installers[0]}`);
console.log(`SHA-256: ${installerHash}`);
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: electronRoot,
stdio: "inherit",
...options,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${path.basename(command)} exited with code ${result.status}`);
}
}
function signatureStatus(artifact) {
const result = spawnSync(
signaturePowerShell,
[
"-NoProfile",
"-NonInteractive",
"-Command",
"(Get-AuthenticodeSignature -LiteralPath $env:VIBE_REVIEW_ARTIFACT_PATH).Status",
],
{
cwd: electronRoot,
env: {
...process.env,
VIBE_REVIEW_ARTIFACT_PATH: artifact,
},
encoding: "utf8",
},
);
if (result.status !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim());
}
return result.stdout.trim();
}
function resolveSignaturePowerShell() {
const candidates = [
process.env.ProgramFiles
? path.join(process.env.ProgramFiles, "PowerShell", "7", "pwsh.exe")
: undefined,
"pwsh.exe",
"powershell.exe",
].filter(Boolean);
const failures = [];
for (const candidate of new Set(candidates)) {
const probe = spawnSync(
candidate,
[
"-NoProfile",
"-NonInteractive",
"-Command",
"Get-Command Get-AuthenticodeSignature -ErrorAction Stop | Out-Null",
],
{ encoding: "utf8", windowsHide: true },
);
if (!probe.error && probe.status === 0) return candidate;
failures.push(`${candidate}: ${probe.error?.message ?? probe.stderr.trim()}`);
}
throw new Error(`No PowerShell host can load Get-AuthenticodeSignature.\n${failures.join("\n")}`);
}
async function sha256(file) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest("hex");
}
@@ -0,0 +1,153 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const signaturePowerShell = resolveSignaturePowerShell();
const certificate = (process.env.WIN_CSC_LINK ?? process.env.CSC_LINK)?.trim();
const password = process.env.WIN_CSC_KEY_PASSWORD ?? process.env.CSC_KEY_PASSWORD;
if (!certificate || password === undefined) {
throw new Error(
"Signed packaging requires WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD " +
"(or their CSC_LINK/CSC_KEY_PASSWORD fallbacks). " +
"Use installer:win:review for a local, non-publishable unsigned artifact.",
);
}
const npmCli = process.env.npm_execpath;
if (!npmCli) {
throw new Error("Signed packaging must be launched through npm.");
}
run(process.execPath, [npmCli, "run", "build"]);
run(process.execPath, [npmCli, "run", "prepare:electron"]);
run(
process.execPath,
[
path.join(electronRoot, "node_modules", "electron-builder", "cli.js"),
"--publish",
"never",
"--win",
"nsis",
"--config.win.forceCodeSigning=true",
],
{
env: {
...process.env,
CSC_IDENTITY_AUTO_DISCOVERY: "true",
ELECTRON_BUILDER_COMPRESSION_LEVEL: "7",
},
},
);
const releaseDirectory = path.join(electronRoot, "release");
const installers = (await readdir(releaseDirectory, { withFileTypes: true }))
.filter(
(entry) =>
entry.isFile() &&
/^Vibe-Trading-Desktop-Unofficial-.*-x64\.exe$/u.test(entry.name),
)
.map((entry) => path.join(releaseDirectory, entry.name));
if (installers.length !== 1) {
throw new Error(`Expected one signed installer, found ${installers.length}.`);
}
const unpackedDirectory = path.join(releaseDirectory, "win-unpacked");
const applicationExecutables = (await readdir(unpackedDirectory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".exe"))
.map((entry) => path.join(unpackedDirectory, entry.name));
if (applicationExecutables.length !== 1) {
throw new Error(
`Expected one top-level packaged application executable, found ${applicationExecutables.length}.`,
);
}
for (const artifact of [...applicationExecutables, ...installers]) {
console.log(`Authenticode signature verified: ${verifySignature(artifact)}`);
}
const installerHash = await sha256(installers[0]);
await writeFile(
path.join(releaseDirectory, "SHA256SUMS.txt"),
`${installerHash} ${path.basename(installers[0])}\n`,
"ascii",
);
console.log(`Signed installer ready: ${installers[0]}`);
console.log(`SHA-256: ${installerHash}`);
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: electronRoot,
stdio: "inherit",
...options,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`${path.basename(command)} exited with code ${result.status}`);
}
}
function verifySignature(artifact) {
const signatureCheck = spawnSync(
signaturePowerShell,
[
"-NoProfile",
"-NonInteractive",
"-Command",
[
"$signature = Get-AuthenticodeSignature -LiteralPath $env:VIBE_SIGNED_ARTIFACT_PATH",
"if ($signature.Status -ne 'Valid') {",
" throw \"Artifact signature is $($signature.Status): $($signature.StatusMessage)\"",
"}",
"$signature.SignerCertificate.Subject",
].join("; "),
],
{
cwd: electronRoot,
env: {
...process.env,
VIBE_SIGNED_ARTIFACT_PATH: artifact,
},
encoding: "utf8",
},
);
if (signatureCheck.status !== 0) {
throw new Error(signatureCheck.stderr.trim() || signatureCheck.stdout.trim());
}
return `${path.basename(artifact)} — ${signatureCheck.stdout.trim()}`;
}
function resolveSignaturePowerShell() {
const candidates = [
process.env.ProgramFiles
? path.join(process.env.ProgramFiles, "PowerShell", "7", "pwsh.exe")
: undefined,
"pwsh.exe",
"powershell.exe",
].filter(Boolean);
const failures = [];
for (const candidate of new Set(candidates)) {
const probe = spawnSync(
candidate,
[
"-NoProfile",
"-NonInteractive",
"-Command",
"Get-Command Get-AuthenticodeSignature -ErrorAction Stop | Out-Null",
],
{ encoding: "utf8", windowsHide: true },
);
if (!probe.error && probe.status === 0) return candidate;
failures.push(`${candidate}: ${probe.error?.message ?? probe.stderr.trim()}`);
}
throw new Error(`No PowerShell host can load Get-AuthenticodeSignature.\n${failures.join("\n")}`);
}
async function sha256(file) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(file)) hash.update(chunk);
return hash.digest("hex");
}
@@ -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;
}
}
@@ -0,0 +1,132 @@
const assert = require("node:assert/strict");
const { promises: fs } = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app } = require("electron");
const { SecureCredentialStore } = require("../dist/secure-credentials.js");
void app.whenReady().then(run).catch(async (error) => {
console.error(error);
app.exit(1);
});
async function run() {
const temporaryRoot = await fs.mkdtemp(
path.join(os.tmpdir(), "vibe-trading-credentials-"),
);
const homeDirectory = path.join(temporaryRoot, "home");
const userDataDirectory = path.join(temporaryRoot, "user-data");
const configDirectory = path.join(homeDirectory, ".vibe-trading");
let exitCode = 0;
try {
await fs.mkdir(configDirectory, { recursive: true });
await fs.writeFile(
path.join(configDirectory, ".env"),
[
"OPENAI_API_KEY=desktop-smoke-openai-secret",
"MODELSCOPE_API_KEY=desktop-smoke-modelscope-secret",
"TUSHARE_TOKEN='desktop-smoke-tushare-secret'",
"ANTHROPIC_API_KEY=sk-xxx",
"NON_SECRET_SETTING=preserved",
"",
].join("\n"),
"utf8",
);
await fs.writeFile(
path.join(configDirectory, "qveris.json"),
`${JSON.stringify({
api_key: "desktop-smoke-qveris-secret",
enabled: true,
}, null, 2)}\n`,
"utf8",
);
const store = new SecureCredentialStore({ homeDirectory, userDataDirectory });
await store.initialize();
const initialEnvironment = store.environment();
assert.equal(initialEnvironment.OPENAI_API_KEY, "desktop-smoke-openai-secret");
assert.equal(
initialEnvironment.MODELSCOPE_API_KEY,
"desktop-smoke-modelscope-secret",
);
assert.equal(initialEnvironment.TUSHARE_TOKEN, "desktop-smoke-tushare-secret");
assert.equal(initialEnvironment.QVERIS_API_KEY, "desktop-smoke-qveris-secret");
assert.equal(initialEnvironment.ANTHROPIC_API_KEY, undefined);
const status = store.status();
assert.deepEqual(status.configured, [
"MODELSCOPE_API_KEY",
"OPENAI_API_KEY",
"QVERIS_API_KEY",
"TUSHARE_TOKEN",
]);
assert.deepEqual(status.migrated, [
"OPENAI_API_KEY",
"MODELSCOPE_API_KEY",
"TUSHARE_TOKEN",
"QVERIS_API_KEY",
]);
const encryptedFile = await fs.readFile(
path.join(userDataDirectory, "credentials.v1.json"),
"utf8",
);
for (const secret of Object.values(initialEnvironment)) {
assert.ok(!encryptedFile.includes(secret), "encrypted file contains a plaintext secret");
}
const migratedDotenv = await fs.readFile(path.join(configDirectory, ".env"), "utf8");
assert.match(migratedDotenv, /OPENAI_API_KEY is stored by Vibe-Trading Desktop/u);
assert.match(migratedDotenv, /MODELSCOPE_API_KEY is stored by Vibe-Trading Desktop/u);
assert.match(migratedDotenv, /TUSHARE_TOKEN is stored by Vibe-Trading Desktop/u);
assert.match(migratedDotenv, /ANTHROPIC_API_KEY=sk-xxx/u);
assert.match(migratedDotenv, /NON_SECRET_SETTING=preserved/u);
assert.ok(!migratedDotenv.includes("desktop-smoke-openai-secret"));
assert.ok(!migratedDotenv.includes("desktop-smoke-modelscope-secret"));
assert.ok(!migratedDotenv.includes("desktop-smoke-tushare-secret"));
const migratedQveris = JSON.parse(
await fs.readFile(path.join(configDirectory, "qveris.json"), "utf8"),
);
assert.deepEqual(migratedQveris, { enabled: true });
const reloadedStore = new SecureCredentialStore({ homeDirectory, userDataDirectory });
await reloadedStore.initialize();
assert.equal(
reloadedStore.environment().OPENAI_API_KEY,
"desktop-smoke-openai-secret",
);
assert.equal(
reloadedStore.environment().MODELSCOPE_API_KEY,
"desktop-smoke-modelscope-secret",
);
await store.set("OPENAI_API_KEY", "desktop-smoke-replacement");
assert.equal(store.environment().OPENAI_API_KEY, "desktop-smoke-replacement");
await store.set("OPENAI_API_KEY", null);
assert.equal(store.environment().OPENAI_API_KEY, undefined);
await assert.rejects(
store.set("UNSUPPORTED_SECRET", "not-allowed"),
/This credential key is not supported\./u,
);
console.log(
JSON.stringify({
encryptionAvailable: status.available,
migrationVerified: true,
persistenceVerified: true,
plaintextRemovalVerified: true,
allowlistVerified: true,
clearVerified: true,
}, null, 2),
);
} catch (error) {
console.error(error);
exitCode = 1;
} finally {
await fs.rm(temporaryRoot, { recursive: true, force: true });
}
app.exit(exitCode);
}
+6 -1
View File
@@ -20,6 +20,7 @@ type BackendManagerOptions = {
logDirectory: string;
apiAuthKey: string;
messages: DesktopMessages;
credentialEnvironment?: NodeJS.ProcessEnv;
onStatus: (message: string) => void;
onUnexpectedExit: (message: string) => void;
};
@@ -91,7 +92,9 @@ export class BackendManager {
const gtkBin = path.join(path.dirname(executable), "gtk", "bin");
const childEnvironment: NodeJS.ProcessEnv = {
...process.env,
...this.options.credentialEnvironment,
API_AUTH_KEY: this.options.apiAuthKey,
VIBE_TRADING_DESKTOP_SECURE_CREDENTIALS: "1",
PYTHONUTF8: "1",
PYTHONUNBUFFERED: "1",
ELECTRON_RUN_AS_NODE: "1",
@@ -110,7 +113,9 @@ export class BackendManager {
const watchdogPath = path.join(__dirname, "backend-watchdog.js");
const watchdog = spawn(process.execPath, [watchdogPath], {
cwd: __dirname,
// In a packaged app __dirname is inside app.asar. Electron's fs layer can
// read that path, but Windows cannot use an ASAR path as a process cwd.
cwd: this.options.resourcesPath,
windowsHide: true,
env: childEnvironment,
stdio: ["ignore", "pipe", "pipe", "ipc"],
+30
View File
@@ -11,6 +11,11 @@ export type DesktopMessages = {
backendHealthTimeout: string;
backendNotFound: string;
portUnavailable: string;
credentialEncryptionUnavailable: string;
credentialFileUnsupported: string;
credentialKeyUnsupported: string;
credentialRequestInvalid: string;
credentialStoreUnavailable: string;
closingService: string;
menuApplication: string;
menuRestartService: string;
@@ -65,6 +70,11 @@ const messages: Record<DesktopLocale, DesktopMessages> = {
backendHealthTimeout: "Timed out waiting for the Vibe-Trading backend.\n{details}",
backendNotFound: "Could not find vibe-trading.exe. Install the backend first or set VIBE_TRADING_EXECUTABLE.",
portUnavailable: "Could not allocate a local port.",
credentialEncryptionUnavailable: "Credential encryption is unavailable for this Windows user session.",
credentialFileUnsupported: "The desktop credential file format is not supported.",
credentialKeyUnsupported: "This credential key is not supported.",
credentialRequestInvalid: "The credential request is invalid.",
credentialStoreUnavailable: "Secure credential storage has not been initialized.",
closingService: "Closing the local service…",
menuApplication: "Application",
menuRestartService: "Restart local service",
@@ -95,6 +105,11 @@ const messages: Record<DesktopLocale, DesktopMessages> = {
backendHealthTimeout: "等待 Vibe-Trading 后端就绪超时。\n{details}",
backendNotFound: "找不到 vibe-trading.exe。请先安装后端,或设置 VIBE_TRADING_EXECUTABLE。",
portUnavailable: "无法分配本地端口。",
credentialEncryptionUnavailable: "当前 Windows 用户会话无法使用凭据加密。",
credentialFileUnsupported: "桌面凭据文件格式不受支持。",
credentialKeyUnsupported: "不支持该凭据键。",
credentialRequestInvalid: "凭据请求无效。",
credentialStoreUnavailable: "安全凭据存储尚未初始化。",
closingService: "正在关闭本地服务…",
menuApplication: "应用",
menuRestartService: "重启本地服务",
@@ -125,6 +140,11 @@ const messages: Record<DesktopLocale, DesktopMessages> = {
backendHealthTimeout: "Vibe-Trading バックエンドの準備待ちがタイムアウトしました。\n{details}",
backendNotFound: "vibe-trading.exe が見つかりません。バックエンドをインストールするか、VIBE_TRADING_EXECUTABLE を設定してください。",
portUnavailable: "ローカルポートを割り当てられませんでした。",
credentialEncryptionUnavailable: "この Windows ユーザーセッションでは資格情報の暗号化を利用できません。",
credentialFileUnsupported: "デスクトップ資格情報ファイルの形式はサポートされていません。",
credentialKeyUnsupported: "この資格情報キーはサポートされていません。",
credentialRequestInvalid: "資格情報の要求が無効です。",
credentialStoreUnavailable: "安全な資格情報ストアが初期化されていません。",
closingService: "ローカルサービスを終了しています…",
menuApplication: "アプリケーション",
menuRestartService: "ローカルサービスを再起動",
@@ -155,6 +175,11 @@ const messages: Record<DesktopLocale, DesktopMessages> = {
backendHealthTimeout: "Vibe-Trading 백엔드 준비를 기다리는 동안 시간이 초과되었습니다.\n{details}",
backendNotFound: "vibe-trading.exe를 찾을 수 없습니다. 백엔드를 설치하거나 VIBE_TRADING_EXECUTABLE을 설정하세요.",
portUnavailable: "로컬 포트를 할당할 수 없습니다.",
credentialEncryptionUnavailable: "현재 Windows 사용자 세션에서 자격 증명 암호화를 사용할 수 없습니다.",
credentialFileUnsupported: "데스크톱 자격 증명 파일 형식이 지원되지 않습니다.",
credentialKeyUnsupported: "지원되지 않는 자격 증명 키입니다.",
credentialRequestInvalid: "자격 증명 요청이 올바르지 않습니다.",
credentialStoreUnavailable: "보안 자격 증명 저장소가 초기화되지 않았습니다.",
closingService: "로컬 서비스를 종료하는 중…",
menuApplication: "애플리케이션",
menuRestartService: "로컬 서비스 다시 시작",
@@ -185,6 +210,11 @@ const messages: Record<DesktopLocale, DesktopMessages> = {
backendHealthTimeout: "انتهت مهلة انتظار جاهزية الواجهة الخلفية لـ Vibe-Trading.\n{details}",
backendNotFound: "تعذّر العثور على vibe-trading.exe. ثبّت الواجهة الخلفية أولاً أو اضبط VIBE_TRADING_EXECUTABLE.",
portUnavailable: "تعذّر تخصيص منفذ محلي.",
credentialEncryptionUnavailable: "تشفير بيانات الاعتماد غير متاح لجلسة مستخدم Windows الحالية.",
credentialFileUnsupported: "تنسيق ملف بيانات اعتماد سطح المكتب غير مدعوم.",
credentialKeyUnsupported: "مفتاح بيانات الاعتماد هذا غير مدعوم.",
credentialRequestInvalid: "طلب بيانات الاعتماد غير صالح.",
credentialStoreUnavailable: "لم تتم تهيئة مخزن بيانات الاعتماد الآمن.",
closingService: "جارٍ إغلاق الخدمة المحلية…",
menuApplication: "التطبيق",
menuRestartService: "إعادة تشغيل الخدمة المحلية",
+24 -1
View File
@@ -17,6 +17,7 @@ import {
getRendererLocale,
resolveDesktopLocale,
} from "./locales";
import { SecureCredentialStore } from "./secure-credentials";
let mainWindow: BrowserWindow | undefined;
let backend: BackendManager | undefined;
@@ -27,6 +28,7 @@ let desktopMessages = getDesktopMessages(desktopLocale);
const apiAuthKey = randomBytes(32).toString("base64url");
const productName = "Vibe-Trading Desktop (Unofficial Community Build)";
const testUserData = process.env.VIBE_TRADING_DESKTOP_TEST_USER_DATA;
let credentialStore: SecureCredentialStore | undefined;
if (testUserData) app.setPath("userData", path.resolve(testUserData));
app.setName(productName);
@@ -51,6 +53,8 @@ async function ready(): Promise<void> {
desktopMessages = getDesktopMessages(desktopLocale);
nativeTheme.themeSource = "dark";
app.setAppLogsPath();
credentialStore = new SecureCredentialStore({ messages: desktopMessages });
await credentialStore.initialize();
createWindow();
registerIpc();
createMenu();
@@ -126,6 +130,19 @@ function registerIpc(): void {
await boot();
return true;
});
ipcMain.handle("desktop:get-credential-status", (event) => {
assertMainWindowSender(event.sender);
return requireCredentialStore().status();
});
ipcMain.handle("desktop:set-credential", async (event, name: unknown, value: unknown) => {
assertMainWindowSender(event.sender);
if (typeof name !== "string" || (typeof value !== "string" && value !== null)) {
throw new Error(desktopMessages.credentialRequestInvalid);
}
const store = requireCredentialStore();
await store.set(name, value);
return store.status();
});
}
async function showLoadingPage(): Promise<void> {
@@ -157,6 +174,7 @@ async function bootInternal(): Promise<void> {
logDirectory: app.getPath("logs"),
apiAuthKey,
messages: desktopMessages,
credentialEnvironment: requireCredentialStore().environment(),
onStatus: (message) => mainWindow?.webContents.send("desktop:status", message),
onUnexpectedExit: (message) => {
if (!quitting) void reportBootError(message);
@@ -165,8 +183,8 @@ async function bootInternal(): Promise<void> {
try {
const url = await backend.start();
writeParentDeathSmokeRecord(url);
await mainWindow.loadURL(url);
writeParentDeathSmokeRecord(url);
} catch (error) {
await backend.stop();
await reportBootError(errorText(error));
@@ -223,6 +241,11 @@ function assertMainWindowSender(sender: Electron.WebContents): void {
if (sender !== mainWindow?.webContents) throw new Error("Desktop request rejected");
}
function requireCredentialStore(): SecureCredentialStore {
if (!credentialStore) throw new Error(desktopMessages.credentialStoreUnavailable);
return credentialStore;
}
async function shutdownAndQuit(): Promise<void> {
if (quitting) return;
quitting = true;
+3
View File
@@ -11,4 +11,7 @@ contextBridge.exposeInMainWorld("vibeDesktop", {
retry: () => ipcRenderer.send("desktop:retry"),
openLogs: () => ipcRenderer.send("desktop:open-logs"),
restartBackend: () => ipcRenderer.invoke("desktop:restart-backend") as Promise<boolean>,
getCredentialStatus: () => ipcRenderer.invoke("desktop:get-credential-status"),
setCredential: (name: string, value: string | null) =>
ipcRenderer.invoke("desktop:set-credential", name, value),
});
+239
View File
@@ -0,0 +1,239 @@
import { app, safeStorage } from "electron";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { DesktopMessages } from "./locales";
type CredentialFile = {
version: 1;
values: Record<string, string>;
};
const ENV_CREDENTIALS = new Set([
"OPENROUTER_API_KEY",
"REQUESTY_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"DEEPSEEK_API_KEY",
"SILICONFLOW_API_KEY",
"SILICONFLOW_GLOBAL_API_KEY",
"NVIDIA_API_KEY",
"GEMINI_API_KEY",
"GROQ_API_KEY",
"DASHSCOPE_API_KEY",
"ZHIPU_API_KEY",
"MOONSHOT_API_KEY",
"KIMI_CODING_API_KEY",
"MINIMAX_API_KEY",
"MIMO_API_KEY",
"MODELSCOPE_API_KEY",
"SPARK_API_KEY",
"ZAI_API_KEY",
"TUSHARE_TOKEN",
"QVERIS_API_KEY",
]);
const PLACEHOLDER_VALUES = new Set([
"",
"sk-or-v1-your-key-here",
"sk-or-...here",
"sk-xxx",
"xxx",
"gsk_xxx",
"your-tushare-token",
]);
export type CredentialStatus = {
available: boolean;
configured: string[];
migrated: string[];
};
export type SecureCredentialStoreOptions = {
userDataDirectory?: string;
homeDirectory?: string;
messages?: Pick<
DesktopMessages,
| "credentialEncryptionUnavailable"
| "credentialFileUnsupported"
| "credentialKeyUnsupported"
>;
};
const defaultMessages: NonNullable<SecureCredentialStoreOptions["messages"]> = {
credentialEncryptionUnavailable: "Credential encryption is unavailable for this Windows user session.",
credentialFileUnsupported: "The desktop credential file format is not supported.",
credentialKeyUnsupported: "This credential key is not supported.",
};
export class SecureCredentialStore {
private readonly filePath: string;
private readonly homeDirectory: string;
private readonly messages: NonNullable<SecureCredentialStoreOptions["messages"]>;
private values: Record<string, string> = {};
private migrated: string[] = [];
constructor(options: SecureCredentialStoreOptions = {}) {
this.filePath = path.join(
options.userDataDirectory ?? app.getPath("userData"),
"credentials.v1.json",
);
this.homeDirectory = options.homeDirectory ?? os.homedir();
this.messages = options.messages ?? defaultMessages;
}
async initialize(): Promise<void> {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error(this.messages.credentialEncryptionUnavailable);
}
await this.load();
await this.migrateDotenv(path.join(this.homeDirectory, ".vibe-trading", ".env"));
await this.migrateJsonField(
path.join(this.homeDirectory, ".vibe-trading", "qveris.json"),
"api_key",
"QVERIS_API_KEY",
);
}
status(): CredentialStatus {
return {
available: safeStorage.isEncryptionAvailable(),
configured: Object.keys(this.values).sort(),
migrated: [...this.migrated],
};
}
async set(name: string, value: string | null): Promise<void> {
this.assertAllowed(name);
const normalized = value?.trim() ?? "";
if (!normalized) {
delete this.values[name];
} else {
this.values[name] = safeStorage.encryptString(normalized).toString("base64");
}
await this.persist();
}
environment(): NodeJS.ProcessEnv {
const output: NodeJS.ProcessEnv = {};
for (const name of Object.keys(this.values)) {
const value = this.decrypt(name);
if (value) output[name] = value;
}
return output;
}
private decrypt(name: string): string {
try {
return safeStorage.decryptString(Buffer.from(this.values[name], "base64"));
} catch {
return "";
}
}
private assertAllowed(name: string): void {
if (!ENV_CREDENTIALS.has(name)) {
throw new Error(this.messages.credentialKeyUnsupported);
}
}
private async load(): Promise<void> {
try {
const parsed = JSON.parse(await fs.readFile(this.filePath, "utf8")) as CredentialFile;
if (parsed.version !== 1 || !parsed.values || typeof parsed.values !== "object") {
throw new Error(this.messages.credentialFileUnsupported);
}
this.values = Object.fromEntries(
Object.entries(parsed.values).filter(
([name, value]) => ENV_CREDENTIALS.has(name) && typeof value === "string",
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
private async persist(): Promise<void> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.tmp`;
await fs.writeFile(
temporary,
`${JSON.stringify({ version: 1, values: this.values }, null, 2)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
await fs.rename(temporary, this.filePath);
}
private async migrateDotenv(filePath: string): Promise<void> {
let text: string;
try {
text = await fs.readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
let changed = false;
const nextLines: string[] = [];
for (const line of text.split(/\r?\n/u)) {
const match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=\s*(.*)\s*$/u);
const name = match?.[1] ?? "";
const value = unquote(match?.[2] ?? "");
if (ENV_CREDENTIALS.has(name) && !PLACEHOLDER_VALUES.has(value.toLowerCase())) {
if (!this.values[name]) {
this.values[name] = safeStorage.encryptString(value).toString("base64");
this.migrated.push(name);
}
nextLines.push(`# ${name} is stored by Vibe-Trading Desktop secure storage.`);
changed = true;
} else {
nextLines.push(line);
}
}
if (changed) {
await this.persist();
await atomicTextWrite(filePath, nextLines.join("\n"));
}
}
private async migrateJsonField(
filePath: string,
field: string,
credential: string,
): Promise<void> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as Record<string, unknown>;
const value = typeof parsed[field] === "string" ? parsed[field].trim() : "";
if (!value) return;
if (!this.values[credential]) {
this.values[credential] = safeStorage.encryptString(value).toString("base64");
this.migrated.push(credential);
await this.persist();
}
delete parsed[field];
await atomicTextWrite(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) {
throw error;
}
}
}
}
function unquote(value: string): string {
const trimmed = value.trim();
if (
trimmed.length >= 2 &&
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
async function atomicTextWrite(filePath: string, content: string): Promise<void> {
const temporary = `${filePath}.${process.pid}.tmp`;
await fs.writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
await fs.rename(temporary, filePath);
}
@@ -157,15 +157,23 @@ export function QVerisSettings() {
budget_credits_per_session: Number(form.budget_credits_per_session) || 0,
};
const trimmedApiKey = form.api_key.trim();
if (trimmedApiKey) payload.api_key = trimmedApiKey;
if (trimmedApiKey && !window.vibeDesktop) payload.api_key = trimmedApiKey;
const nextConfig = await requestJson<QVerisConfig>("/qveris/config", {
method: "PUT",
body: JSON.stringify(payload),
});
if (trimmedApiKey && window.vibeDesktop) {
await window.vibeDesktop.setCredential("QVERIS_API_KEY", trimmedApiKey);
}
setConfig(nextConfig);
setForm(formFromConfig(nextConfig));
toast.success(t("qveris.saved"));
if (trimmedApiKey && window.vibeDesktop) {
toast.info(t("settings.desktopCredentialRestarting"));
await window.vibeDesktop.restartBackend();
return;
}
await load();
} catch (saveError) {
const message = saveError instanceof Error ? saveError.message : t("qveris.testFailed");
+21
View File
@@ -0,0 +1,21 @@
export {};
export interface DesktopCredentialStatus {
available: boolean;
configured: string[];
migrated: string[];
}
declare global {
interface Window {
vibeDesktop?: {
isDesktop: boolean;
restartBackend: () => Promise<boolean>;
getCredentialStatus: () => Promise<DesktopCredentialStatus>;
setCredential: (
name: string,
value: string | null,
) => Promise<DesktopCredentialStatus>;
};
}
}
+1
View File
@@ -390,6 +390,7 @@
"llmSettingsSaved": "تم حفظ إعدادات LLM",
"saveLlmSettingsFailed": "فشل حفظ إعدادات LLM: {{message}}",
"dataSourceSettingsSaved": "تم حفظ إعدادات مصادر البيانات",
"desktopCredentialRestarting": "تم تشفير بيانات الاعتماد. جارٍ إعادة تشغيل الخدمة المحلية…",
"saveDataSourceSettingsFailed": "فشل حفظ إعدادات مصادر البيانات: {{message}}",
"configured": "تم التكوين",
"keepCurrentKey": "اتركه فارغًا للاحتفاظ بالمفتاح الحالي",
+1
View File
@@ -395,6 +395,7 @@
"llmSettingsSaved": "LLM settings saved",
"saveLlmSettingsFailed": "Failed to save LLM settings: {{message}}",
"dataSourceSettingsSaved": "Data source settings saved",
"desktopCredentialRestarting": "Credential encrypted. Restarting the local service…",
"saveDataSourceSettingsFailed": "Failed to save data source settings: {{message}}",
"configured": "Configured",
"keepCurrentKey": "Leave blank to keep the current key",
+1
View File
@@ -390,6 +390,7 @@
"llmSettingsSaved": "LLM 設定を保存しました",
"saveLlmSettingsFailed": "LLM 設定の保存に失敗: {{message}}",
"dataSourceSettingsSaved": "データソース設定を保存しました",
"desktopCredentialRestarting": "認証情報を暗号化しました。ローカルサービスを再起動しています…",
"saveDataSourceSettingsFailed": "データソース設定の保存に失敗: {{message}}",
"configured": "設定済み",
"keepCurrentKey": "空欄で現在のキーを保持",
+1
View File
@@ -390,6 +390,7 @@
"llmSettingsSaved": "LLM 설정이 저장되었습니다",
"saveLlmSettingsFailed": "LLM 설정 저장 실패: {{message}}",
"dataSourceSettingsSaved": "데이터 소스 설정이 저장되었습니다",
"desktopCredentialRestarting": "자격 증명이 암호화되었습니다. 로컬 서비스를 다시 시작하는 중입니다…",
"saveDataSourceSettingsFailed": "데이터 소스 설정 저장 실패: {{message}}",
"configured": "구성됨",
"keepCurrentKey": "비우면 현재 키 유지",
+1
View File
@@ -390,6 +390,7 @@
"llmSettingsSaved": "LLM 设置已保存",
"saveLlmSettingsFailed": "保存 LLM 设置失败: {{message}}",
"dataSourceSettingsSaved": "数据源设置已保存",
"desktopCredentialRestarting": "凭据已加密,正在重启本地服务…",
"saveDataSourceSettingsFailed": "保存数据源设置失败: {{message}}",
"configured": "已配置",
"keepCurrentKey": "留空以保留当前密钥",
+29 -6
View File
@@ -36,6 +36,7 @@ function toForm(settings: LLMSettings): LLMFormState {
export function Settings() {
const { t } = useTranslation();
const isDesktop = window.vibeDesktop?.isDesktop === true;
const [settings, setSettings] = useState<LLMSettings | null>(null);
const [dataSettings, setDataSettings] = useState<DataSourceSettings | null>(null);
const [channelStatus, setChannelStatus] = useState<ChannelRuntimeStatus | null>(null);
@@ -218,16 +219,27 @@ export function Settings() {
if (!form) return;
setSaving(true);
try {
const desktop = window.vibeDesktop;
const updated = await api.updateLLMSettings({
...form,
api_key: apiKey.trim() || undefined,
clear_api_key: clearApiKey,
api_key: isDesktop ? undefined : apiKey.trim() || undefined,
clear_api_key: isDesktop ? false : clearApiKey,
});
if (desktop && selectedProvider?.api_key_env && (apiKey.trim() || clearApiKey)) {
await desktop.setCredential(
selectedProvider.api_key_env,
clearApiKey ? null : apiKey.trim(),
);
}
setSettings(updated);
setForm(toForm(updated));
setApiKey("");
setClearApiKey(false);
toast.success(t("settings.llmSettingsSaved"));
if (desktop && selectedProvider?.api_key_env && (apiKey.trim() || clearApiKey)) {
toast.info(t("settings.desktopCredentialRestarting"));
await desktop.restartBackend();
}
} catch (error) {
toast.error(t("settings.saveLlmSettingsFailed", {
message: error instanceof Error
@@ -243,14 +255,25 @@ export function Settings() {
event.preventDefault();
setDataSaving(true);
try {
const desktop = window.vibeDesktop;
const updated = await api.updateDataSourceSettings({
tushare_token: tushareToken.trim() || undefined,
clear_tushare_token: clearTushareToken,
tushare_token: isDesktop ? undefined : tushareToken.trim() || undefined,
clear_tushare_token: isDesktop ? false : clearTushareToken,
});
if (desktop && (tushareToken.trim() || clearTushareToken)) {
await desktop.setCredential(
"TUSHARE_TOKEN",
clearTushareToken ? null : tushareToken.trim(),
);
}
setDataSettings(updated);
setTushareToken("");
setClearTushareToken(false);
toast.success(t("settings.dataSourceSettingsSaved"));
if (desktop && (tushareToken.trim() || clearTushareToken)) {
toast.info(t("settings.desktopCredentialRestarting"));
await desktop.restartBackend();
}
} catch (error) {
toast.error(t("settings.saveDataSourceSettingsFailed", {
message: error instanceof Error
@@ -302,7 +325,7 @@ export function Settings() {
<h1 className="text-2xl font-semibold tracking-tight">{t("settings.title")}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t("settings.subtitle")}</p>
</div>
{localApiAccessSection}
{!isDesktop && localApiAccessSection}
{/* QVERIS-INTEGRATION */}
<QVerisSettings />
<div className="flex min-h-32 items-center justify-center rounded-lg border bg-card p-5 text-sm text-muted-foreground">
@@ -455,7 +478,7 @@ export function Settings() {
<p className="max-w-3xl text-sm text-muted-foreground">{t("settings.subtitle")}</p>
</div>
{localApiAccessSection}
{!isDesktop && localApiAccessSection}
{/* QVERIS-INTEGRATION */}
<QVerisSettings />
@@ -31,6 +31,7 @@ vi.mock("@/lib/apiAuth", () => ({
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
}));
@@ -153,6 +154,7 @@ describe("Settings QVeris card", () => {
afterEach(() => {
cleanup();
delete window.vibeDesktop;
vi.unstubAllGlobals();
});
@@ -207,6 +209,40 @@ describe("Settings QVeris card", () => {
expect(JSON.parse(String(putCall?.[1]?.body))).not.toHaveProperty("api_key");
});
it("stores a desktop API key through safe storage and restarts the backend", async () => {
const setCredential = vi.fn().mockResolvedValue({
available: true,
configured: ["QVERIS_API_KEY"],
migrated: [],
});
const restartBackend = vi.fn().mockResolvedValue(true);
window.vibeDesktop = {
isDesktop: true,
getCredentialStatus: vi.fn(),
setCredential,
restartBackend,
};
const fetchMock = mockQVerisFetch();
render(<QVerisSettings />);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save QVeris settings" })).toBeEnabled(),
);
fireEvent.change(screen.getByPlaceholderText("sk-...8TI"), {
target: { value: "sk-desktop-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Save QVeris settings" }));
await waitFor(() =>
expect(setCredential).toHaveBeenCalledWith("QVERIS_API_KEY", "sk-desktop-key"),
);
const putCall = fetchMock.mock.calls.find(
([path, init]) => String(path) === "/qveris/config" && init?.method === "PUT",
);
expect(JSON.parse(String(putCall?.[1]?.body))).not.toHaveProperty("api_key");
expect(restartBackend).toHaveBeenCalledTimes(1);
});
it("uses the signup href from the config response and renders the empty usage state", async () => {
mockQVerisFetch(qverisConfig({ signup_url: "https://qveris.ai/?ref=config-response" }), qverisStatus({ recent: [] }));
render(<QVerisSettings />);