Merge pull request #1015 from QCYTSN/agent/windows-packaging
feat(desktop): add Windows packaging and secure credential storage
This commit is contained in:
31 files changed
+9592
-34
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
# ===================================================================
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Generated
+3420
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: "إعادة تشغيل الخدمة المحلية",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Vendored
+21
@@ -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>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,7 @@
|
||||
"llmSettingsSaved": "تم حفظ إعدادات LLM",
|
||||
"saveLlmSettingsFailed": "فشل حفظ إعدادات LLM: {{message}}",
|
||||
"dataSourceSettingsSaved": "تم حفظ إعدادات مصادر البيانات",
|
||||
"desktopCredentialRestarting": "تم تشفير بيانات الاعتماد. جارٍ إعادة تشغيل الخدمة المحلية…",
|
||||
"saveDataSourceSettingsFailed": "فشل حفظ إعدادات مصادر البيانات: {{message}}",
|
||||
"configured": "تم التكوين",
|
||||
"keepCurrentKey": "اتركه فارغًا للاحتفاظ بالمفتاح الحالي",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -390,6 +390,7 @@
|
||||
"llmSettingsSaved": "LLM 設定を保存しました",
|
||||
"saveLlmSettingsFailed": "LLM 設定の保存に失敗: {{message}}",
|
||||
"dataSourceSettingsSaved": "データソース設定を保存しました",
|
||||
"desktopCredentialRestarting": "認証情報を暗号化しました。ローカルサービスを再起動しています…",
|
||||
"saveDataSourceSettingsFailed": "データソース設定の保存に失敗: {{message}}",
|
||||
"configured": "設定済み",
|
||||
"keepCurrentKey": "空欄で現在のキーを保持",
|
||||
|
||||
@@ -390,6 +390,7 @@
|
||||
"llmSettingsSaved": "LLM 설정이 저장되었습니다",
|
||||
"saveLlmSettingsFailed": "LLM 설정 저장 실패: {{message}}",
|
||||
"dataSourceSettingsSaved": "데이터 소스 설정이 저장되었습니다",
|
||||
"desktopCredentialRestarting": "자격 증명이 암호화되었습니다. 로컬 서비스를 다시 시작하는 중입니다…",
|
||||
"saveDataSourceSettingsFailed": "데이터 소스 설정 저장 실패: {{message}}",
|
||||
"configured": "구성됨",
|
||||
"keepCurrentKey": "비우면 현재 키 유지",
|
||||
|
||||
@@ -390,6 +390,7 @@
|
||||
"llmSettingsSaved": "LLM 设置已保存",
|
||||
"saveLlmSettingsFailed": "保存 LLM 设置失败: {{message}}",
|
||||
"dataSourceSettingsSaved": "数据源设置已保存",
|
||||
"desktopCredentialRestarting": "凭据已加密,正在重启本地服务…",
|
||||
"saveDataSourceSettingsFailed": "保存数据源设置失败: {{message}}",
|
||||
"configured": "已配置",
|
||||
"keepCurrentKey": "留空以保留当前密钥",
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
Reference in new issue
Block a user