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_bash_tool.py
|
||||
-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
|
||||
|
||||
# Desktop shell build outputs
|
||||
desktop/electron/node_modules/
|
||||
desktop/electron/dist/
|
||||
|
||||
# Alpha Zoo wiki artifacts (generated, not source)
|
||||
wiki/alpha-library/manifest.json
|
||||
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