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:
Gao Fengkai authored and GitHub committed 2026-08-08 16:23:26 +08:00
1 parent 6c44732818
commit 832b2c9efd
19 files changed
+2197

No files matched your search

+38
View File
@@ -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
+4
View File
@@ -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/
+86
View File
@@ -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.
+114
View File
@@ -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.
+134
View File
@@ -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.
+191
View File
@@ -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"
}
}
}
+23
View File
@@ -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"
}
}
+7
View File
@@ -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);
}
+46
View File
@@ -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();
}
+420
View File
@@ -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);
}
+166
View File
@@ -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));
}
+92
View File
@@ -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>
+248
View File
@@ -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);
});
}
+261
View File
@@ -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);
});
+14
View File
@@ -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>,
});
+15
View File
@@ -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"]
}