refactor(desktop): 替换 WebUI 为 Electron + electron-vite 桌面壳方案

- 使用 electron-vite 构建 main/preload,electron-builder 打包分发
- main process: dev 模式直连 localhost:3000,生产模式 spawn sidecar binary
- 添加 loading 页面,server 就绪前显示加载动画
- 更新 catalog 依赖: electron, electron-vite, electron-builder
- 移除 @webui-dev/bun-webui 依赖
This commit is contained in:
2026-02-08 18:16:13 +08:00
parent e8e473b357
commit 7318600e20
13 changed files with 961 additions and 110 deletions

View File

@@ -11,7 +11,7 @@ Guidelines for AI agents working in this Bun monorepo.
- **Package Manager**: Bun — **NOT npm / yarn / pnpm** - **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Apps**: - **Apps**:
- `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`) - `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
- `apps/desktop` - Electrobun desktop shell, loads server in native window (see `apps/desktop/AGENTS.md`) - `apps/desktop` - Electron desktop shell, sidecar server pattern (see `apps/desktop/AGENTS.md`)
- **Packages**: `packages/utils`, `packages/tsconfig` (shared configs) - **Packages**: `packages/utils`, `packages/tsconfig` (shared configs)
## Build / Lint / Test Commands ## Build / Lint / Test Commands
@@ -41,9 +41,11 @@ bun db:studio # Open Drizzle Studio
### Desktop App (`apps/desktop`) ### Desktop App (`apps/desktop`)
```bash ```bash
bun dev # Start Electrobun dev mode (requires server dev running) bun dev # electron-vite dev mode (requires server dev running)
bun build # Build canary release bun build # electron-vite build (main + preload)
bun build:stable # Build stable release bun build:win # Build + package for Windows
bun build:mac # Build + package for macOS
bun build:linux # Build + package for Linux
bun fix # Biome auto-fix bun fix # Biome auto-fix
bun typecheck # TypeScript check bun typecheck # TypeScript check
``` ```
@@ -158,11 +160,14 @@ export const myTable = pgTable('my_table', {
│ │ │ ├── api/ # ORPC contracts, routers, middlewares │ │ │ ├── api/ # ORPC contracts, routers, middlewares
│ │ │ └── db/ # Drizzle schema │ │ │ └── db/ # Drizzle schema
│ │ └── AGENTS.md │ │ └── AGENTS.md
│ └── desktop/ # Electrobun desktop shell │ └── desktop/ # Electron desktop shell
│ ├── src/ │ ├── src/
│ │ ── bun/ │ │ ── main/
│ │ └── index.ts # Main process entry │ │ └── index.ts # Main process entry
├── electrobun.config.ts # Electrobun configuration │ └── preload/
│ │ └── index.ts # Preload script
│ ├── electron.vite.config.ts
│ ├── electron-builder.yml # Packaging config
│ └── AGENTS.md │ └── AGENTS.md
├── packages/ ├── packages/
│ ├── tsconfig/ # Shared TS configs │ ├── tsconfig/ # Shared TS configs
@@ -175,4 +180,4 @@ export const myTable = pgTable('my_table', {
## See Also ## See Also
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns - `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns
- `apps/desktop/AGENTS.md` - Electrobun desktop development guide - `apps/desktop/AGENTS.md` - Electron desktop development guide

View File

@@ -1,4 +1,7 @@
# WebUI native libraries (auto-downloaded) # Electron-vite build output
*.dll out/
*.so dist/
*.dylib
# Sidecar binary (copied from apps/server build)
resources/server
resources/server.exe

View File

@@ -1,27 +1,32 @@
# AGENTS.md - Desktop App Guidelines # AGENTS.md - Desktop App Guidelines
Thin WebUI shell hosting the fullstack server app. Thin Electron shell hosting the fullstack server app.
## Tech Stack ## Tech Stack
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Never use `npm`, `npx`, or `node`.** > **⚠️ This project uses Bun as the package manager. Runtime is Electron (Node.js). Never use `npm`, `npx`, `yarn`, or `pnpm`.**
- **Type**: WebUI desktop shell - **Type**: Electron desktop shell
- **Design**: Server-driven desktop (thin native window hosting web app) - **Design**: Server-driven desktop (thin native window hosting web app)
- **Runtime**: Bun (Main process) + System browser / WebView (via WebUI) - **Runtime**: Electron (Main/Renderer) + Sidecar server binary (Bun-compiled)
- **Window Library**: [webui-dev/webui](https://github.com/webui-dev/webui) — opens installed browser or native WebView as GUI - **Build Tool**: electron-vite (Vite-based, handles main + preload builds)
- **Build**: Turborepo - **Packager**: electron-builder (installers, signing, auto-update)
- **Orchestration**: Turborepo
## Architecture ## Architecture
- **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. - **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it opens a BrowserWindow 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. - **Dev mode**: Opens a BrowserWindow pointing to `localhost:3000`. Requires `apps/server` to be running separately (Turbo handles this).
- **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. - **Production mode**: Spawns a compiled server binary (from `resources/`) as a sidecar process, waits for readiness, then loads its URL.
## Commands ## Commands
```bash ```bash
bun dev # Open WebUI window (requires server dev running) bun dev # electron-vite dev (requires server dev running)
bun build # electron-vite build (main + preload)
bun build:win # Build + package for Windows
bun build:mac # Build + package for macOS
bun build:linux # Build + package for Linux
bun fix # Biome auto-fix bun fix # Biome auto-fix
bun typecheck # TypeScript check bun typecheck # TypeScript check
``` ```
@@ -31,27 +36,40 @@ bun typecheck # TypeScript check
``` ```
. .
├── src/ ├── src/
── bun/ ── main/
└── index.ts # Main process entry (server readiness check + WebUI window) └── index.ts # Main process (server lifecycle + BrowserWindow)
├── package.json # Scripts and dependencies │ └── preload/
├── turbo.json # Dev pipeline (depends on server dev) │ └── index.ts # Preload script (security isolation)
── AGENTS.md # Desktop guidelines (this file) ── resources/ # Sidecar binaries (gitignored, copied from server build)
├── out/ # electron-vite build output (gitignored)
├── electron.vite.config.ts
├── electron-builder.yml # Packaging configuration
├── package.json
├── turbo.json
└── AGENTS.md
``` ```
## Development Workflow ## Development Workflow
1. **Start server**: In `apps/server`, run `bun dev`. 1. **Start server**: `bun dev` in `apps/server` (or use root `bun dev` via Turbo).
2. **Start desktop**: In `apps/desktop`, run `bun dev`. 2. **Start desktop**: `bun dev` in `apps/desktop`.
3. **Connection**: The desktop app polls `localhost:3000` until responsive, then opens the browser window. 3. **Connection**: Main process polls `localhost:3000` until responsive, then opens BrowserWindow.
## Production Build Workflow
1. **Build server**: `bun build` in `apps/server``.output/`
2. **Compile server**: `bun compile` in `apps/server``out/server-{platform}`
3. **Copy binary**: Copy the platform-appropriate binary to `apps/desktop/resources/server`
4. **Package desktop**: `bun build:linux` (or `:mac` / `:win`) in `apps/desktop`
## Critical Rules ## Critical Rules
**DO:** **DO:**
- Use arrow functions for all utility functions. - Use arrow functions for all utility functions.
- Ensure `apps/server` dev server is running before starting desktop. - Keep the desktop app as a thin shell — no UI or business logic.
- Use `catalog:` for all dependency versions in `package.json`. - Use `catalog:` for all dependency versions in `package.json`.
**DON'T:** **DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, or `pnpm`. Always use `bun`. - Use `npm`, `npx`, `yarn`, or `pnpm`. Use `bun` for package management.
- Include UI components or business logic in the desktop app (keep it thin). - Include UI components or business logic in the desktop app.
- Use `as any` or `@ts-ignore`. - Use `as any` or `@ts-ignore`.

View File

@@ -0,0 +1,38 @@
appId: com.furtherverse.app
productName: Furtherverse
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.env,.env.*,bun.lock}'
- '!{tsconfig.json,tsconfig.node.json}'
- '!{AGENTS.md,README.md,CHANGELOG.md}'
extraResources:
- from: resources/
to: .
filter:
- '**/*'
asarUnpack:
- resources/**
win:
executableName: Furtherverse
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
category: public.app-category.productivity
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- deb
maintainer: furtherverse.com
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'electron-vite'
export default defineConfig({
main: {},
preload: {},
renderer: {},
})

View File

@@ -3,16 +3,21 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "out/main/index.js",
"scripts": { "scripts": {
"dev": "bun src/bun/index.ts", "dev": "electron-vite dev",
"build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win --config",
"build:mac": "electron-vite build && electron-builder --mac --config",
"build:linux": "electron-vite build && electron-builder --linux --config",
"fix": "biome check --write", "fix": "biome check --write",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": {
"@webui-dev/bun-webui": "catalog:"
},
"devDependencies": { "devDependencies": {
"@furtherverse/tsconfig": "workspace:*", "@furtherverse/tsconfig": "workspace:*",
"@types/bun": "catalog:" "@types/node": "catalog:",
"electron": "catalog:",
"electron-builder": "catalog:",
"electron-vite": "catalog:"
} }
} }

View File

@@ -1,55 +0,0 @@
import { WebUI } from '@webui-dev/bun-webui'
const DEV_SERVER_URL = 'http://localhost:3000'
const SERVER_READY_TIMEOUT_MS = 10_000
const isServerReady = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}
const waitForServer = async (
url: string,
timeoutMs = SERVER_READY_TIMEOUT_MS,
): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await isServerReady(url)) return true
await Bun.sleep(100)
}
return false
}
const main = async () => {
console.log('Starting Furtherverse Desktop...')
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)
}
console.log('Opening window at', DEV_SERVER_URL)
const win = new WebUI()
win.setSize(1200, 800)
win.setCenter()
await win.show(DEV_SERVER_URL)
console.log('Window opened. Waiting for close...')
await WebUI.wait()
process.exit(0)
}
main().catch((error) => {
console.error('Failed to start:', error)
process.exit(1)
})

View File

@@ -0,0 +1,122 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { app, BrowserWindow, shell } from 'electron'
const DEV_SERVER_URL = 'http://localhost:3000'
const PROD_SERVER_PORT = 23_410
let mainWindow: BrowserWindow | null = null
let serverProcess: ReturnType<typeof spawn> | null = null
const isServerReady = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}
const waitForServer = async (
url: string,
timeoutMs = 15_000,
): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (await isServerReady(url)) return true
await new Promise<void>((resolve) => setTimeout(resolve, 200))
}
return false
}
const spawnServer = (): string => {
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
const binaryPath = join(process.resourcesPath, binaryName)
serverProcess = spawn(binaryPath, [], {
env: {
...process.env,
PORT: String(PROD_SERVER_PORT),
HOST: '127.0.0.1',
},
stdio: 'pipe',
})
serverProcess.stdout?.on('data', (data: Buffer) => {
console.log(`[server] ${data.toString().trim()}`)
})
serverProcess.stderr?.on('data', (data: Buffer) => {
console.error(`[server] ${data.toString().trim()}`)
})
serverProcess.on('error', (err) => {
console.error('Failed to start server:', err)
})
return `http://127.0.0.1:${PROD_SERVER_PORT}`
}
const getServerUrl = (): string => {
if (!app.isPackaged) {
return DEV_SERVER_URL
}
return spawnServer()
}
const createWindow = async () => {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.mjs'),
sandbox: true,
},
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.show()
const serverUrl = getServerUrl()
console.log(`Waiting for server at ${serverUrl}...`)
const ready = await waitForServer(serverUrl)
if (!ready) {
console.error(
app.isPackaged
? 'Server binary did not start in time.'
: 'Dev server not responding. Run `bun dev` in apps/server first.',
)
app.quit()
return
}
console.log(`Loading ${serverUrl}`)
mainWindow.loadURL(serverUrl)
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
app.on('before-quit', () => {
if (serverProcess) {
serverProcess.kill()
serverProcess = null
}
})

View File

@@ -0,0 +1 @@
export {}

View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furtherverse</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #fafafa;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.loader {
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #333;
border-top-color: #fafafa;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
p { font-size: 14px; color: #888; }
</style>
</head>
<body>
<div class="loader">
<div class="spinner"></div>
<p>Starting server…</p>
</div>
</body>
</html>

View File

@@ -1,6 +1,7 @@
{ {
"extends": "@furtherverse/tsconfig/bun.json", "extends": "@furtherverse/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"] "types": ["node"]
} },
"include": ["src/main/**/*", "src/preload/**/*", "electron.vite.config.ts"]
} }

688
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,9 @@
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"@webui-dev/bun-webui": "^2.5.4", "electron": "^34.0.0",
"electron-builder": "^26.0.0",
"electron-vite": "^5.0.0",
"nitro": "npm:nitro-nightly@3.0.1-20260206-171553-bc737c0c", "nitro": "npm:nitro-nightly@3.0.1-20260206-171553-bc737c0c",
"ohash": "^2.0.11", "ohash": "^2.0.11",
"postgres": "^3.4.8", "postgres": "^3.4.8",