feat(desktop): add Electron shell lifecycle (#923)
* feat(desktop): add Electron shell lifecycle Signed-off-by: QCYTSN <2558348385@qq.com> * docs(desktop): document shell security boundary Signed-off-by: QCYTSN <2558348385@qq.com> * fix(desktop): address shell lifecycle review Signed-off-by: QCYTSN <2558348385@qq.com> * fix(desktop): constrain backend executable resolution Signed-off-by: QCYTSN <2558348385@qq.com> --------- Signed-off-by: QCYTSN <2558348385@qq.com>
This commit is contained in:
1 parent
6c44732818
commit
832b2c9efd
19 files changed
+2197
No files matched your search
@@ -123,3 +123,41 @@ jobs:
|
|||||||
agent/tests/test_background_tools.py
|
agent/tests/test_background_tools.py
|
||||||
agent/tests/test_bash_tool.py
|
agent/tests/test_bash_tool.py
|
||||||
-q
|
-q
|
||||||
|
|
||||||
|
desktop-electron-windows:
|
||||||
|
name: Windows desktop source lifecycle
|
||||||
|
runs-on: windows-2025
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# actions/checkout v7.0.1
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
|
# actions/setup-python v7.0.0
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
architecture: x64
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
# actions/setup-node v7.0.0
|
||||||
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: desktop/electron/package-lock.json
|
||||||
|
|
||||||
|
- name: Install Vibe-Trading source backend
|
||||||
|
run: python -m pip install -e .
|
||||||
|
|
||||||
|
- name: Install desktop dependencies
|
||||||
|
working-directory: desktop/electron
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build desktop shell
|
||||||
|
working-directory: desktop/electron
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run desktop lifecycle and parent-death smoke tests
|
||||||
|
working-directory: desktop/electron
|
||||||
|
run: npm run smoke:lifecycle
|
||||||
@@ -101,6 +101,10 @@ AGENTS.md
|
|||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Desktop shell build outputs
|
||||||
|
desktop/electron/node_modules/
|
||||||
|
desktop/electron/dist/
|
||||||
|
|
||||||
# Alpha Zoo wiki artifacts (generated, not source)
|
# Alpha Zoo wiki artifacts (generated, not source)
|
||||||
wiki/alpha-library/manifest.json
|
wiki/alpha-library/manifest.json
|
||||||
wiki/alpha-library/content/
|
wiki/alpha-library/content/
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## What the shell does
|
||||||
|
|
||||||
|
- Enforces a single desktop application instance.
|
||||||
|
- Starts one owned `vibe-trading serve` process through a parent-death
|
||||||
|
watchdog.
|
||||||
|
- Selects a free loopback port and binds the backend to `127.0.0.1`.
|
||||||
|
- Generates a 256-bit authentication secret for each desktop process.
|
||||||
|
- Adds the secret to same-origin renderer requests without exposing its value
|
||||||
|
to page JavaScript.
|
||||||
|
- Waits for the authenticated `/health` endpoint before loading the UI.
|
||||||
|
- Captures backend output in Electron's per-user log directory.
|
||||||
|
- Reports startup failures and exposes retry and log-folder actions.
|
||||||
|
- Requests authenticated graceful shutdown before asking the watchdog to
|
||||||
|
terminate the owned Windows process tree as a fallback.
|
||||||
|
- Terminates the Python process tree if the Electron main process is killed
|
||||||
|
without running JavaScript shutdown handlers.
|
||||||
|
- Localizes desktop-owned loading, status, error, dialog, and menu text in
|
||||||
|
English, Simplified Chinese, Japanese, Korean, and Arabic, including RTL
|
||||||
|
loading-page layout for Arabic.
|
||||||
|
- Prevents in-window navigation away from the local backend origin.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Run from source
|
||||||
|
|
||||||
|
From a complete Vibe-Trading checkout:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\python.exe -m pip install -e .
|
||||||
|
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ..\desktop\electron
|
||||||
|
npm ci
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows lifecycle suite used by CI can be run from the same directory:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run smoke:lifecycle
|
||||||
|
```
|
||||||
|
|
||||||
|
It verifies localized message parity, graceful shutdown, authentication, and
|
||||||
|
the parent-death path by force-terminating only the Electron main PID before
|
||||||
|
checking that the Python process and loopback listener are gone.
|
||||||
|
|
||||||
|
Backend resolution is intentionally narrow. It checks an explicit
|
||||||
|
`VIBE_TRADING_EXECUTABLE` override first, then exact application/resource
|
||||||
|
locations used by packaged builds. In source mode only, it may use
|
||||||
|
`.venv\Scripts\vibe-trading.exe` from an ancestor containing this project's
|
||||||
|
`pyproject.toml` (`[project].name = "vibe-trading-ai"`). Its final fallback is
|
||||||
|
`vibe-trading.exe` on `PATH`; arbitrary ancestor directories and the drive root
|
||||||
|
are never searched for executables. To select an explicit development backend:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:VIBE_TRADING_EXECUTABLE = "C:\path\to\vibe-trading.exe"
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review scope
|
||||||
|
|
||||||
|
This first change 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`.
|
||||||
|
|
||||||
|
The desktop shell remains unofficial. No release or distribution ownership is
|
||||||
|
implied by this source directory.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Desktop Shell Review Notes
|
||||||
|
|
||||||
|
## Upstream baseline
|
||||||
|
|
||||||
|
- Repository: `HKUDS/Vibe-Trading`
|
||||||
|
- Baseline commit: `261f007c410f7a6ff015a17f6830c8f809cd7413`
|
||||||
|
- Baseline version metadata: `0.1.12`
|
||||||
|
- Rebuilt directly from current upstream `main`; no `0.1.11` source overlay is
|
||||||
|
included.
|
||||||
|
|
||||||
|
## File inventory
|
||||||
|
|
||||||
|
```text
|
||||||
|
.gitignore
|
||||||
|
.github/workflows/test.yml
|
||||||
|
desktop/electron/
|
||||||
|
README.md
|
||||||
|
REVIEW_NOTES.md
|
||||||
|
THREAT_MODEL.md
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
tsconfig.json
|
||||||
|
scripts/
|
||||||
|
copy-static.mjs
|
||||||
|
smoke-parent-death.mjs
|
||||||
|
smoke-lifecycle.mjs
|
||||||
|
test-backend-resolution.mjs
|
||||||
|
test-locales.mjs
|
||||||
|
src/
|
||||||
|
backend-manager.ts
|
||||||
|
backend-watchdog.ts
|
||||||
|
loading.html
|
||||||
|
locales.ts
|
||||||
|
main.ts
|
||||||
|
preload.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
No agent, provider, session, frontend, channel, packaging, credential-storage,
|
||||||
|
or updater file is changed. The workflow change adds only the Windows desktop
|
||||||
|
source-lifecycle job requested during review.
|
||||||
|
|
||||||
|
## Dependency and license review
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm ci
|
||||||
|
npm ls --all --json
|
||||||
|
npm audit --json
|
||||||
|
npm sbom --sbom-format cyclonedx
|
||||||
|
```
|
||||||
|
|
||||||
|
Host result refreshed on 2026-08-04:
|
||||||
|
|
||||||
|
- 14 installed dependency packages;
|
||||||
|
- 0 known npm audit vulnerabilities;
|
||||||
|
- no production JavaScript dependencies;
|
||||||
|
- direct development dependencies: Electron 43.1.1, TypeScript 5.9.3, and
|
||||||
|
`@types/node` 24.13.3.
|
||||||
|
|
||||||
|
Observed package licenses:
|
||||||
|
|
||||||
|
| License | Packages |
|
||||||
|
| --- | --- |
|
||||||
|
| MIT | `@electron/get`, `@types/node`, `debug`, `electron`, `env-paths`, `ms`, `progress`, `undici`, `undici-types` |
|
||||||
|
| ISC | `graceful-fs`, `semver` |
|
||||||
|
| Apache-2.0 | `sumchecker`, `typescript` |
|
||||||
|
| BSD-2-Clause | `@electron-internal/extract-zip` |
|
||||||
|
|
||||||
|
The CycloneDX JSON output is generated from the committed lock file for PR
|
||||||
|
review rather than hand-maintained. Electron's Chromium/Node third-party
|
||||||
|
notices become a packaging deliverable and are intentionally deferred.
|
||||||
|
|
||||||
|
## Validation ledger
|
||||||
|
|
||||||
|
Host development validation on Windows:
|
||||||
|
|
||||||
|
- [x] `npm ci`
|
||||||
|
- [x] `npm run build`
|
||||||
|
- [x] `npm audit` reports zero vulnerabilities
|
||||||
|
- [x] TypeScript strict compilation
|
||||||
|
- [x] current-upstream backend starts on a random `127.0.0.1` port
|
||||||
|
- [x] unauthenticated protected route returns HTTP 401
|
||||||
|
- [x] authenticated health and protected-route requests succeed
|
||||||
|
- [x] graceful shutdown stops the listener and leaves no owned Python process
|
||||||
|
- [x] a second Electron launch is redirected to the existing instance and does
|
||||||
|
not create a second backend
|
||||||
|
- [x] force-terminating the Electron main process leaves no owned Python
|
||||||
|
process or listening backend port
|
||||||
|
- [x] repeated desktop startup and process-residue checks
|
||||||
|
- [x] missing-backend startup fails with an actionable diagnostic
|
||||||
|
- [x] backend discovery prefers an explicit override, then exact packaged
|
||||||
|
locations, a marker-anchored source virtual environment, and finally `PATH`
|
||||||
|
- [x] an executable planted in an unmarked ancestor is ignored, a wrong
|
||||||
|
`pyproject.toml` project name is rejected, and packaged mode does not search
|
||||||
|
source ancestors
|
||||||
|
- [x] all desktop-owned user-facing strings have en/zh-CN/ja/ko/ar parity and
|
||||||
|
Arabic selects RTL layout
|
||||||
|
- [x] clean-Windows source startup is exercised by the
|
||||||
|
`Windows desktop source lifecycle` job on a fresh `windows-2025` runner
|
||||||
|
|
||||||
|
The clean-Windows job installs Python 3.12 and Node 22 into a fresh runner,
|
||||||
|
installs Vibe-Trading from the checked-out source, runs `npm ci`, performs a
|
||||||
|
strict production build, starts the source backend through Electron, verifies
|
||||||
|
the authenticated loopback boundary and graceful shutdown, then kills only
|
||||||
|
the Electron main PID and asserts that the Python PID and listener disappear.
|
||||||
|
This replaces the older unchecked manual-VM entry with a reproducible CI gate.
|
||||||
|
|
||||||
|
## Unsigned-build limitations
|
||||||
|
|
||||||
|
This change is source-only and intentionally produces no installer. A later
|
||||||
|
packaging review must cover Authenticode signing, installer reputation, bundled
|
||||||
|
license notices, Python SBOM, update authenticity, and release ownership.
|
||||||
|
Nothing in this change creates or claims an HKUDS release channel.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Desktop Process-Boundary Threat Model
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- The per-launch API authentication secret.
|
||||||
|
- Local Vibe-Trading sessions, reports, configuration, and research data.
|
||||||
|
- Any credentials already present in the environment inherited by the Python
|
||||||
|
process.
|
||||||
|
- The integrity of the executable selected as the backend.
|
||||||
|
- The ability to invoke authenticated local API routes.
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
```text
|
||||||
|
Electron main process
|
||||||
|
|-- owns random API secret
|
||||||
|
|-- 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
|
||||||
|
|
|
||||||
|
+--> backend parent-death watchdog
|
||||||
|
|-- inherits the launch secret only to pass it 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
|
||||||
|
```
|
||||||
|
|
||||||
|
The renderer is trusted to perform the same application actions as the web UI,
|
||||||
|
but it is not given the raw authentication secret. A renderer compromise can
|
||||||
|
still call authenticated API routes through the Electron session and is
|
||||||
|
therefore security-significant.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
### Loopback and authentication
|
||||||
|
|
||||||
|
- The backend is launched with `--host 127.0.0.1`.
|
||||||
|
- A free ephemeral port is selected for every backend start.
|
||||||
|
- The secret is 32 cryptographically random bytes encoded with Base64URL.
|
||||||
|
- The secret exists only in Electron main-process memory and the owned
|
||||||
|
watchdog/Python child environments. It is not written to logs,
|
||||||
|
configuration, or renderer storage.
|
||||||
|
- Electron adds `Authorization: Bearer <secret>` only when the request origin
|
||||||
|
exactly matches the active backend origin.
|
||||||
|
- Health and shutdown requests are authenticated.
|
||||||
|
- A new desktop process receives a new secret, so any stale value is invalid
|
||||||
|
after exit.
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
- `nodeIntegration` is disabled.
|
||||||
|
- `contextIsolation` and Chromium sandboxing are enabled.
|
||||||
|
- The preload exposes only status, error, retry, log-folder, and backend-restart
|
||||||
|
operations.
|
||||||
|
- 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.
|
||||||
|
- Developer tools are unavailable when Electron reports a packaged build.
|
||||||
|
- Renderer traffic uses an isolated persistent Electron partition rather than
|
||||||
|
the default browser session.
|
||||||
|
|
||||||
|
### Backend executable resolution
|
||||||
|
|
||||||
|
- `VIBE_TRADING_EXECUTABLE`, when it names an existing file, is an explicit
|
||||||
|
operator override and takes precedence.
|
||||||
|
- Packaged-runtime discovery checks only exact paths anchored to Electron's
|
||||||
|
application and resources directories, including fixed `app` and `resources`
|
||||||
|
subtrees. It does not walk their ancestors.
|
||||||
|
- Source-mode discovery is disabled for packaged applications. During source
|
||||||
|
development, an ancestor is accepted only when its `pyproject.toml` contains
|
||||||
|
`[project].name = "vibe-trading-ai"`; only that marked root's
|
||||||
|
`.venv\Scripts\vibe-trading.exe` is eligible.
|
||||||
|
- The final fallback checks non-empty `PATH` entries for `vibe-trading.exe`.
|
||||||
|
- No generic executable candidate is evaluated while walking ancestors, and
|
||||||
|
the filesystem drive root is never treated as a source-project root.
|
||||||
|
|
||||||
|
### Process lifecycle
|
||||||
|
|
||||||
|
- Only one desktop application instance is allowed.
|
||||||
|
- Standard output and standard error are appended to a per-user Electron log.
|
||||||
|
- Startup waits for authenticated health success and reports early process exit
|
||||||
|
with a bounded log tail.
|
||||||
|
- Electron starts a small watchdog before the Python backend. The watchdog
|
||||||
|
verifies that the Electron main PID is alive before spawning Python.
|
||||||
|
- The watchdog retains an IPC channel to Electron and also polls the exact
|
||||||
|
parent PID. An IPC disconnect or dead parent PID triggers Windows
|
||||||
|
`taskkill /T /F` against the Python process tree without relying on an
|
||||||
|
Electron JavaScript shutdown callback.
|
||||||
|
- Normal shutdown first calls the authenticated backend shutdown route. If the
|
||||||
|
backend remains alive, Electron asks the watchdog to terminate it and finally
|
||||||
|
retains process-tree termination as a bounded fallback.
|
||||||
|
- The automated parent-death smoke test terminates only the Electron main PID,
|
||||||
|
then independently verifies that both the Python PID and loopback listener
|
||||||
|
are gone.
|
||||||
|
|
||||||
|
## Residual risks
|
||||||
|
|
||||||
|
- Renderer script injection can exercise the authenticated API even though it
|
||||||
|
cannot read the raw secret.
|
||||||
|
- A local administrator, debugger, or process with equivalent user privileges
|
||||||
|
may inspect the Electron, watchdog, or Python process and its environment.
|
||||||
|
- Port discovery closes the probe socket before Python binds it. Another local
|
||||||
|
process can win that race, causing startup failure; it does not receive the
|
||||||
|
authentication secret.
|
||||||
|
- The development override `VIBE_TRADING_EXECUTABLE` trusts the explicitly
|
||||||
|
selected executable. Users must not point it at untrusted code.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
Generated
+191
@@ -0,0 +1,191 @@
|
|||||||
|
{
|
||||||
|
"name": "vibe-trading-desktop-unofficial-community",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "vibe-trading-desktop-unofficial-community",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"electron": "43.1.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electron-internal/extract-zip": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electron/get": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"env-paths": "^3.0.0",
|
||||||
|
"graceful-fs": "^4.2.11",
|
||||||
|
"progress": "^2.0.3",
|
||||||
|
"semver": "^7.6.3",
|
||||||
|
"sumchecker": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"undici": "^7.24.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/electron": {
|
||||||
|
"version": "43.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron/-/electron-43.1.1.tgz",
|
||||||
|
"integrity": "sha512-I5c5vfuVvaXpWx3IZdwvXgxQW44+e7OP1wXGVQkogLeSFSkUZ6sLCcWV05AdEcs65AO5tAIJJwbp7ixw+LdarA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@electron-internal/extract-zip": "^1.0.1",
|
||||||
|
"@electron/get": "^5.0.0",
|
||||||
|
"@types/node": "^24.9.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"electron": "cli.js",
|
||||||
|
"install-electron": "install.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/env-paths": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/progress": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sumchecker": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "vibe-trading-desktop-unofficial-community",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"private": true,
|
||||||
|
"author": "Vibe-Trading Desktop Community Contributors",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Vibe-Trading Desktop (Unofficial Community Build)",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && node scripts/copy-static.mjs",
|
||||||
|
"start": "npm run build && electron .",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"sbom": "npm sbom --sbom-format cyclonedx"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"electron": "43.1.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { copyFile, mkdir } from "node:fs/promises";
|
||||||
|
|
||||||
|
await mkdir(new URL("../dist/", import.meta.url), { recursive: true });
|
||||||
|
await copyFile(
|
||||||
|
new URL("../src/loading.html", import.meta.url),
|
||||||
|
new URL("../dist/loading.html", import.meta.url),
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { BackendManager } from "../dist/backend-manager.js";
|
||||||
|
import { getDesktopMessages } from "../dist/locales.js";
|
||||||
|
|
||||||
|
const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const logDirectory = await mkdtemp(path.join(os.tmpdir(), "vibe-trading-desktop-shell-"));
|
||||||
|
const statuses = [];
|
||||||
|
let unexpectedExit;
|
||||||
|
const apiAuthKey = randomBytes(32).toString("base64url");
|
||||||
|
|
||||||
|
const backend = new BackendManager({
|
||||||
|
appPath: electronRoot,
|
||||||
|
resourcesPath: electronRoot,
|
||||||
|
logDirectory,
|
||||||
|
apiAuthKey,
|
||||||
|
messages: getDesktopMessages("en"),
|
||||||
|
onStatus: (message) => statuses.push(message),
|
||||||
|
onUnexpectedExit: (message) => {
|
||||||
|
unexpectedExit = message;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = await backend.start();
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.hostname !== "127.0.0.1") {
|
||||||
|
throw new Error(`Expected a loopback backend, received ${parsed.origin}`);
|
||||||
|
}
|
||||||
|
const health = await fetch(new URL("health", url), {
|
||||||
|
headers: { Authorization: `Bearer ${apiAuthKey}` },
|
||||||
|
});
|
||||||
|
if (!health.ok) throw new Error(`Authenticated health returned HTTP ${health.status}`);
|
||||||
|
const unauthorized = await fetch(new URL("sessions", url));
|
||||||
|
if (unauthorized.status !== 401) {
|
||||||
|
throw new Error(`Expected unauthenticated sessions request to return 401, received ${unauthorized.status}`);
|
||||||
|
}
|
||||||
|
const authorized = await fetch(new URL("sessions", url), {
|
||||||
|
headers: { Authorization: `Bearer ${apiAuthKey}` },
|
||||||
|
});
|
||||||
|
if (!authorized.ok) {
|
||||||
|
throw new Error(`Authenticated sessions request returned HTTP ${authorized.status}`);
|
||||||
|
}
|
||||||
|
if (unexpectedExit) throw new Error(unexpectedExit);
|
||||||
|
} finally {
|
||||||
|
await backend.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
await fetch(new URL("health", url), { signal: AbortSignal.timeout(1_000) });
|
||||||
|
} catch {
|
||||||
|
stopped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stopped) throw new Error("Backend still accepted connections after shutdown");
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
backendOrigin: new URL(url).origin,
|
||||||
|
statuses,
|
||||||
|
logDirectory,
|
||||||
|
authBoundaryVerified: true,
|
||||||
|
shutdownVerified: stopped,
|
||||||
|
}, null, 2));
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
console.log("Parent-death smoke is Windows-specific; skipped on this platform.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronPath = process.env.VIBE_TRADING_DESKTOP_ELECTRON_EXECUTABLE
|
||||||
|
|| (await import("electron")).default;
|
||||||
|
|
||||||
|
const electronRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const testDirectory = await mkdtemp(path.join(os.tmpdir(), "vibe-desktop-parent-death-"));
|
||||||
|
const readyFile = path.join(testDirectory, "ready.json");
|
||||||
|
const userData = path.join(testDirectory, "user-data");
|
||||||
|
const output = [];
|
||||||
|
const electronMain = spawn(electronPath, [electronRoot, "--disable-gpu"], {
|
||||||
|
cwd: electronRoot,
|
||||||
|
windowsHide: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VIBE_TRADING_DESKTOP_LOCALE: "en",
|
||||||
|
VIBE_TRADING_DESKTOP_PARENT_DEATH_SMOKE_FILE: readyFile,
|
||||||
|
VIBE_TRADING_DESKTOP_TEST_USER_DATA: userData,
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
electronMain.stdout?.on("data", (chunk) => output.push(`[OUT] ${chunk.toString("utf8")}`));
|
||||||
|
electronMain.stderr?.on("data", (chunk) => output.push(`[ERR] ${chunk.toString("utf8")}`));
|
||||||
|
|
||||||
|
let ready;
|
||||||
|
try {
|
||||||
|
ready = await waitForReadyFile(readyFile, 180_000, electronMain, output);
|
||||||
|
assert.equal(ready.mainPid, electronMain.pid, "Spawned Electron PID did not match its main PID");
|
||||||
|
assert.equal(typeof ready.backendPid, "number");
|
||||||
|
assert.match(ready.backendOrigin, /^http:\/\/127\.0\.0\.1:\d+$/u);
|
||||||
|
assert.equal(await listenerAcceptsConnections(ready.backendOrigin), true);
|
||||||
|
assert.equal(isProcessAlive(ready.backendPid), true);
|
||||||
|
|
||||||
|
await killOnlyProcess(ready.mainPid);
|
||||||
|
await waitForCondition(
|
||||||
|
() => !isProcessAlive(ready.backendPid),
|
||||||
|
20_000,
|
||||||
|
`Python backend PID ${ready.backendPid} survived Electron main termination`,
|
||||||
|
);
|
||||||
|
await waitForCondition(
|
||||||
|
async () => !(await listenerAcceptsConnections(ready.backendOrigin)),
|
||||||
|
20_000,
|
||||||
|
`Backend listener ${ready.backendOrigin} survived Electron main termination`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
killedPid: ready.mainPid,
|
||||||
|
killMode: "single-pid-only",
|
||||||
|
backendPid: ready.backendPid,
|
||||||
|
backendOrigin: ready.backendOrigin,
|
||||||
|
backendExited: true,
|
||||||
|
listenerClosed: true,
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
if (electronMain.pid && isProcessAlive(electronMain.pid)) {
|
||||||
|
await killOnlyProcess(electronMain.pid);
|
||||||
|
}
|
||||||
|
if (ready?.backendPid && isProcessAlive(ready.backendPid)) {
|
||||||
|
await killProcessTree(ready.backendPid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReadyFile(filePath, timeoutMs, child, capturedOutput) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) {
|
||||||
|
throw new Error(`Electron main exited before readiness.\n${capturedOutput.join("")}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(filePath, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for Electron parent-death readiness.\n${capturedOutput.join("")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listenerAcceptsConnections(origin) {
|
||||||
|
try {
|
||||||
|
await fetch(new URL("health", `${origin}/`), { signal: AbortSignal.timeout(1_000) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessAlive(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(predicate, timeoutMs, errorMessage) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await predicate()) return;
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function killOnlyProcess(pid) {
|
||||||
|
return runTaskkill(["/PID", String(pid), "/F"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function killProcessTree(pid) {
|
||||||
|
return runTaskkill(["/PID", String(pid), "/T", "/F"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTaskkill(arguments_) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const killer = spawn("taskkill.exe", arguments_, { windowsHide: true, stdio: "ignore" });
|
||||||
|
killer.once("error", reject);
|
||||||
|
killer.once("exit", (code) => {
|
||||||
|
if (code === 0 || code === 128) resolve();
|
||||||
|
else reject(new Error(`taskkill exited with code ${String(code)}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(milliseconds) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveBackend } from "../dist/backend-manager.js";
|
||||||
|
|
||||||
|
const testRoot = await mkdtemp(path.join(os.tmpdir(), "vibe-desktop-resolution-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const explicit = await makeExecutable("explicit/vibe-trading.exe");
|
||||||
|
const packagedResources = path.join(testRoot, "packaged/resources");
|
||||||
|
const packagedPython = await makeExecutable("packaged/resources/backend/python.exe");
|
||||||
|
const pathExecutable = await makeExecutable("path-bin/vibe-trading.exe");
|
||||||
|
|
||||||
|
assertResolution(
|
||||||
|
resolveBackend(options({
|
||||||
|
appPath: path.join(testRoot, "packaged/app.asar"),
|
||||||
|
resourcesPath: packagedResources,
|
||||||
|
executableOverride: explicit,
|
||||||
|
pathEnvironment: path.dirname(pathExecutable),
|
||||||
|
})),
|
||||||
|
explicit,
|
||||||
|
"explicit override must take precedence",
|
||||||
|
);
|
||||||
|
|
||||||
|
const packagedResolution = resolveBackend(options({
|
||||||
|
appPath: path.join(testRoot, "packaged/app.asar"),
|
||||||
|
resourcesPath: packagedResources,
|
||||||
|
pathEnvironment: path.dirname(pathExecutable),
|
||||||
|
}));
|
||||||
|
assertResolution(packagedResolution, packagedPython, "exact packaged resource must be selected");
|
||||||
|
assert.equal(packagedResolution?.includeServeCommand, false);
|
||||||
|
|
||||||
|
const sourceRoot = path.join(testRoot, "marked-source");
|
||||||
|
await writeProjectMarker(sourceRoot, "vibe-trading-ai");
|
||||||
|
const sourceExecutable = await makeExecutable("marked-source/.venv/Scripts/vibe-trading.exe");
|
||||||
|
const sourceResolution = resolveBackend(options({
|
||||||
|
appPath: path.join(sourceRoot, "desktop/electron"),
|
||||||
|
resourcesPath: path.join(testRoot, "empty-resources"),
|
||||||
|
moduleDirectory: path.join(sourceRoot, "desktop/electron/dist"),
|
||||||
|
pathEnvironment: path.dirname(pathExecutable),
|
||||||
|
}));
|
||||||
|
assertResolution(sourceResolution, sourceExecutable, "marked source virtual environment must be selected");
|
||||||
|
|
||||||
|
const plantedRoot = path.join(testRoot, "unmarked-ancestor");
|
||||||
|
const plantedPython = await makeExecutable("unmarked-ancestor/backend/python.exe");
|
||||||
|
const plantedAppPath = path.join(plantedRoot, "workspace/desktop/electron");
|
||||||
|
const safePathExecutable = await makeExecutable("safe-path/vibe-trading.exe");
|
||||||
|
const unmarkedResolution = resolveBackend(options({
|
||||||
|
appPath: plantedAppPath,
|
||||||
|
resourcesPath: path.join(plantedAppPath, "resources"),
|
||||||
|
moduleDirectory: path.join(plantedAppPath, "dist"),
|
||||||
|
pathEnvironment: path.dirname(safePathExecutable),
|
||||||
|
}));
|
||||||
|
assert.notEqual(path.resolve(unmarkedResolution?.executable ?? ""), path.resolve(plantedPython));
|
||||||
|
assertResolution(
|
||||||
|
unmarkedResolution,
|
||||||
|
safePathExecutable,
|
||||||
|
"unmarked ancestor executable must be ignored in favor of PATH",
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrongProjectRoot = path.join(testRoot, "wrong-project");
|
||||||
|
await writeProjectMarker(wrongProjectRoot, "another-project");
|
||||||
|
await makeExecutable("wrong-project/.venv/Scripts/vibe-trading.exe");
|
||||||
|
assertResolution(
|
||||||
|
resolveBackend(options({
|
||||||
|
appPath: path.join(wrongProjectRoot, "desktop/electron"),
|
||||||
|
resourcesPath: path.join(testRoot, "wrong-project-resources"),
|
||||||
|
moduleDirectory: path.join(wrongProjectRoot, "desktop/electron/dist"),
|
||||||
|
pathEnvironment: path.dirname(pathExecutable),
|
||||||
|
})),
|
||||||
|
pathExecutable,
|
||||||
|
"wrong project marker must not authorize a source virtual environment",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertResolution(
|
||||||
|
resolveBackend(options({
|
||||||
|
appPath: path.join(sourceRoot, "desktop/electron"),
|
||||||
|
resourcesPath: path.join(testRoot, "disabled-source-resources"),
|
||||||
|
moduleDirectory: path.join(sourceRoot, "desktop/electron/dist"),
|
||||||
|
allowSourceDiscovery: false,
|
||||||
|
pathEnvironment: path.dirname(pathExecutable),
|
||||||
|
})),
|
||||||
|
pathExecutable,
|
||||||
|
"packaged mode must not search source ancestors even when a marker exists",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
explicitOverride: true,
|
||||||
|
exactPackagedResource: true,
|
||||||
|
markedSourceRoot: true,
|
||||||
|
unmarkedAncestorRejected: true,
|
||||||
|
wrongMarkerRejected: true,
|
||||||
|
packagedSourceDiscoveryDisabled: true,
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await rm(testRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(overrides) {
|
||||||
|
return {
|
||||||
|
appPath: path.join(testRoot, "empty-app"),
|
||||||
|
resourcesPath: path.join(testRoot, "empty-resources"),
|
||||||
|
moduleDirectory: path.join(testRoot, "empty-module"),
|
||||||
|
allowSourceDiscovery: true,
|
||||||
|
pathEnvironment: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeExecutable(relativePath) {
|
||||||
|
const target = path.join(testRoot, ...relativePath.split("/"));
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, "test fixture", "utf8");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProjectMarker(root, projectName) {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(root, "pyproject.toml"),
|
||||||
|
`[project]\nname = "${projectName}"\nversion = "0.0.0"\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertResolution(resolution, expectedExecutable, message) {
|
||||||
|
assert.ok(resolution, message);
|
||||||
|
assert.equal(path.resolve(resolution.executable), path.resolve(expectedExecutable), message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDesktopMessages,
|
||||||
|
getRendererLocale,
|
||||||
|
resolveDesktopLocale,
|
||||||
|
supportedDesktopLocales,
|
||||||
|
} from "../dist/locales.js";
|
||||||
|
|
||||||
|
const english = getDesktopMessages("en");
|
||||||
|
const expectedKeys = Object.keys(english).sort();
|
||||||
|
|
||||||
|
assert.deepEqual(supportedDesktopLocales, ["en", "zh-CN", "ja", "ko", "ar"]);
|
||||||
|
for (const locale of supportedDesktopLocales) {
|
||||||
|
const localeMessages = getDesktopMessages(locale);
|
||||||
|
assert.deepEqual(Object.keys(localeMessages).sort(), expectedKeys, `${locale} message keys differ`);
|
||||||
|
for (const key of expectedKeys) {
|
||||||
|
assert.equal(typeof localeMessages[key], "string", `${locale}.${key} is not a string`);
|
||||||
|
assert.notEqual(localeMessages[key].trim(), "", `${locale}.${key} is empty`);
|
||||||
|
assert.deepEqual(
|
||||||
|
placeholders(localeMessages[key]),
|
||||||
|
placeholders(english[key]),
|
||||||
|
`${locale}.${key} placeholders differ from English`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(resolveDesktopLocale(undefined), "en");
|
||||||
|
assert.equal(resolveDesktopLocale("fr-FR"), "en");
|
||||||
|
assert.equal(resolveDesktopLocale("zh-Hans-CN"), "zh-CN");
|
||||||
|
assert.equal(resolveDesktopLocale("ja-JP"), "ja");
|
||||||
|
assert.equal(resolveDesktopLocale("ko_KR"), "ko");
|
||||||
|
assert.equal(resolveDesktopLocale("ar-SA"), "ar");
|
||||||
|
assert.equal(getRendererLocale("en").direction, "ltr");
|
||||||
|
assert.equal(getRendererLocale("ar").direction, "rtl");
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
locales: supportedDesktopLocales,
|
||||||
|
messageKeys: expectedKeys.length,
|
||||||
|
rtlLocale: "ar",
|
||||||
|
parityVerified: true,
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
function placeholders(value) {
|
||||||
|
return [...value.matchAll(/\{([a-zA-Z0-9_]+)\}/gu)].map((match) => match[1]).sort();
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { ChildProcess, spawn } from "node:child_process";
|
||||||
|
import {
|
||||||
|
createWriteStream,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
WriteStream,
|
||||||
|
} from "node:fs";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
DesktopMessages,
|
||||||
|
formatDesktopMessage,
|
||||||
|
} from "./locales";
|
||||||
|
|
||||||
|
type BackendManagerOptions = {
|
||||||
|
appPath: string;
|
||||||
|
resourcesPath: string;
|
||||||
|
allowSourceDiscovery: boolean;
|
||||||
|
logDirectory: string;
|
||||||
|
apiAuthKey: string;
|
||||||
|
messages: DesktopMessages;
|
||||||
|
onStatus: (message: string) => void;
|
||||||
|
onUnexpectedExit: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedBackend = {
|
||||||
|
executable: string;
|
||||||
|
prefixArguments: string[];
|
||||||
|
includeServeCommand: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackendResolutionOptions = {
|
||||||
|
appPath: string;
|
||||||
|
resourcesPath: string;
|
||||||
|
moduleDirectory?: string;
|
||||||
|
allowSourceDiscovery: boolean;
|
||||||
|
executableOverride?: string;
|
||||||
|
pathEnvironment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WatchdogMessage = {
|
||||||
|
type: string;
|
||||||
|
pid?: number;
|
||||||
|
code?: number | null;
|
||||||
|
signal?: NodeJS.Signals | null;
|
||||||
|
message?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BackendManager {
|
||||||
|
private watchdog: ChildProcess | undefined;
|
||||||
|
private watchdogError: Error | undefined;
|
||||||
|
private backendPid: number | undefined;
|
||||||
|
private baseUrl: string | undefined;
|
||||||
|
private stopping = false;
|
||||||
|
private logStream: WriteStream | undefined;
|
||||||
|
private readonly recentOutput: string[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly options: BackendManagerOptions) {}
|
||||||
|
|
||||||
|
async start(): Promise<string> {
|
||||||
|
if (this.watchdog) throw new Error(this.options.messages.backendAlreadyRunning);
|
||||||
|
this.stopping = false;
|
||||||
|
this.watchdogError = undefined;
|
||||||
|
const resolved = resolveBackend({
|
||||||
|
appPath: this.options.appPath,
|
||||||
|
resourcesPath: this.options.resourcesPath,
|
||||||
|
moduleDirectory: __dirname,
|
||||||
|
allowSourceDiscovery: this.options.allowSourceDiscovery,
|
||||||
|
executableOverride: process.env.VIBE_TRADING_EXECUTABLE,
|
||||||
|
pathEnvironment: process.env.PATH,
|
||||||
|
});
|
||||||
|
if (!resolved) throw new Error(this.options.messages.backendNotFound);
|
||||||
|
const executable = resolved.executable;
|
||||||
|
const port = await findFreePort(this.options.messages.portUnavailable);
|
||||||
|
this.baseUrl = `http://127.0.0.1:${port}/`;
|
||||||
|
mkdirSync(this.options.logDirectory, { recursive: true });
|
||||||
|
const day = new Date().toISOString().slice(0, 10).replaceAll("-", "");
|
||||||
|
this.logStream = createWriteStream(path.join(this.options.logDirectory, `desktop-${day}.log`), {
|
||||||
|
flags: "a",
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
this.writeLog(`\n[${new Date().toISOString()}] Starting ${executable} on 127.0.0.1:${port}`);
|
||||||
|
|
||||||
|
const backendArguments = [
|
||||||
|
...resolved.prefixArguments,
|
||||||
|
...(resolved.includeServeCommand ? ["serve"] : []),
|
||||||
|
"--host", "127.0.0.1", "--port", String(port),
|
||||||
|
];
|
||||||
|
const gtkBin = path.join(path.dirname(executable), "gtk", "bin");
|
||||||
|
const childEnvironment: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
API_AUTH_KEY: this.options.apiAuthKey,
|
||||||
|
PYTHONUTF8: "1",
|
||||||
|
PYTHONUNBUFFERED: "1",
|
||||||
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
|
VIBE_TRADING_DESKTOP_PARENT_PID: String(process.pid),
|
||||||
|
VIBE_TRADING_DESKTOP_BACKEND_EXECUTABLE: executable,
|
||||||
|
VIBE_TRADING_DESKTOP_BACKEND_ARGUMENTS: Buffer.from(
|
||||||
|
JSON.stringify(backendArguments),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64url"),
|
||||||
|
VIBE_TRADING_DESKTOP_BACKEND_CWD: path.dirname(executable),
|
||||||
|
};
|
||||||
|
if (existsSync(gtkBin)) {
|
||||||
|
childEnvironment.PATH = `${gtkBin}${path.delimiter}${process.env.PATH ?? ""}`;
|
||||||
|
childEnvironment.WEASYPRINT_DLL_DIRECTORIES = gtkBin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchdogPath = path.join(__dirname, "backend-watchdog.js");
|
||||||
|
const watchdog = spawn(process.execPath, [watchdogPath], {
|
||||||
|
cwd: __dirname,
|
||||||
|
windowsHide: true,
|
||||||
|
env: childEnvironment,
|
||||||
|
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||||
|
});
|
||||||
|
this.watchdog = watchdog;
|
||||||
|
watchdog.stdout?.on("data", (chunk: Buffer) => this.capture("OUT", chunk));
|
||||||
|
watchdog.stderr?.on("data", (chunk: Buffer) => this.capture("ERR", chunk));
|
||||||
|
watchdog.on("message", (message: WatchdogMessage) => this.handleWatchdogMessage(message));
|
||||||
|
watchdog.once("error", (error) => {
|
||||||
|
this.watchdogError = error;
|
||||||
|
this.writeLog(`[WATCHDOG] ${error.stack ?? error.message}`);
|
||||||
|
});
|
||||||
|
watchdog.once("exit", (code, signal) => {
|
||||||
|
this.writeLog(`[WATCHDOG] exited code=${String(code)} signal=${String(signal)}`);
|
||||||
|
this.backendPid = undefined;
|
||||||
|
if (!this.stopping) {
|
||||||
|
this.options.onUnexpectedExit(formatDesktopMessage(
|
||||||
|
this.options.messages.backendUnexpectedExit,
|
||||||
|
{ code: code ?? signal ?? "unknown" },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.options.onStatus(formatDesktopMessage(this.options.messages.backendStarting, { port }));
|
||||||
|
await this.waitUntilHealthy();
|
||||||
|
this.options.onStatus(formatDesktopMessage(this.options.messages.backendReady, { port }));
|
||||||
|
return this.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
const watchdog = this.watchdog;
|
||||||
|
if (!watchdog) return;
|
||||||
|
this.stopping = true;
|
||||||
|
if (isRunning(watchdog) && this.baseUrl) {
|
||||||
|
try {
|
||||||
|
await fetch(new URL("system/shutdown", this.baseUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${this.options.apiAuthKey}` },
|
||||||
|
signal: AbortSignal.timeout(2_000),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.writeLog(`[SHUTDOWN] graceful request failed: ${errorText(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRunning(watchdog)) {
|
||||||
|
await Promise.race([waitForExit(watchdog), delay(3_000)]);
|
||||||
|
}
|
||||||
|
if (isRunning(watchdog)) {
|
||||||
|
this.writeLog(`[SHUTDOWN] asking watchdog to terminate backend pid=${String(this.backendPid)}`);
|
||||||
|
try {
|
||||||
|
watchdog.send({ type: "terminate-backend" });
|
||||||
|
} catch (error) {
|
||||||
|
this.writeLog(`[SHUTDOWN] watchdog IPC failed: ${errorText(error)}`);
|
||||||
|
}
|
||||||
|
await Promise.race([waitForExit(watchdog), delay(5_000)]);
|
||||||
|
}
|
||||||
|
if (isRunning(watchdog) && watchdog.pid) {
|
||||||
|
this.writeLog(`[SHUTDOWN] terminating watchdog tree pid=${watchdog.pid}`);
|
||||||
|
await runTaskkill(watchdog.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.watchdog = undefined;
|
||||||
|
this.watchdogError = undefined;
|
||||||
|
this.backendPid = undefined;
|
||||||
|
this.baseUrl = undefined;
|
||||||
|
await new Promise<void>((resolve) => this.logStream?.end(resolve) ?? resolve());
|
||||||
|
this.logStream = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url(): string | undefined {
|
||||||
|
return this.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get processId(): number | undefined {
|
||||||
|
return this.backendPid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWatchdogMessage(message: WatchdogMessage): void {
|
||||||
|
switch (message?.type) {
|
||||||
|
case "backend-started":
|
||||||
|
if (typeof message.pid === "number") {
|
||||||
|
this.backendPid = message.pid;
|
||||||
|
this.writeLog(`[WATCHDOG] backend started pid=${message.pid}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "backend-error":
|
||||||
|
this.writeLog(`[WATCHDOG] backend error: ${message.message ?? "unknown"}`);
|
||||||
|
break;
|
||||||
|
case "backend-exited":
|
||||||
|
this.writeLog(
|
||||||
|
`[WATCHDOG] backend exited code=${String(message.code)} signal=${String(message.signal)}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "watchdog-terminating":
|
||||||
|
this.writeLog(`[WATCHDOG] terminating backend reason=${message.reason ?? "unknown"}`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitUntilHealthy(): Promise<void> {
|
||||||
|
if (!this.watchdog || !this.baseUrl) throw new Error(this.options.messages.backendNotStarted);
|
||||||
|
const deadline = Date.now() + 180_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (this.watchdogError) throw this.watchdogError;
|
||||||
|
if (!isRunning(this.watchdog)) {
|
||||||
|
throw new Error(formatDesktopMessage(this.options.messages.backendExitedEarly, {
|
||||||
|
code: this.watchdog.exitCode ?? this.watchdog.signalCode ?? "unknown",
|
||||||
|
details: this.tail(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(new URL("health", this.baseUrl), {
|
||||||
|
headers: { Authorization: `Bearer ${this.options.apiAuthKey}` },
|
||||||
|
signal: AbortSignal.timeout(2_000),
|
||||||
|
});
|
||||||
|
if (response.ok) return;
|
||||||
|
} catch {
|
||||||
|
// The loopback socket is not accepting connections yet.
|
||||||
|
}
|
||||||
|
await delay(100);
|
||||||
|
}
|
||||||
|
throw new Error(formatDesktopMessage(this.options.messages.backendHealthTimeout, {
|
||||||
|
details: this.tail(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private capture(stream: "OUT" | "ERR", chunk: Buffer): void {
|
||||||
|
for (const line of chunk.toString("utf8").split(/\r?\n/u)) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const formatted = `[${new Date().toISOString()}] [${stream}] ${line}`;
|
||||||
|
this.recentOutput.push(formatted);
|
||||||
|
if (this.recentOutput.length > 80) this.recentOutput.shift();
|
||||||
|
this.writeLog(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tail(): string {
|
||||||
|
return this.recentOutput.slice(-20).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeLog(message: string): void {
|
||||||
|
this.logStream?.write(`${message}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceProjectName = "vibe-trading-ai";
|
||||||
|
|
||||||
|
export function resolveBackend(options: BackendResolutionOptions): ResolvedBackend | undefined {
|
||||||
|
const configured = options.executableOverride;
|
||||||
|
if (configured && existsSync(configured)) return commandBackend(configured);
|
||||||
|
|
||||||
|
for (const root of trustedPackagedRoots(options.appPath, options.resourcesPath)) {
|
||||||
|
const resolved = resolvePackagedBackend(root);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.allowSourceDiscovery) {
|
||||||
|
const sourceRoots = new Set<string>();
|
||||||
|
for (const start of [options.appPath, options.moduleDirectory]) {
|
||||||
|
if (!start) continue;
|
||||||
|
const sourceRoot = findMarkedSourceRoot(start);
|
||||||
|
if (sourceRoot) sourceRoots.add(sourceRoot);
|
||||||
|
}
|
||||||
|
for (const sourceRoot of sourceRoots) {
|
||||||
|
const candidate = path.join(sourceRoot, ".venv", "Scripts", "vibe-trading.exe");
|
||||||
|
if (existsSync(candidate)) return commandBackend(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawEntry of (options.pathEnvironment ?? "").split(path.delimiter)) {
|
||||||
|
const entry = rawEntry.replaceAll('"', "").trim();
|
||||||
|
if (!entry) continue;
|
||||||
|
const candidate = path.join(entry, "vibe-trading.exe");
|
||||||
|
if (existsSync(candidate)) return commandBackend(candidate);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustedPackagedRoots(appPath: string, resourcesPath: string): string[] {
|
||||||
|
return [...new Set([
|
||||||
|
path.resolve(appPath),
|
||||||
|
path.resolve(resourcesPath),
|
||||||
|
path.resolve(resourcesPath, "app"),
|
||||||
|
path.resolve(resourcesPath, "resources"),
|
||||||
|
])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePackagedBackend(root: string): ResolvedBackend | undefined {
|
||||||
|
for (const candidate of [
|
||||||
|
path.join(root, "backend", "python.exe"),
|
||||||
|
path.join(root, "runtime", "backend", "python.exe"),
|
||||||
|
]) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return {
|
||||||
|
executable: path.resolve(candidate),
|
||||||
|
prefixArguments: [
|
||||||
|
"-c",
|
||||||
|
"import api_server; raise SystemExit(api_server.serve_main())",
|
||||||
|
],
|
||||||
|
includeServeCommand: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const candidate of [
|
||||||
|
path.join(root, "backend", "Scripts", "vibe-trading.exe"),
|
||||||
|
path.join(root, "runtime", "Scripts", "vibe-trading.exe"),
|
||||||
|
]) {
|
||||||
|
if (existsSync(candidate)) return commandBackend(candidate);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMarkedSourceRoot(start: string): string | undefined {
|
||||||
|
let current = path.resolve(start);
|
||||||
|
while (true) {
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) return undefined;
|
||||||
|
if (isVibeTradingProject(path.join(current, "pyproject.toml"))) return current;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVibeTradingProject(markerPath: string): boolean {
|
||||||
|
if (!existsSync(markerPath)) return false;
|
||||||
|
try {
|
||||||
|
let inProjectSection = false;
|
||||||
|
for (const rawLine of readFileSync(markerPath, "utf8").split(/\r?\n/u)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (/^\[[^\]]+\]$/u.test(line)) {
|
||||||
|
inProjectSection = line === "[project]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inProjectSection) continue;
|
||||||
|
const name = /^name\s*=\s*(["'])([^"']+)\1(?:\s*#.*)?$/u.exec(line)?.[2];
|
||||||
|
if (name) return name === sourceProjectName;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandBackend(executable: string): ResolvedBackend {
|
||||||
|
return {
|
||||||
|
executable: path.resolve(executable),
|
||||||
|
prefixArguments: [],
|
||||||
|
includeServeCommand: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFreePort(errorMessage: string): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = createServer();
|
||||||
|
server.unref();
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close();
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((error) => (error ? reject(error) : resolve(port)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(child: ChildProcess): boolean {
|
||||||
|
return child.exitCode === null && child.signalCode === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit(child: ChildProcess): Promise<void> {
|
||||||
|
if (!isRunning(child)) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => child.once("exit", () => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(milliseconds: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTaskkill(pid: number): Promise<void> {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// The watchdog has already exited.
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const killer = spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
killer.once("exit", () => resolve());
|
||||||
|
killer.once("error", () => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { ChildProcess, spawn } from "node:child_process";
|
||||||
|
|
||||||
|
type WatchdogCommand = {
|
||||||
|
type: "terminate-backend";
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentPid = parseRequiredPid(process.env.VIBE_TRADING_DESKTOP_PARENT_PID, "parent");
|
||||||
|
const executable = requireEnvironment("VIBE_TRADING_DESKTOP_BACKEND_EXECUTABLE");
|
||||||
|
const backendCwd = requireEnvironment("VIBE_TRADING_DESKTOP_BACKEND_CWD");
|
||||||
|
const backendArguments = parseArguments(
|
||||||
|
requireEnvironment("VIBE_TRADING_DESKTOP_BACKEND_ARGUMENTS"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isProcessAlive(parentPid)) process.exit(0);
|
||||||
|
|
||||||
|
const backendEnvironment = { ...process.env };
|
||||||
|
for (const key of [
|
||||||
|
"ELECTRON_RUN_AS_NODE",
|
||||||
|
"VIBE_TRADING_DESKTOP_PARENT_PID",
|
||||||
|
"VIBE_TRADING_DESKTOP_BACKEND_EXECUTABLE",
|
||||||
|
"VIBE_TRADING_DESKTOP_BACKEND_ARGUMENTS",
|
||||||
|
"VIBE_TRADING_DESKTOP_BACKEND_CWD",
|
||||||
|
"VIBE_TRADING_DESKTOP_ELECTRON_EXECUTABLE",
|
||||||
|
"VIBE_TRADING_DESKTOP_LOCALE",
|
||||||
|
"VIBE_TRADING_DESKTOP_PARENT_DEATH_SMOKE_FILE",
|
||||||
|
"VIBE_TRADING_DESKTOP_TEST_USER_DATA",
|
||||||
|
]) {
|
||||||
|
delete backendEnvironment[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const backend = spawn(executable, backendArguments, {
|
||||||
|
cwd: backendCwd,
|
||||||
|
windowsHide: true,
|
||||||
|
env: backendEnvironment,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
backend.stdout?.pipe(process.stdout, { end: false });
|
||||||
|
backend.stderr?.pipe(process.stderr, { end: false });
|
||||||
|
process.stdout.on("error", () => undefined);
|
||||||
|
process.stderr.on("error", () => undefined);
|
||||||
|
|
||||||
|
let terminating = false;
|
||||||
|
let backendExited = false;
|
||||||
|
const parentMonitor = setInterval(() => {
|
||||||
|
if (!isProcessAlive(parentPid)) void terminateBackend("parent-pid-exited");
|
||||||
|
}, 250);
|
||||||
|
parentMonitor.unref();
|
||||||
|
|
||||||
|
backend.once("spawn", () => {
|
||||||
|
sendToParent({ type: "backend-started", pid: backend.pid });
|
||||||
|
if (!process.connected || !isProcessAlive(parentPid)) {
|
||||||
|
void terminateBackend("parent-unavailable-after-spawn");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
backend.once("error", (error) => {
|
||||||
|
sendToParent({ type: "backend-error", message: error.message });
|
||||||
|
clearInterval(parentMonitor);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
backend.once("exit", (code, signal) => {
|
||||||
|
backendExited = true;
|
||||||
|
clearInterval(parentMonitor);
|
||||||
|
sendToParent({ type: "backend-exited", code, signal });
|
||||||
|
if (!terminating) process.exit(code ?? 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("disconnect", () => {
|
||||||
|
void terminateBackend("ipc-disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("message", (message: WatchdogCommand) => {
|
||||||
|
if (message?.type === "terminate-backend") {
|
||||||
|
void terminateBackend("parent-requested");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
void terminateBackend("watchdog-sigterm");
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
void terminateBackend("watchdog-sigint");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function terminateBackend(reason: string): Promise<void> {
|
||||||
|
if (terminating) return;
|
||||||
|
terminating = true;
|
||||||
|
clearInterval(parentMonitor);
|
||||||
|
sendToParent({ type: "watchdog-terminating", reason });
|
||||||
|
|
||||||
|
if (!backendExited && backend.exitCode === null && backend.pid) {
|
||||||
|
await terminateProcessTree(backend);
|
||||||
|
}
|
||||||
|
if (!backendExited) {
|
||||||
|
await Promise.race([waitForExit(backend), delay(5_000)]);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminateProcessTree(child: ChildProcess): Promise<void> {
|
||||||
|
if (!child.pid) return;
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
killer.once("exit", () => resolve());
|
||||||
|
killer.once("error", () => resolve());
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToParent(message: unknown): void {
|
||||||
|
if (!process.connected) return;
|
||||||
|
try {
|
||||||
|
process.send?.(message);
|
||||||
|
} catch {
|
||||||
|
// The Electron main process can disappear between the connected check and send.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRequiredPid(rawValue: string | undefined, label: string): number {
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`Invalid ${label} PID supplied to desktop backend watchdog`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(encoded: string): string[] {
|
||||||
|
const value: unknown = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
||||||
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
||||||
|
throw new Error("Invalid backend argument payload supplied to desktop backend watchdog");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEnvironment(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) throw new Error(`Missing ${name} for desktop backend watchdog`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit(child: ChildProcess): Promise<void> {
|
||||||
|
if (child.exitCode !== null) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => child.once("exit", () => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(milliseconds: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" dir="ltr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"
|
||||||
|
/>
|
||||||
|
<title>Vibe-Trading Desktop (Unofficial Community Build)</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: Inter, "Segoe UI", sans-serif; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: #0b0f14; color: #f4f7fa; min-height: 100vh; display: grid; place-items: center; }
|
||||||
|
main { width: min(560px, calc(100vw - 48px)); text-align: center; }
|
||||||
|
.logo { width: 68px; height: 68px; margin: 0 auto 24px; border-radius: 20px; display: grid; place-items: center; background: #14251f; color: #29d391; font: 700 36px/1 Inter, sans-serif; }
|
||||||
|
h1 { margin: 0; font-size: 26px; font-weight: 650; }
|
||||||
|
p { color: #9aa7b5; line-height: 1.65; margin: 13px 0 24px; }
|
||||||
|
.track { height: 4px; overflow: hidden; border-radius: 4px; background: #1e2732; }
|
||||||
|
.bar { width: 36%; height: 100%; border-radius: inherit; background: #29d391; animation: loading 1.25s ease-in-out infinite; }
|
||||||
|
.actions { display: none; gap: 10px; justify-content: center; margin-top: 22px; }
|
||||||
|
button { border: 1px solid #2b3745; background: #19232d; color: #f4f7fa; border-radius: 7px; padding: 9px 15px; cursor: pointer; }
|
||||||
|
button:hover { background: #22303d; }
|
||||||
|
body.error .track { display: none; }
|
||||||
|
body.error .actions { display: flex; }
|
||||||
|
body.error .logo { background: #2a171a; color: #ef5350; }
|
||||||
|
html[dir="rtl"] .actions { flex-direction: row-reverse; }
|
||||||
|
@keyframes loading { from { transform: translateX(-120%); } to { transform: translateX(300%); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="logo" aria-hidden="true">V</div>
|
||||||
|
<h1 id="title"></h1>
|
||||||
|
<p id="message" role="status" aria-live="polite"></p>
|
||||||
|
<div id="progress" class="track" role="progressbar"><div class="bar"></div></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="retry" type="button"></button>
|
||||||
|
<button id="logs" type="button"></button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const api = window.vibeDesktop;
|
||||||
|
const encodedLocale = new URLSearchParams(window.location.search).get("locale");
|
||||||
|
const unpaddedLocale = (encodedLocale || "").replaceAll("-", "+").replaceAll("_", "/");
|
||||||
|
const normalizedLocale = unpaddedLocale.padEnd(Math.ceil(unpaddedLocale.length / 4) * 4, "=");
|
||||||
|
const localePayload = JSON.parse(
|
||||||
|
new TextDecoder().decode(Uint8Array.from(atob(normalizedLocale), (character) => character.charCodeAt(0))),
|
||||||
|
);
|
||||||
|
let messages = localePayload.messages;
|
||||||
|
let pageState = "loading";
|
||||||
|
let hasRuntimeMessage = false;
|
||||||
|
|
||||||
|
function applyLocale(payload) {
|
||||||
|
messages = payload.messages;
|
||||||
|
document.documentElement.lang = payload.locale || "en";
|
||||||
|
document.documentElement.dir = payload.direction === "rtl" ? "rtl" : "ltr";
|
||||||
|
document.querySelector("#retry").textContent = messages.loadingRetry;
|
||||||
|
document.querySelector("#logs").textContent = messages.loadingOpenLogs;
|
||||||
|
document.querySelector("#progress").setAttribute("aria-label", messages.loadingTitle);
|
||||||
|
document.querySelector("#title").textContent = pageState === "error"
|
||||||
|
? messages.loadingFailedTitle
|
||||||
|
: pageState === "restarting"
|
||||||
|
? messages.loadingRestartingTitle
|
||||||
|
: messages.loadingTitle;
|
||||||
|
if (!hasRuntimeMessage) document.querySelector("#message").textContent = messages.loadingMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.onStatus((message) => {
|
||||||
|
hasRuntimeMessage = true;
|
||||||
|
document.querySelector("#message").textContent = message;
|
||||||
|
});
|
||||||
|
api.onError((message) => {
|
||||||
|
pageState = "error";
|
||||||
|
hasRuntimeMessage = true;
|
||||||
|
document.body.classList.add("error");
|
||||||
|
document.querySelector("#title").textContent = messages.loadingFailedTitle;
|
||||||
|
document.querySelector("#message").textContent = message;
|
||||||
|
});
|
||||||
|
document.querySelector("#retry").addEventListener("click", () => {
|
||||||
|
pageState = "restarting";
|
||||||
|
hasRuntimeMessage = false;
|
||||||
|
document.body.classList.remove("error");
|
||||||
|
document.querySelector("#title").textContent = messages.loadingRestartingTitle;
|
||||||
|
document.querySelector("#message").textContent = messages.loadingMessage;
|
||||||
|
api.retry();
|
||||||
|
});
|
||||||
|
document.querySelector("#logs").addEventListener("click", () => api.openLogs());
|
||||||
|
applyLocale(localePayload);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
export type DesktopLocale = "en" | "zh-CN" | "ja" | "ko" | "ar";
|
||||||
|
|
||||||
|
export type DesktopMessages = {
|
||||||
|
startupFailureTitle: string;
|
||||||
|
backendAlreadyRunning: string;
|
||||||
|
backendUnexpectedExit: string;
|
||||||
|
backendStarting: string;
|
||||||
|
backendReady: string;
|
||||||
|
backendNotStarted: string;
|
||||||
|
backendExitedEarly: string;
|
||||||
|
backendHealthTimeout: string;
|
||||||
|
backendNotFound: string;
|
||||||
|
portUnavailable: string;
|
||||||
|
closingService: string;
|
||||||
|
menuApplication: string;
|
||||||
|
menuRestartService: string;
|
||||||
|
menuOpenLogs: string;
|
||||||
|
menuQuit: string;
|
||||||
|
menuView: string;
|
||||||
|
menuReload: string;
|
||||||
|
menuDeveloperTools: string;
|
||||||
|
menuActualSize: string;
|
||||||
|
menuZoomIn: string;
|
||||||
|
menuZoomOut: string;
|
||||||
|
menuFullscreen: string;
|
||||||
|
loadingTitle: string;
|
||||||
|
loadingMessage: string;
|
||||||
|
loadingRetry: string;
|
||||||
|
loadingOpenLogs: string;
|
||||||
|
loadingFailedTitle: string;
|
||||||
|
loadingRestartingTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DesktopRendererLocale = {
|
||||||
|
locale: DesktopLocale;
|
||||||
|
direction: "ltr" | "rtl";
|
||||||
|
messages: Pick<
|
||||||
|
DesktopMessages,
|
||||||
|
| "loadingTitle"
|
||||||
|
| "loadingMessage"
|
||||||
|
| "loadingRetry"
|
||||||
|
| "loadingOpenLogs"
|
||||||
|
| "loadingFailedTitle"
|
||||||
|
| "loadingRestartingTitle"
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const supportedDesktopLocales: readonly DesktopLocale[] = [
|
||||||
|
"en",
|
||||||
|
"zh-CN",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"ar",
|
||||||
|
];
|
||||||
|
|
||||||
|
const messages: Record<DesktopLocale, DesktopMessages> = {
|
||||||
|
en: {
|
||||||
|
startupFailureTitle: "Vibe-Trading Desktop failed to start",
|
||||||
|
backendAlreadyRunning: "The backend process is already running.",
|
||||||
|
backendUnexpectedExit: "The Vibe-Trading backend exited unexpectedly (code {code}).",
|
||||||
|
backendStarting: "Backend started; waiting for health check · {port}",
|
||||||
|
backendReady: "Local service is ready · {port}",
|
||||||
|
backendNotStarted: "The backend has not started.",
|
||||||
|
backendExitedEarly: "The Vibe-Trading backend exited before it was ready (code {code}).\n{details}",
|
||||||
|
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.",
|
||||||
|
closingService: "Closing the local service…",
|
||||||
|
menuApplication: "Application",
|
||||||
|
menuRestartService: "Restart local service",
|
||||||
|
menuOpenLogs: "Open log folder",
|
||||||
|
menuQuit: "Quit",
|
||||||
|
menuView: "View",
|
||||||
|
menuReload: "Reload",
|
||||||
|
menuDeveloperTools: "Developer tools",
|
||||||
|
menuActualSize: "Actual size",
|
||||||
|
menuZoomIn: "Zoom in",
|
||||||
|
menuZoomOut: "Zoom out",
|
||||||
|
menuFullscreen: "Fullscreen",
|
||||||
|
loadingTitle: "Starting Vibe-Trading Desktop",
|
||||||
|
loadingMessage: "The desktop app is preparing the local service. The first start may take a moment.",
|
||||||
|
loadingRetry: "Retry",
|
||||||
|
loadingOpenLogs: "Open log folder",
|
||||||
|
loadingFailedTitle: "Startup failed",
|
||||||
|
loadingRestartingTitle: "Restarting",
|
||||||
|
},
|
||||||
|
"zh-CN": {
|
||||||
|
startupFailureTitle: "Vibe-Trading Desktop 启动失败",
|
||||||
|
backendAlreadyRunning: "后端进程已经在运行。",
|
||||||
|
backendUnexpectedExit: "Vibe-Trading 后端意外退出(代码 {code})。",
|
||||||
|
backendStarting: "后端已启动,正在等待健康检查 · {port}",
|
||||||
|
backendReady: "本地服务已就绪 · {port}",
|
||||||
|
backendNotStarted: "后端尚未启动。",
|
||||||
|
backendExitedEarly: "Vibe-Trading 后端在就绪前退出(代码 {code})。\n{details}",
|
||||||
|
backendHealthTimeout: "等待 Vibe-Trading 后端就绪超时。\n{details}",
|
||||||
|
backendNotFound: "找不到 vibe-trading.exe。请先安装后端,或设置 VIBE_TRADING_EXECUTABLE。",
|
||||||
|
portUnavailable: "无法分配本地端口。",
|
||||||
|
closingService: "正在关闭本地服务…",
|
||||||
|
menuApplication: "应用",
|
||||||
|
menuRestartService: "重启本地服务",
|
||||||
|
menuOpenLogs: "打开日志文件夹",
|
||||||
|
menuQuit: "退出",
|
||||||
|
menuView: "视图",
|
||||||
|
menuReload: "刷新",
|
||||||
|
menuDeveloperTools: "开发者工具",
|
||||||
|
menuActualSize: "实际大小",
|
||||||
|
menuZoomIn: "放大",
|
||||||
|
menuZoomOut: "缩小",
|
||||||
|
menuFullscreen: "全屏",
|
||||||
|
loadingTitle: "正在启动 Vibe-Trading Desktop",
|
||||||
|
loadingMessage: "桌面端正在准备本地服务,第一次启动可能需要一点时间。",
|
||||||
|
loadingRetry: "重试",
|
||||||
|
loadingOpenLogs: "打开日志文件夹",
|
||||||
|
loadingFailedTitle: "启动失败",
|
||||||
|
loadingRestartingTitle: "正在重新启动",
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
startupFailureTitle: "Vibe-Trading Desktop の起動に失敗しました",
|
||||||
|
backendAlreadyRunning: "バックエンドプロセスはすでに実行中です。",
|
||||||
|
backendUnexpectedExit: "Vibe-Trading バックエンドが予期せず終了しました(コード {code})。",
|
||||||
|
backendStarting: "バックエンドを起動しました。ヘルスチェックを待っています · {port}",
|
||||||
|
backendReady: "ローカルサービスの準備ができました · {port}",
|
||||||
|
backendNotStarted: "バックエンドはまだ起動していません。",
|
||||||
|
backendExitedEarly: "Vibe-Trading バックエンドが準備完了前に終了しました(コード {code})。\n{details}",
|
||||||
|
backendHealthTimeout: "Vibe-Trading バックエンドの準備待ちがタイムアウトしました。\n{details}",
|
||||||
|
backendNotFound: "vibe-trading.exe が見つかりません。バックエンドをインストールするか、VIBE_TRADING_EXECUTABLE を設定してください。",
|
||||||
|
portUnavailable: "ローカルポートを割り当てられませんでした。",
|
||||||
|
closingService: "ローカルサービスを終了しています…",
|
||||||
|
menuApplication: "アプリケーション",
|
||||||
|
menuRestartService: "ローカルサービスを再起動",
|
||||||
|
menuOpenLogs: "ログフォルダーを開く",
|
||||||
|
menuQuit: "終了",
|
||||||
|
menuView: "表示",
|
||||||
|
menuReload: "再読み込み",
|
||||||
|
menuDeveloperTools: "開発者ツール",
|
||||||
|
menuActualSize: "実際のサイズ",
|
||||||
|
menuZoomIn: "拡大",
|
||||||
|
menuZoomOut: "縮小",
|
||||||
|
menuFullscreen: "全画面表示",
|
||||||
|
loadingTitle: "Vibe-Trading Desktop を起動しています",
|
||||||
|
loadingMessage: "ローカルサービスを準備しています。初回起動には少し時間がかかる場合があります。",
|
||||||
|
loadingRetry: "再試行",
|
||||||
|
loadingOpenLogs: "ログフォルダーを開く",
|
||||||
|
loadingFailedTitle: "起動に失敗しました",
|
||||||
|
loadingRestartingTitle: "再起動しています",
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
startupFailureTitle: "Vibe-Trading Desktop를 시작하지 못했습니다",
|
||||||
|
backendAlreadyRunning: "백엔드 프로세스가 이미 실행 중입니다.",
|
||||||
|
backendUnexpectedExit: "Vibe-Trading 백엔드가 예기치 않게 종료되었습니다(코드 {code}).",
|
||||||
|
backendStarting: "백엔드가 시작되었습니다. 상태 확인을 기다리는 중 · {port}",
|
||||||
|
backendReady: "로컬 서비스가 준비되었습니다 · {port}",
|
||||||
|
backendNotStarted: "백엔드가 아직 시작되지 않았습니다.",
|
||||||
|
backendExitedEarly: "Vibe-Trading 백엔드가 준비되기 전에 종료되었습니다(코드 {code}).\n{details}",
|
||||||
|
backendHealthTimeout: "Vibe-Trading 백엔드 준비를 기다리는 동안 시간이 초과되었습니다.\n{details}",
|
||||||
|
backendNotFound: "vibe-trading.exe를 찾을 수 없습니다. 백엔드를 설치하거나 VIBE_TRADING_EXECUTABLE을 설정하세요.",
|
||||||
|
portUnavailable: "로컬 포트를 할당할 수 없습니다.",
|
||||||
|
closingService: "로컬 서비스를 종료하는 중…",
|
||||||
|
menuApplication: "애플리케이션",
|
||||||
|
menuRestartService: "로컬 서비스 다시 시작",
|
||||||
|
menuOpenLogs: "로그 폴더 열기",
|
||||||
|
menuQuit: "종료",
|
||||||
|
menuView: "보기",
|
||||||
|
menuReload: "새로 고침",
|
||||||
|
menuDeveloperTools: "개발자 도구",
|
||||||
|
menuActualSize: "실제 크기",
|
||||||
|
menuZoomIn: "확대",
|
||||||
|
menuZoomOut: "축소",
|
||||||
|
menuFullscreen: "전체 화면",
|
||||||
|
loadingTitle: "Vibe-Trading Desktop 시작 중",
|
||||||
|
loadingMessage: "로컬 서비스를 준비하고 있습니다. 처음 시작할 때는 잠시 시간이 걸릴 수 있습니다.",
|
||||||
|
loadingRetry: "다시 시도",
|
||||||
|
loadingOpenLogs: "로그 폴더 열기",
|
||||||
|
loadingFailedTitle: "시작 실패",
|
||||||
|
loadingRestartingTitle: "다시 시작하는 중",
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
startupFailureTitle: "تعذّر بدء Vibe-Trading Desktop",
|
||||||
|
backendAlreadyRunning: "عملية الواجهة الخلفية قيد التشغيل بالفعل.",
|
||||||
|
backendUnexpectedExit: "توقفت الواجهة الخلفية لـ Vibe-Trading بشكل غير متوقع (الرمز {code}).",
|
||||||
|
backendStarting: "بدأت الواجهة الخلفية؛ جارٍ انتظار فحص السلامة · {port}",
|
||||||
|
backendReady: "الخدمة المحلية جاهزة · {port}",
|
||||||
|
backendNotStarted: "لم تبدأ الواجهة الخلفية بعد.",
|
||||||
|
backendExitedEarly: "توقفت الواجهة الخلفية لـ Vibe-Trading قبل أن تصبح جاهزة (الرمز {code}).\n{details}",
|
||||||
|
backendHealthTimeout: "انتهت مهلة انتظار جاهزية الواجهة الخلفية لـ Vibe-Trading.\n{details}",
|
||||||
|
backendNotFound: "تعذّر العثور على vibe-trading.exe. ثبّت الواجهة الخلفية أولاً أو اضبط VIBE_TRADING_EXECUTABLE.",
|
||||||
|
portUnavailable: "تعذّر تخصيص منفذ محلي.",
|
||||||
|
closingService: "جارٍ إغلاق الخدمة المحلية…",
|
||||||
|
menuApplication: "التطبيق",
|
||||||
|
menuRestartService: "إعادة تشغيل الخدمة المحلية",
|
||||||
|
menuOpenLogs: "فتح مجلد السجلات",
|
||||||
|
menuQuit: "إنهاء",
|
||||||
|
menuView: "عرض",
|
||||||
|
menuReload: "إعادة تحميل",
|
||||||
|
menuDeveloperTools: "أدوات المطور",
|
||||||
|
menuActualSize: "الحجم الفعلي",
|
||||||
|
menuZoomIn: "تكبير",
|
||||||
|
menuZoomOut: "تصغير",
|
||||||
|
menuFullscreen: "ملء الشاشة",
|
||||||
|
loadingTitle: "جارٍ بدء Vibe-Trading Desktop",
|
||||||
|
loadingMessage: "يُجهّز تطبيق سطح المكتب الخدمة المحلية. قد يستغرق التشغيل الأول بعض الوقت.",
|
||||||
|
loadingRetry: "إعادة المحاولة",
|
||||||
|
loadingOpenLogs: "فتح مجلد السجلات",
|
||||||
|
loadingFailedTitle: "فشل بدء التشغيل",
|
||||||
|
loadingRestartingTitle: "جارٍ إعادة التشغيل",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveDesktopLocale(rawLocale: string | undefined): DesktopLocale {
|
||||||
|
const normalized = (rawLocale ?? "").trim().toLowerCase().replaceAll("_", "-");
|
||||||
|
if (normalized === "zh" || normalized.startsWith("zh-cn") || normalized.startsWith("zh-hans")) {
|
||||||
|
return "zh-CN";
|
||||||
|
}
|
||||||
|
if (normalized === "ja" || normalized.startsWith("ja-")) return "ja";
|
||||||
|
if (normalized === "ko" || normalized.startsWith("ko-")) return "ko";
|
||||||
|
if (normalized === "ar" || normalized.startsWith("ar-")) return "ar";
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesktopMessages(locale: DesktopLocale): DesktopMessages {
|
||||||
|
return messages[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRendererLocale(locale: DesktopLocale): DesktopRendererLocale {
|
||||||
|
const localeMessages = getDesktopMessages(locale);
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
direction: locale === "ar" ? "rtl" : "ltr",
|
||||||
|
messages: {
|
||||||
|
loadingTitle: localeMessages.loadingTitle,
|
||||||
|
loadingMessage: localeMessages.loadingMessage,
|
||||||
|
loadingRetry: localeMessages.loadingRetry,
|
||||||
|
loadingOpenLogs: localeMessages.loadingOpenLogs,
|
||||||
|
loadingFailedTitle: localeMessages.loadingFailedTitle,
|
||||||
|
loadingRestartingTitle: localeMessages.loadingRestartingTitle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDesktopMessage(
|
||||||
|
template: string,
|
||||||
|
values: Record<string, string | number | null | undefined>,
|
||||||
|
): string {
|
||||||
|
return template.replace(/\{([a-zA-Z0-9_]+)\}/gu, (_match, key: string) => {
|
||||||
|
const value = values[key];
|
||||||
|
return value === null || value === undefined ? "" : String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
dialog,
|
||||||
|
ipcMain,
|
||||||
|
Menu,
|
||||||
|
nativeTheme,
|
||||||
|
shell,
|
||||||
|
} from "electron";
|
||||||
|
import { BackendManager } from "./backend-manager";
|
||||||
|
import {
|
||||||
|
DesktopLocale,
|
||||||
|
getDesktopMessages,
|
||||||
|
getRendererLocale,
|
||||||
|
resolveDesktopLocale,
|
||||||
|
} from "./locales";
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | undefined;
|
||||||
|
let backend: BackendManager | undefined;
|
||||||
|
let bootPromise: Promise<void> | undefined;
|
||||||
|
let quitting = false;
|
||||||
|
let desktopLocale: DesktopLocale = "en";
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (testUserData) app.setPath("userData", path.resolve(testUserData));
|
||||||
|
app.setName(productName);
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on("second-instance", () => {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
});
|
||||||
|
void app.whenReady().then(ready).catch((error) => {
|
||||||
|
dialog.showErrorBox(desktopMessages.startupFailureTitle, errorText(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready(): Promise<void> {
|
||||||
|
desktopLocale = resolveDesktopLocale(
|
||||||
|
process.env.VIBE_TRADING_DESKTOP_LOCALE ?? app.getLocale(),
|
||||||
|
);
|
||||||
|
desktopMessages = getDesktopMessages(desktopLocale);
|
||||||
|
nativeTheme.themeSource = "dark";
|
||||||
|
app.setAppLogsPath();
|
||||||
|
createWindow();
|
||||||
|
registerIpc();
|
||||||
|
createMenu();
|
||||||
|
await showLoadingPage();
|
||||||
|
void boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
const parentDeathSmoke = Boolean(process.env.VIBE_TRADING_DESKTOP_PARENT_DEATH_SMOKE_FILE);
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 820,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 640,
|
||||||
|
show: false,
|
||||||
|
backgroundColor: "#0b0f14",
|
||||||
|
title: productName,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
devTools: !app.isPackaged,
|
||||||
|
partition: "persist:vibe-trading-desktop",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rendererSession = mainWindow.webContents.session;
|
||||||
|
rendererSession.setPermissionCheckHandler(() => false);
|
||||||
|
rendererSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||||
|
rendererSession.webRequest.onBeforeSendHeaders(
|
||||||
|
{ urls: ["http://127.0.0.1/*"] },
|
||||||
|
(details, callback) => {
|
||||||
|
const backendOrigin = backend?.url ? new URL(backend.url).origin : undefined;
|
||||||
|
const requestOrigin = safeOrigin(details.url);
|
||||||
|
if (backendOrigin && requestOrigin === backendOrigin) {
|
||||||
|
details.requestHeaders.Authorization = `Bearer ${apiAuthKey}`;
|
||||||
|
}
|
||||||
|
callback({ requestHeaders: details.requestHeaders });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!parentDeathSmoke) {
|
||||||
|
mainWindow.once("ready-to-show", () => mainWindow?.show());
|
||||||
|
}
|
||||||
|
mainWindow.on("close", (event) => {
|
||||||
|
if (quitting) return;
|
||||||
|
event.preventDefault();
|
||||||
|
void shutdownAndQuit();
|
||||||
|
});
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (isSafeExternalUrl(url)) void shell.openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||||
|
const currentOrigin = backend?.url ? new URL(backend.url).origin : undefined;
|
||||||
|
if (currentOrigin && safeOrigin(url) === currentOrigin) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (isSafeExternalUrl(url)) void shell.openExternal(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpc(): void {
|
||||||
|
ipcMain.on("desktop:retry", (event) => {
|
||||||
|
assertMainWindowSender(event.sender);
|
||||||
|
void boot();
|
||||||
|
});
|
||||||
|
ipcMain.on("desktop:open-logs", (event) => {
|
||||||
|
assertMainWindowSender(event.sender);
|
||||||
|
void shell.openPath(app.getPath("logs"));
|
||||||
|
});
|
||||||
|
ipcMain.handle("desktop:restart-backend", async (event) => {
|
||||||
|
assertMainWindowSender(event.sender);
|
||||||
|
await boot();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showLoadingPage(): Promise<void> {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
const localePayload = Buffer.from(
|
||||||
|
JSON.stringify(getRendererLocale(desktopLocale)),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64url");
|
||||||
|
await mainWindow.loadFile(path.join(__dirname, "loading.html"), {
|
||||||
|
query: { locale: localePayload },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot(): Promise<void> {
|
||||||
|
if (bootPromise) return bootPromise;
|
||||||
|
bootPromise = bootInternal().finally(() => { bootPromise = undefined; });
|
||||||
|
return bootPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootInternal(): Promise<void> {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
await backend?.stop();
|
||||||
|
if (!mainWindow.webContents.getURL().startsWith("file:")) await showLoadingPage();
|
||||||
|
|
||||||
|
backend = new BackendManager({
|
||||||
|
appPath: app.getAppPath(),
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
allowSourceDiscovery: !app.isPackaged,
|
||||||
|
logDirectory: app.getPath("logs"),
|
||||||
|
apiAuthKey,
|
||||||
|
messages: desktopMessages,
|
||||||
|
onStatus: (message) => mainWindow?.webContents.send("desktop:status", message),
|
||||||
|
onUnexpectedExit: (message) => {
|
||||||
|
if (!quitting) void reportBootError(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await backend.start();
|
||||||
|
writeParentDeathSmokeRecord(url);
|
||||||
|
await mainWindow.loadURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
await backend.stop();
|
||||||
|
await reportBootError(errorText(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeParentDeathSmokeRecord(url: string): void {
|
||||||
|
const target = process.env.VIBE_TRADING_DESKTOP_PARENT_DEATH_SMOKE_FILE;
|
||||||
|
if (!target) return;
|
||||||
|
const backendPid = backend?.processId;
|
||||||
|
if (!backendPid) throw new Error("Parent-death smoke test could not resolve the backend PID");
|
||||||
|
writeFileSync(path.resolve(target), JSON.stringify({
|
||||||
|
mainPid: process.pid,
|
||||||
|
backendPid,
|
||||||
|
backendOrigin: new URL(url).origin,
|
||||||
|
}), { encoding: "utf8", flag: "wx" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportBootError(message: string): Promise<void> {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (!mainWindow.webContents.getURL().startsWith("file:")) await showLoadingPage();
|
||||||
|
mainWindow.webContents.send("desktop:error", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMenu(): void {
|
||||||
|
Menu.setApplicationMenu(Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: desktopMessages.menuApplication,
|
||||||
|
submenu: [
|
||||||
|
{ label: desktopMessages.menuRestartService, click: () => void boot() },
|
||||||
|
{ label: desktopMessages.menuOpenLogs, click: () => void shell.openPath(app.getPath("logs")) },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "quit", label: desktopMessages.menuQuit },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: desktopMessages.menuView,
|
||||||
|
submenu: [
|
||||||
|
{ role: "reload", label: desktopMessages.menuReload },
|
||||||
|
...(!app.isPackaged
|
||||||
|
? [{ role: "toggleDevTools" as const, label: desktopMessages.menuDeveloperTools }]
|
||||||
|
: []),
|
||||||
|
{ type: "separator" },
|
||||||
|
{ role: "resetZoom", label: desktopMessages.menuActualSize },
|
||||||
|
{ role: "zoomIn", label: desktopMessages.menuZoomIn },
|
||||||
|
{ role: "zoomOut", label: desktopMessages.menuZoomOut },
|
||||||
|
{ role: "togglefullscreen", label: desktopMessages.menuFullscreen },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMainWindowSender(sender: Electron.WebContents): void {
|
||||||
|
if (sender !== mainWindow?.webContents) throw new Error("Desktop request rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdownAndQuit(): Promise<void> {
|
||||||
|
if (quitting) return;
|
||||||
|
quitting = true;
|
||||||
|
mainWindow?.webContents.send("desktop:status", desktopMessages.closingService);
|
||||||
|
await backend?.stop();
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeExternalUrl(rawUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
const protocol = new URL(rawUrl).protocol;
|
||||||
|
return protocol === "https:" || protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeOrigin(rawUrl: string): string | undefined {
|
||||||
|
try {
|
||||||
|
return new URL(rawUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (process.platform !== "darwin") void shutdownAndQuit();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
dialog.showErrorBox(productName, error.stack ?? error.message);
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("vibeDesktop", {
|
||||||
|
isDesktop: true,
|
||||||
|
onStatus: (callback: (message: string) => void) => {
|
||||||
|
ipcRenderer.on("desktop:status", (_event, message: string) => callback(message));
|
||||||
|
},
|
||||||
|
onError: (callback: (message: string) => void) => {
|
||||||
|
ipcRenderer.on("desktop:error", (_event, message: string) => callback(message));
|
||||||
|
},
|
||||||
|
retry: () => ipcRenderer.send("desktop:retry"),
|
||||||
|
openLogs: () => ipcRenderer.send("desktop:open-logs"),
|
||||||
|
restartBackend: () => ipcRenderer.invoke("desktop:restart-backend") as Promise<boolean>,
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user