forked from imbytecat/fullstack-starter
refactor(desktop): 替换 Electrobun 为 WebUI 作为桌面窗口方案
Electrobun 太不稳定,改用 webui-dev/webui(轻量 C 库,~300KB)通过 系统浏览器或 WebView 提供桌面窗口。已验证 bun:ffi 加载和 bun build --compile 均正常工作。 - 移除 electrobun 依赖和配置 - 添加 @webui-dev/bun-webui 依赖 - 重写桌面入口为 WebUI 窗口方案 - 移除 Conveyor 打包工具(mise.toml)
This commit is contained in:
7
apps/desktop/.gitignore
vendored
7
apps/desktop/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
# Electrobun build output
|
||||
build/
|
||||
artifacts/
|
||||
# WebUI native libraries (auto-downloaded)
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
# AGENTS.md - Desktop App Guidelines
|
||||
|
||||
Thin Electrobun shell hosting the fullstack server app.
|
||||
Thin WebUI shell hosting the fullstack server app.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Never use `npm`, `npx`, or `node`.**
|
||||
|
||||
- **Type**: Electrobun desktop application
|
||||
- **Design**: Server-driven desktop (thin native shell hosting web app)
|
||||
- **Runtime**: Bun (Main process) + CEF (Chromium Embedded Framework)
|
||||
- **Framework**: Electrobun
|
||||
- **Build**: Electrobun CLI + Turborepo
|
||||
- **Type**: WebUI desktop shell
|
||||
- **Design**: Server-driven desktop (thin native window hosting web app)
|
||||
- **Runtime**: Bun (Main process) + System browser / WebView (via WebUI)
|
||||
- **Window Library**: [webui-dev/webui](https://github.com/webui-dev/webui) — opens installed browser or native WebView as GUI
|
||||
- **Build**: Turborepo
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it merely hosts the `apps/server` TanStack Start application in a native window.
|
||||
- **Dev mode**: Connects to an external Vite dev server at `localhost:3000`. Requires `apps/server` to be running separately.
|
||||
- **Prod mode**: Spawns an embedded TanStack Start server (Nitro) as a child process and loads the dynamically assigned local URL.
|
||||
- **Config**: `electrobun.config.ts` manages app metadata (identifier, name), build entries, and asset bundling.
|
||||
- **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it opens a browser window pointing to the `apps/server` TanStack Start application.
|
||||
- **Dev mode**: Waits for the Vite dev server at `localhost:3000`, then opens a WebUI window. Requires `apps/server` to be running separately.
|
||||
- **WebUI**: Uses `@webui-dev/bun-webui` (FFI-based C library binding). Auto-detects and opens the best available browser in a private profile, or uses the system WebView.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun dev # Start Electrobun dev mode (requires server dev running)
|
||||
|
||||
# Build
|
||||
bun build # Build stable release (all platforms)
|
||||
|
||||
# Code Quality
|
||||
bun dev # Open WebUI window (requires server dev running)
|
||||
bun fix # Biome auto-fix
|
||||
bun typecheck # TypeScript check
|
||||
```
|
||||
@@ -39,10 +32,9 @@ bun typecheck # TypeScript check
|
||||
.
|
||||
├── src/
|
||||
│ └── bun/
|
||||
│ └── index.ts # Main process entry (Window management + server lifecycle)
|
||||
├── electrobun.config.ts # App metadata and build/copy configuration
|
||||
│ └── index.ts # Main process entry (server readiness check + WebUI window)
|
||||
├── package.json # Scripts and dependencies
|
||||
├── turbo.json # Build pipeline dependencies (depends on server build)
|
||||
├── turbo.json # Dev pipeline (depends on server dev)
|
||||
└── AGENTS.md # Desktop guidelines (this file)
|
||||
```
|
||||
|
||||
@@ -50,82 +42,16 @@ bun typecheck # TypeScript check
|
||||
|
||||
1. **Start server**: In `apps/server`, run `bun dev`.
|
||||
2. **Start desktop**: In `apps/desktop`, run `bun dev`.
|
||||
3. **Connection**: The desktop app polls `localhost:3000` until responsive, then opens the native window.
|
||||
|
||||
## Production Architecture
|
||||
|
||||
### Build Pipeline
|
||||
The desktop build is orchestrated by Turbo. It depends on the server's production build:
|
||||
- `turbo.json`: `@furtherverse/desktop#build` depends on `@furtherverse/server#build`.
|
||||
- `electrobun.config.ts`: Copies `../server/.output` to `server-output` folder within the app bundle.
|
||||
|
||||
### Server Lifecycle
|
||||
In production, the main process manages the embedded server:
|
||||
- **Spawn**: Spawns server from `server-output/server/index.mjs` using `Bun.spawn`.
|
||||
- **Port Allocation**: A free port is pre-allocated via `node:net` (`createServer` on `127.0.0.1:0`), then passed to the server as the `PORT` environment variable.
|
||||
- **Readiness Check**: The main process polls the server URL with `fetch` until it responds, rather than parsing stdout.
|
||||
- **Retry**: If the server fails to become ready (timeout or early exit), the process is killed and a new attempt is made with a fresh port (up to 3 retries).
|
||||
- **Lifecycle**: The server process is tied to the app; it is killed on `SIGTERM`, `SIGINT`, or app exit. If the server process crashes, the app exits with an error.
|
||||
|
||||
## Environment Detection
|
||||
|
||||
The application determines its environment via the `ELECTROBUN_BUILD_ENV` variable, automatically set by the Electrobun CLI:
|
||||
|
||||
```typescript
|
||||
const isDev = () => {
|
||||
const env = process.env.ELECTROBUN_BUILD_ENV
|
||||
return !env || env === 'dev'
|
||||
}
|
||||
```
|
||||
|
||||
- **dev**: Development mode (connects to external host).
|
||||
- **canary / stable**: Production mode (starts embedded server).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ELECTROBUN_BUILD_ENV`: Auto-set by CLI. Determines whether to use local dev server or embedded server.
|
||||
- `DATABASE_URL`: Required by the server process. Must be passed through from the parent environment to the spawned child process.
|
||||
|
||||
## Electrobun Patterns
|
||||
|
||||
### BrowserWindow Configuration
|
||||
The main window uses the CEF renderer for consistency across platforms.
|
||||
|
||||
```typescript
|
||||
new BrowserWindow({
|
||||
title: 'Furtherverse',
|
||||
url: serverUrl,
|
||||
frame: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
},
|
||||
renderer: 'cef',
|
||||
})
|
||||
```
|
||||
|
||||
### Path Aliases
|
||||
The main process uses `electrobun/bun` for native APIs and `PATHS` for locating bundled assets.
|
||||
|
||||
```typescript
|
||||
import { BrowserWindow, PATHS } from 'electrobun/bun'
|
||||
|
||||
// Locate the embedded server bundle
|
||||
const serverEntryPath = join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
|
||||
```
|
||||
3. **Connection**: The desktop app polls `localhost:3000` until responsive, then opens the browser window.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
**DO:**
|
||||
- Use arrow functions for all components and utility functions.
|
||||
- Ensure `apps/server` is built before building `apps/desktop` (handled by Turbo).
|
||||
- Pre-allocate a free port and pass it via `PORT` env var instead of parsing stdout.
|
||||
- Handle server process termination to avoid orphan processes.
|
||||
- Use arrow functions for all utility functions.
|
||||
- Ensure `apps/server` dev server is running before starting desktop.
|
||||
- Use `catalog:` for all dependency versions in `package.json`.
|
||||
|
||||
**DON'T:**
|
||||
- Use `npm`, `npx`, `node`, `yarn`, or `pnpm`. Always use `bun`.
|
||||
- Hardcode `localhost:3000` for production builds.
|
||||
- Include UI components or business logic in the desktop app (keep it thin).
|
||||
- Use `as any` or `@ts-ignore`.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ElectrobunConfig } from 'electrobun'
|
||||
import { version } from './package.json'
|
||||
|
||||
export default {
|
||||
app: {
|
||||
name: 'Desktop',
|
||||
identifier: 'com.furtherverse.desktop',
|
||||
version,
|
||||
},
|
||||
build: {
|
||||
useAsar: true,
|
||||
bun: {
|
||||
entrypoint: 'src/bun/index.ts',
|
||||
},
|
||||
copy: {
|
||||
'../server/.output': 'server-output',
|
||||
},
|
||||
linux: {
|
||||
bundleCEF: true,
|
||||
},
|
||||
mac: {
|
||||
bundleCEF: true,
|
||||
},
|
||||
win: {
|
||||
bundleCEF: true,
|
||||
},
|
||||
},
|
||||
} satisfies ElectrobunConfig
|
||||
@@ -4,13 +4,12 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "electrobun build --env=stable --targets=win-x64",
|
||||
"dev": "electrobun build --env=dev && electrobun dev",
|
||||
"dev": "bun src/bun/index.ts",
|
||||
"fix": "biome check --write",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"electrobun": "catalog:"
|
||||
"@webui-dev/bun-webui": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@furtherverse/tsconfig": "workspace:*",
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
import { createServer } from 'node:net'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { BrowserWindow, PATHS } from 'electrobun/bun'
|
||||
import { WebUI } from '@webui-dev/bun-webui'
|
||||
|
||||
const DEV_SERVER_URL = 'http://localhost:3000'
|
||||
const SERVER_READY_TIMEOUT_MS = 5_000
|
||||
const MAX_SPAWN_RETRIES = 3
|
||||
const SERVER_READY_TIMEOUT_MS = 10_000
|
||||
|
||||
const isDev = (): boolean => {
|
||||
const env = process.env.ELECTROBUN_BUILD_ENV
|
||||
return !env || env === 'dev'
|
||||
}
|
||||
|
||||
const getServerEntryPath = (): string => {
|
||||
return join(PATHS.VIEWS_FOLDER, '..', 'server-output', 'server', 'index.mjs')
|
||||
}
|
||||
|
||||
const getFreePort = (): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = createServer()
|
||||
srv.unref()
|
||||
srv.once('error', reject)
|
||||
srv.listen({ port: 0, host: '127.0.0.1', exclusive: true }, () => {
|
||||
const addr = srv.address()
|
||||
if (addr && typeof addr === 'object') {
|
||||
const port = addr.port
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)))
|
||||
} else {
|
||||
srv.close(() => reject(new Error('Unexpected address() result')))
|
||||
}
|
||||
})
|
||||
})
|
||||
const isServerReady = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const waitForServer = async (
|
||||
@@ -38,126 +18,35 @@ const waitForServer = async (
|
||||
): Promise<boolean> => {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (response.ok) return true
|
||||
} catch (_) {
|
||||
// Server not up yet, retry after sleep
|
||||
}
|
||||
if (await isServerReady(url)) return true
|
||||
await Bun.sleep(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const spawnServer = async (): Promise<{
|
||||
process: ReturnType<typeof Bun.spawn>
|
||||
url: string
|
||||
}> => {
|
||||
const serverEntryPath = getServerEntryPath()
|
||||
const serverDir = dirname(serverEntryPath)
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_SPAWN_RETRIES; attempt++) {
|
||||
const port = await getFreePort()
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
const serverProc = Bun.spawn([process.execPath, serverEntryPath], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
HOST: '127.0.0.1',
|
||||
},
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
})
|
||||
|
||||
const ready = await Promise.race([
|
||||
waitForServer(url),
|
||||
serverProc.exited.then((code) => {
|
||||
throw new Error(`Server exited with code ${code} before becoming ready`)
|
||||
}),
|
||||
])
|
||||
|
||||
if (ready) {
|
||||
return { process: serverProc, url }
|
||||
}
|
||||
|
||||
serverProc.kill()
|
||||
await serverProc.exited
|
||||
console.warn(
|
||||
`Server failed to become ready on port ${port} (attempt ${attempt}/${MAX_SPAWN_RETRIES})`,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(`Server failed to start after ${MAX_SPAWN_RETRIES} attempts`)
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
console.log('Starting Furtherverse Desktop...')
|
||||
|
||||
let serverUrl: string
|
||||
let serverProcess: ReturnType<typeof Bun.spawn> | null = null
|
||||
|
||||
if (isDev()) {
|
||||
console.log('Dev mode: waiting for external server at', DEV_SERVER_URL)
|
||||
const ready = await waitForServer(DEV_SERVER_URL)
|
||||
if (!ready) {
|
||||
console.error(
|
||||
'Dev server not responding. Make sure to run: bun dev in apps/server',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('Dev server ready!')
|
||||
serverUrl = DEV_SERVER_URL
|
||||
} else {
|
||||
console.log('Production mode: starting embedded server...')
|
||||
try {
|
||||
const server = await spawnServer()
|
||||
serverProcess = server.process
|
||||
serverUrl = server.url
|
||||
console.log('Server ready at', serverUrl)
|
||||
} catch (err) {
|
||||
console.error('Failed to start embedded server:', err)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('Waiting for server at', DEV_SERVER_URL)
|
||||
const ready = await waitForServer(DEV_SERVER_URL)
|
||||
if (!ready) {
|
||||
console.error(
|
||||
'Dev server not responding. Run `bun dev` in apps/server first.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
new BrowserWindow({
|
||||
title: 'Furtherverse',
|
||||
url: serverUrl,
|
||||
frame: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
},
|
||||
renderer: 'cef',
|
||||
})
|
||||
console.log('Opening window at', DEV_SERVER_URL)
|
||||
|
||||
if (serverProcess) {
|
||||
const cleanup = () => {
|
||||
if (serverProcess) {
|
||||
serverProcess.kill()
|
||||
serverProcess = null
|
||||
}
|
||||
}
|
||||
const win = new WebUI()
|
||||
win.setSize(1200, 800)
|
||||
win.setCenter()
|
||||
await win.show(DEV_SERVER_URL)
|
||||
|
||||
process.on('exit', cleanup)
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
process.on('SIGINT', () => {
|
||||
cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
console.log('Window opened. Waiting for close...')
|
||||
await WebUI.wait()
|
||||
|
||||
serverProcess.exited.then((code) => {
|
||||
if (serverProcess) {
|
||||
console.error(`Server exited unexpectedly with code ${code}`)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"$schema": "../../node_modules/turbo/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["@furtherverse/server#build"],
|
||||
"outputs": ["build/**", "artifacts/**"]
|
||||
}
|
||||
}
|
||||
"tasks": {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user