refactor(desktop): 从 Tauri 迁移到 Electrobun

- 移除 Tauri v2 代码 (src-tauri/, copy.ts)
- 添加 Electrobun 配置和入口 (electrobun.config.ts, src/bun/index.ts)
- 更新 package.json 使用 catalog 管理 electrobun 依赖
- 移除 server 中的 @tauri-apps/api 依赖
- 更新 AGENTS.md 文档
This commit is contained in:
2026-02-07 05:04:53 +08:00
parent 9aa3b46ee5
commit 29969550ed
38 changed files with 332 additions and 6296 deletions

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,51 +1,33 @@
# AGENTS.md - Desktop App Guidelines # AGENTS.md - Desktop App Guidelines
Tauri v2 desktop shell - a lightweight wrapper that loads the server app via sidecar. Electrobun desktop shell - loads the server app in a native window.
## Architecture ## Architecture
- **Type**: Tauri v2 desktop application (shell only) - **Type**: Electrobun desktop application
- **Design**: Tauri provides native desktop APIs; all web logic handled by sidecar - **Design**: Bun main process + system webview (or CEF)
- **Sidecar**: The compiled server binary runs as a child process
- **Dev mode**: Connects to `localhost:3000` (requires server dev running) - **Dev mode**: Connects to `localhost:3000` (requires server dev running)
- **Prod mode**: Automatically starts sidecar binary - **Prod mode**: Embeds server bundle and starts local HTTP server
**This app has NO frontend src** - it loads the server app entirely.
## Commands ## Commands
```bash ```bash
# Development (from apps/desktop/) # Development (from apps/desktop/)
bun dev # Copy sidecar + start Tauri dev bun dev # Start Electrobun dev mode
# Build # Build
bun build # Copy sidecar + build Tauri installer bun build # Build canary release
bun build:stable # Build stable release
# Rust Commands (from src-tauri/)
cargo check # Compile check
cargo clippy # Linter
cargo fmt # Formatter
cargo test # Run tests
cargo test test_name -- --nocapture # Single test with output
``` ```
## Directory Structure ## Directory Structure
``` ```
apps/desktop/ apps/desktop/
├── src-tauri/ # Rust Tauri code ├── src/
── src/ ── bun/
── main.rs # Entry point (calls lib::run) ── index.ts # Electrobun main process entry
│ │ ├── lib.rs # Core app logic (plugins, commands, state) ├── electrobun.config.ts # Electrobun configuration
│ │ ├── commands/
│ │ │ └── mod.rs # Native desktop commands
│ │ └── sidecar.rs # Sidecar process management
│ ├── binaries/ # Sidecar binaries (copied from server build)
│ ├── capabilities/ # Tauri v2 permission config
│ ├── icons/ # App icons
│ ├── Cargo.toml # Rust dependencies
│ └── tauri.conf.json # Tauri configuration
├── copy.ts # Script to copy server binary to binaries/
├── package.json ├── package.json
└── tsconfig.json └── tsconfig.json
``` ```
@@ -53,119 +35,45 @@ apps/desktop/
## Development Workflow ## Development Workflow
1. **Start server dev first**: `cd ../server && bun dev` 1. **Start server dev first**: `cd ../server && bun dev`
2. **Start Tauri**: `bun dev` (from apps/desktop/) 2. **Start Electrobun**: `bun dev` (from apps/desktop/)
3. Tauri connects to localhost:3000 with HMR support 3. Electrobun connects to localhost:3000
## Rust Code Style ## Electrobun Patterns
### Formatting ### BrowserWindow
- **Indent**: 4 spaces
- **Line width**: 100 chars
- Run `cargo fmt` before commit
### Naming ```typescript
| Type | Convention | Example | import { BrowserWindow } from 'electrobun/bun'
|------|------------|---------|
| Functions/variables | snake_case | `find_available_port` |
| Types/structs/enums | PascalCase | `SidecarProcess` |
| Constants | SCREAMING_SNAKE | `DEFAULT_PORT` |
### Imports new BrowserWindow({
```rust title: 'My App',
// Order: std → external crates → internal modules (separated by blank lines) url: 'http://localhost:3000',
use std::sync::Mutex; frame: {
x: 100,
use tauri::Manager; y: 100,
use tauri_plugin_shell::ShellExt; width: 1200,
height: 800,
use crate::sidecar::SidecarProcess; },
})
``` ```
### Error Handling ### Events
```rust
// Use expect() with Chinese error messages
let sidecar = app_handle
.shell()
.sidecar("server")
.expect("无法找到 server sidecar");
// Log with emoji for clear feedback ```typescript
println!("✓ Sidecar 启动成功!"); import Electrobun from 'electrobun/bun'
eprintln!("✗ Sidecar 启动失败");
```
### Async Code Electrobun.events.on('will-quit', () => {
```rust console.log('App quitting...')
// Use Tauri's async runtime for spawning })
tauri::async_runtime::spawn(async move {
let port = find_available_port(3000).await;
// ...
});
```
## Tauri Patterns
### Command Definition
```rust
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// Register in Builder
.invoke_handler(tauri::generate_handler![commands::greet])
```
### State Management
```rust
struct SidecarProcess(Mutex<Option<CommandChild>>);
// Register state
app.manage(SidecarProcess(Mutex::new(None)));
// Access state
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
*state.0.lock().unwrap() = Some(child);
}
```
### Sidecar Lifecycle
```rust
// Start sidecar with environment
let sidecar = app_handle
.shell()
.sidecar("server")
.expect("无法找到 server sidecar")
.env("PORT", port.to_string());
// Cleanup on exit
match event {
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
if let Some(child) = process.take() {
let _ = child.kill();
}
}
_ => {}
}
``` ```
## Critical Rules ## Critical Rules
**DO:** **DO:**
- Run `cargo fmt` and `cargo clippy` before commit - Run server dev before desktop dev
- Use `expect("中文消息")` instead of `unwrap()` - Use `catalog:` for dependencies
- Always cleanup sidecar on app exit - Handle server startup gracefully
- Declare sidecar in `tauri.conf.json``bundle.externalBin`
**DON'T:** **DON'T:**
- Edit `gen/schemas/` (auto-generated) - Hardcode dependency versions (use catalog)
- Use `unwrap()` in production code without context - Block main thread during server wait
- Block the async runtime (use `spawn_blocking`)
## Pre-commit Checklist
- [ ] `cargo fmt` - formatting
- [ ] `cargo clippy` - linting
- [ ] `cargo check` - compiles
- [ ] `cargo test` - tests pass
- [ ] Tauri app starts and exits cleanly

View File

@@ -1,470 +0,0 @@
import * as path from 'node:path'
import { Schema } from '@effect/schema'
import { $ } from 'bun'
import { Console, Context, Data, Effect, Layer } from 'effect'
// ============================================================================
// Domain Models & Schema
// ============================================================================
/**
* Bun 构建目标后缀
*/
const BunTargetSuffixSchema = Schema.Literal(
'windows-x64',
'darwin-arm64',
'darwin-x64',
'linux-x64',
'linux-arm64',
)
/**
* Tauri sidecar 目标三元组
*/
const TauriTargetSchema = Schema.Literal(
'x86_64-pc-windows-msvc',
'aarch64-apple-darwin',
'x86_64-apple-darwin',
'x86_64-unknown-linux-gnu',
'aarch64-unknown-linux-gnu',
)
/**
* 目标映射配置
*/
const TargetMappingSchema = Schema.Struct({
bunSuffix: BunTargetSuffixSchema,
tauriTarget: TauriTargetSchema,
})
type TargetMapping = Schema.Schema.Type<typeof TargetMappingSchema>
/**
* 复制配置
*/
const CopyConfigSchema = Schema.Struct({
sourceDir: Schema.String.pipe(Schema.nonEmptyString()),
targetDir: Schema.String.pipe(Schema.nonEmptyString()),
baseName: Schema.String.pipe(Schema.nonEmptyString()),
mappings: Schema.Array(TargetMappingSchema).pipe(Schema.minItems(1)),
})
type CopyConfig = Schema.Schema.Type<typeof CopyConfigSchema>
/**
* 复制结果
*/
const CopyResultSchema = Schema.Struct({
bunSuffix: BunTargetSuffixSchema,
tauriTarget: TauriTargetSchema,
sourceFile: Schema.String,
targetFile: Schema.String,
success: Schema.Boolean,
})
type CopyResult = Schema.Schema.Type<typeof CopyResultSchema>
// ============================================================================
// Error Models
// ============================================================================
class ConfigError extends Data.TaggedError('ConfigError')<{
readonly message: string
readonly cause: unknown
}> {}
class FileSystemError extends Data.TaggedError('FileSystemError')<{
readonly operation: string
readonly path: string
readonly cause: unknown
}> {}
class CopyError extends Data.TaggedError('CopyError')<{
readonly source: string
readonly target: string
readonly cause: unknown
}> {}
// ============================================================================
// Services
// ============================================================================
/**
* 配置服务
*/
class CopyConfigService extends Context.Tag('CopyConfigService')<
CopyConfigService,
CopyConfig
>() {
/**
* 从原始数据创建并验证配置
*/
static fromRaw = (raw: unknown) =>
Effect.gen(function* () {
const decoded = yield* Schema.decodeUnknown(CopyConfigSchema)(raw)
return decoded
}).pipe(
Effect.catchAll((error) =>
Effect.fail(
new ConfigError({
message: '配置验证失败',
cause: error,
}),
),
),
)
/**
* 默认配置 Layer
*/
static readonly Live = Layer.effect(
CopyConfigService,
CopyConfigService.fromRaw({
sourceDir: path.join(__dirname, '..', 'server', 'out'),
targetDir: path.join(__dirname, 'src-tauri', 'binaries'),
baseName: 'server',
mappings: [
{
bunSuffix: 'windows-x64',
tauriTarget: 'x86_64-pc-windows-msvc',
},
{
bunSuffix: 'darwin-arm64',
tauriTarget: 'aarch64-apple-darwin',
},
{
bunSuffix: 'darwin-x64',
tauriTarget: 'x86_64-apple-darwin',
},
{
bunSuffix: 'linux-x64',
tauriTarget: 'x86_64-unknown-linux-gnu',
},
{
bunSuffix: 'linux-arm64',
tauriTarget: 'aarch64-unknown-linux-gnu',
},
],
} satisfies CopyConfig),
)
}
/**
* 文件系统服务
*/
class FileSystemService extends Context.Tag('FileSystemService')<
FileSystemService,
{
readonly ensureDir: (dir: string) => Effect.Effect<void, FileSystemError>
readonly fileExists: (
filePath: string,
) => Effect.Effect<boolean, FileSystemError>
readonly dirExists: (
dirPath: string,
) => Effect.Effect<boolean, FileSystemError>
readonly copyFile: (
source: string,
target: string,
) => Effect.Effect<void, CopyError>
}
>() {
static readonly Live = Layer.succeed(FileSystemService, {
ensureDir: (dir: string) =>
Effect.tryPromise({
try: async () => {
await $`mkdir -p ${dir}`
},
catch: (cause: unknown) =>
new FileSystemError({
operation: 'ensureDir',
path: dir,
cause,
}),
}),
fileExists: (filePath: string) =>
Effect.tryPromise({
try: async () => {
const file = Bun.file(filePath)
return await file.exists()
},
catch: (cause: unknown) =>
new FileSystemError({
operation: 'fileExists',
path: filePath,
cause,
}),
}),
dirExists: (dirPath: string) =>
Effect.tryPromise({
try: async () => {
const { default: fs } = await import('node:fs/promises')
try {
const stat = await fs.stat(dirPath)
return stat.isDirectory()
} catch {
return false
}
},
catch: (cause: unknown) =>
new FileSystemError({
operation: 'dirExists',
path: dirPath,
cause,
}),
}),
copyFile: (source: string, target: string) =>
Effect.tryPromise({
try: async () => {
await $`cp ${source} ${target}`
},
catch: (cause: unknown) =>
new CopyError({
source,
target,
cause,
}),
}),
})
}
/**
* 复制服务
*/
class CopyService extends Context.Tag('CopyService')<
CopyService,
{
readonly copyBinary: (
config: CopyConfig,
mapping: TargetMapping,
) => Effect.Effect<CopyResult, CopyError | FileSystemError>
readonly copyAllBinaries: (
config: CopyConfig,
) => Effect.Effect<ReadonlyArray<CopyResult>, CopyError | FileSystemError>
}
>() {
static readonly Live = Layer.effect(
CopyService,
Effect.gen(function* () {
const fs = yield* FileSystemService
return {
copyBinary: (config: CopyConfig, mapping: TargetMapping) =>
Effect.gen(function* () {
const { sourceDir, targetDir, baseName } = config
const { bunSuffix, tauriTarget } = mapping
// 确定文件扩展名Windows 需要 .exe
const ext = tauriTarget.includes('windows') ? '.exe' : ''
// 构建源文件和目标文件路径
const sourceFile = path.join(
sourceDir,
`${baseName}-${bunSuffix}${ext}`,
)
const targetFile = path.join(
targetDir,
`${baseName}-${tauriTarget}${ext}`,
)
// 检查源文件是否存在
const exists = yield* fs.fileExists(sourceFile)
if (!exists) {
yield* Console.log(`⚠️ 跳过 ${bunSuffix}: 源文件不存在`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: false,
} satisfies CopyResult
}
// 复制文件
yield* fs.copyFile(sourceFile, targetFile)
yield* Console.log(`${bunSuffix}${tauriTarget}`)
yield* Console.log(` ${sourceFile}`)
yield* Console.log(`${targetFile}\n`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: true,
} satisfies CopyResult
}),
copyAllBinaries: (config: CopyConfig) =>
Effect.gen(function* () {
const effects = config.mappings.map((mapping) =>
Effect.gen(function* () {
const { sourceDir, targetDir, baseName } = config
const { bunSuffix, tauriTarget } = mapping
const ext = tauriTarget.includes('windows') ? '.exe' : ''
const sourceFile = path.join(
sourceDir,
`${baseName}-${bunSuffix}${ext}`,
)
const targetFile = path.join(
targetDir,
`${baseName}-${tauriTarget}${ext}`,
)
const exists = yield* fs.fileExists(sourceFile)
if (!exists) {
yield* Console.log(`⚠️ 跳过 ${bunSuffix}: 源文件不存在`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: false,
} satisfies CopyResult
}
yield* fs.copyFile(sourceFile, targetFile)
yield* Console.log(`${bunSuffix}${tauriTarget}`)
yield* Console.log(` ${sourceFile}`)
yield* Console.log(`${targetFile}\n`)
return {
bunSuffix,
tauriTarget,
sourceFile,
targetFile,
success: true,
} satisfies CopyResult
}),
)
return yield* Effect.all(effects, { concurrency: 'unbounded' })
}),
}
}),
)
}
/**
* 报告服务
*/
class ReporterService extends Context.Tag('ReporterService')<
ReporterService,
{
readonly printSummary: (
results: ReadonlyArray<CopyResult>,
) => Effect.Effect<void>
}
>() {
static readonly Live = Layer.succeed(ReporterService, {
printSummary: (results: ReadonlyArray<CopyResult>) =>
Effect.gen(function* () {
const successful = results.filter((r) => r.success)
const failed = results.filter((r) => !r.success)
yield* Console.log('\n📦 复制摘要:')
yield* Console.log(` ✅ 成功: ${successful.length}`)
yield* Console.log(` ⚠️ 跳过: ${failed.length}`)
if (successful.length > 0) {
yield* Console.log('\n成功复制的文件:')
for (const result of successful) {
yield* Console.log(
`${result.bunSuffix}${result.tauriTarget}`,
)
}
}
if (failed.length > 0) {
yield* Console.log('\n跳过的文件:')
for (const result of failed) {
yield* Console.log(`${result.bunSuffix} (源文件不存在)`)
}
}
}),
})
}
// ============================================================================
// Main Program
// ============================================================================
const program = Effect.gen(function* () {
const config = yield* CopyConfigService
const fs = yield* FileSystemService
const copier = yield* CopyService
const reporter = yield* ReporterService
yield* Console.log('📦 开始复制二进制文件到 Tauri sidecar 目录...\n')
// 1. 检查源目录
const sourceExists = yield* fs.dirExists(config.sourceDir)
if (!sourceExists) {
yield* Console.error(`❌ 源目录不存在: ${config.sourceDir}`)
yield* Console.log(
'💡 提示: 请先在 apps/server 中运行 bun run compile 构建服务器二进制文件',
)
return yield* Effect.fail(
new FileSystemError({
operation: 'checkSourceDir',
path: config.sourceDir,
cause: '源目录不存在',
}),
)
}
// 2. 创建目标目录
yield* fs.ensureDir(config.targetDir)
yield* Console.log(`✓ 目标目录: ${config.targetDir}\n`)
// 3. 并行复制所有二进制文件
const results = yield* copier.copyAllBinaries(config)
// 4. 输出摘要
yield* reporter.printSummary(results)
return results
})
// ============================================================================
// Layer Composition
// ============================================================================
const MainLayer = Layer.mergeAll(
CopyConfigService.Live,
FileSystemService.Live,
CopyService.Live.pipe(Layer.provide(FileSystemService.Live)),
ReporterService.Live,
)
// ============================================================================
// Runner
// ============================================================================
const runnable = program.pipe(
Effect.provide(MainLayer),
Effect.catchTags({
ConfigError: (error) =>
Console.error(`❌ 配置错误: ${error.message}`, error.cause),
FileSystemError: (error) =>
Console.error(
`❌ 文件系统错误 [${error.operation}]: ${error.path}`,
error.cause,
),
CopyError: (error) =>
Console.error(
`❌ 复制失败: ${error.source}${error.target}`,
error.cause,
),
}),
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
)
Effect.runPromise(runnable).catch(() => {
process.exit(1)
})

View File

@@ -0,0 +1,14 @@
import type { ElectrobunConfig } from 'electrobun'
export default {
app: {
name: 'Furtherverse',
identifier: 'com.imbytecat.furtherverse',
version: '0.1.0',
},
build: {
bun: {
entrypoint: 'src/bun/index.ts',
},
},
} satisfies ElectrobunConfig

View File

@@ -4,16 +4,18 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "bun run copy && tauri build", "build": "electrobun build --env=canary",
"copy": "rm -rf binaries && bun --bun copy.ts", "build:stable": "electrobun build --env=stable",
"dev": "bun run copy && tauri dev" "dev": "electrobun dev",
"fix": "biome check --write",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"electrobun": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@effect/schema": "catalog:",
"@furtherverse/tsconfig": "workspace:*", "@furtherverse/tsconfig": "workspace:*",
"@tauri-apps/cli": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"effect": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }

View File

@@ -1,10 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
# Tauri Sidecar
binaries/

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
[package]
name = "server-desktop"
version = "0.1.0"
description = "A Tauri App"
authors = ["imbytecat"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "server_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["net"] }

View File

@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

View File

@@ -1,27 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"local": true,
"remote": {
"urls": [
"http://localhost:*",
"http://127.0.0.1:*",
"http{s}?://localhost(:\\d+)?/*"
]
},
"permissions": [
"core:default",
"core:window:allow-set-title",
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "binaries/app",
"sidecar": true
}
]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,8 +0,0 @@
// 原生桌面功能命令
// 未来可能包含: 文件对话框、系统通知、剪贴板等
// 示例命令 (可根据需要删除或替换)
#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}

View File

@@ -1,33 +0,0 @@
use tauri::Manager;
// 模块声明
mod commands;
mod sidecar;
use sidecar::SidecarProcess;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
// 注册全局状态
app.manage(SidecarProcess(std::sync::Mutex::new(None)));
// 启动 Sidecar 进程
let app_handle = app.handle().clone();
sidecar::spawn_sidecar(app_handle);
Ok(())
})
.invoke_handler(tauri::generate_handler![commands::greet])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// 监听应用退出事件,清理 Sidecar 进程
if let tauri::RunEvent::Exit = event {
// 只在 Exit 事件时清理,避免重复执行
sidecar::cleanup_sidecar_process(app_handle);
}
});
}

View File

@@ -1,6 +0,0 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
server_desktop_lib::run()
}

View File

@@ -1,166 +0,0 @@
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
// ===== 配置常量 =====
/// Sidecar App 启动超时时间(秒)
const STARTUP_TIMEOUT_SECS: u64 = 5;
/// 默认起始端口
const DEFAULT_PORT: u16 = 3000;
/// 端口扫描范围(从起始端口开始扫描的端口数量)
const PORT_SCAN_RANGE: u16 = 100;
/// 窗口默认宽度
const DEFAULT_WINDOW_WIDTH: f64 = 1200.0;
/// 窗口默认高度
const DEFAULT_WINDOW_HEIGHT: f64 = 800.0;
/// 窗口标题
const WINDOW_TITLE: &str = "Tauri App";
// ===== 数据结构 =====
/// 全局状态:存储 Sidecar 进程句柄
pub struct SidecarProcess(pub Mutex<Option<CommandChild>>);
// 检查端口是否可用(未被占用)
async fn is_port_available(port: u16) -> bool {
tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.is_ok()
}
// 查找可用端口
async fn find_available_port(start: u16) -> u16 {
for port in start..start + PORT_SCAN_RANGE {
if is_port_available(port).await {
return port;
}
}
start // 回退到起始端口
}
/// 启动 Sidecar 进程并创建主窗口
pub fn spawn_sidecar(app_handle: tauri::AppHandle) {
// 检测是否为开发模式
let is_dev = cfg!(debug_assertions);
if is_dev {
// 开发模式:直接创建窗口连接到 Vite 开发服务器
println!("🔧 开发模式");
match tauri::WebviewWindowBuilder::new(
&app_handle,
"main",
tauri::WebviewUrl::External("http://localhost:3000".parse().unwrap()),
)
.title(WINDOW_TITLE)
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
.center()
.build()
{
Ok(_) => println!("✓ 开发窗口创建成功"),
Err(e) => {
eprintln!("✗ 窗口创建失败: {}", e);
}
}
return;
}
// 生产模式:启动 sidecar 二进制
tauri::async_runtime::spawn(async move {
println!("🚀 生产模式");
// 查找可用端口
let port = find_available_port(DEFAULT_PORT).await;
println!("使用端口: {}", port);
// 启动 sidecar
let sidecar = app_handle
.shell()
.sidecar("server")
.expect("无法找到 app")
.env("PORT", port.to_string());
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
// 保存进程句柄到全局状态
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
*state.0.lock().unwrap() = Some(child);
}
// 监听 stdout等待服务器就绪信号
let start_time = std::time::Instant::now();
let timeout = Duration::from_secs(STARTUP_TIMEOUT_SECS);
let mut app_ready = false;
while let Some(event) = rx.recv().await {
if let CommandEvent::Stdout(line) = event {
let output = String::from_utf8_lossy(&line);
println!("App: {}", output);
// 检测 App 启动成功的标志
if output.contains("Listening on:") || output.contains("localhost") {
app_ready = true;
println!("✓ App 启动成功!");
// 创建主窗口
let url = format!("http://localhost:{}", port);
tauri::WebviewWindowBuilder::new(
&app_handle,
"main",
tauri::WebviewUrl::External(url.parse().unwrap()),
)
.title(WINDOW_TITLE)
.inner_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
.center()
.build()
.expect("创建窗口失败");
break;
}
}
// 超时检查
if start_time.elapsed() > timeout {
eprintln!("✗ 启动超时: App 未能在 {} 秒内启动", STARTUP_TIMEOUT_SECS);
break;
}
}
if !app_ready {
eprintln!("✗ App 启动失败");
std::process::exit(1);
}
});
}
/// 清理 Sidecar 进程 (在应用退出时调用)
pub fn cleanup_sidecar_process(app_handle: &tauri::AppHandle) {
let is_dev = cfg!(debug_assertions);
if is_dev {
// 开发模式退出时发送异常信号exit 1让 Turbo 停止 Vite 服务器
println!("🔧 开发模式退出,终止所有依赖任务...");
std::process::exit(1);
}
// 生产模式:正常清理 sidecar 进程
println!("应用退出,正在清理 Sidecar 进程...");
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
if let Ok(mut process) = state.0.lock() {
if let Some(child) = process.take() {
let _ = child.kill();
println!("✓ Sidecar 进程已终止");
}
}
}
}

View File

@@ -1,25 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "server-desktop",
"version": "0.1.0",
"identifier": "com.imbytecat.server-desktop",
"app": {
"withGlobalTauri": true,
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": ["binaries/server"]
}
}

View File

@@ -0,0 +1,49 @@
import Electrobun, { BrowserWindow } from 'electrobun/bun'
const DEV_SERVER_URL = 'http://localhost:3000'
async function waitForServer(url: string, timeoutMs = 30000): 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 {
await Bun.sleep(100)
}
}
return false
}
async function main() {
console.log('Starting Furtherverse Desktop...')
console.log('Waiting for dev server at', DEV_SERVER_URL)
const ready = await waitForServer(DEV_SERVER_URL)
if (!ready) {
console.error(
'Dev server not responding. Make sure to run: cd apps/server && bun dev',
)
process.exit(1)
}
console.log('Dev server ready!')
new BrowserWindow({
title: 'Furtherverse',
url: DEV_SERVER_URL,
frame: {
x: 100,
y: 100,
width: 1200,
height: 800,
},
})
Electrobun.events.on('will-quit', () => console.log('Quitting...'))
}
main().catch((error) => {
console.error('Failed to start:', error)
process.exit(1)
})

View File

@@ -1,4 +1,3 @@
{ {
"extends": "@furtherverse/tsconfig/bun.json", "extends": "@furtherverse/tsconfig/bun.json"
"exclude": ["node_modules", "src-tauri"]
} }

View File

@@ -1,14 +0,0 @@
{
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"dependsOn": ["@furtherverse/server#compile"],
"outputs": ["src-tauri/target/release/**"]
},
"dev": {
"dependsOn": ["@furtherverse/server#compile"],
"with": ["@furtherverse/server#dev"]
}
}
}

View File

@@ -27,7 +27,6 @@
"@tanstack/react-router": "catalog:", "@tanstack/react-router": "catalog:",
"@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:", "@tanstack/react-start": "catalog:",
"@tauri-apps/api": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"drizzle-zod": "catalog:", "drizzle-zod": "catalog:",
"postgres": "catalog:", "postgres": "catalog:",

View File

@@ -1,9 +1,7 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { isTauri } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import type { ChangeEventHandler, FormEventHandler } from 'react' import type { ChangeEventHandler, FormEventHandler } from 'react'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { orpc } from '@/client/query-client' import { orpc } from '@/client/query-client'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
@@ -21,11 +19,6 @@ function Todos() {
const updateMutation = useMutation(orpc.todo.update.mutationOptions()) const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions()) const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
useEffect(() => {
if (!isTauri()) return
getCurrentWindow().setTitle('待办事项')
}, [])
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => { const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault() e.preventDefault()
if (newTodoTitle.trim()) { if (newTodoTitle.trim()) {

804
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -39,8 +39,7 @@
"@tanstack/react-router-devtools": "^1.158.1", "@tanstack/react-router-devtools": "^1.158.1",
"@tanstack/react-router-ssr-query": "^1.158.1", "@tanstack/react-router-ssr-query": "^1.158.1",
"@tanstack/react-start": "^1.158.3", "@tanstack/react-start": "^1.158.3",
"@tauri-apps/api": "^2.10.1", "electrobun": "^1.11.5-beta.5",
"@tauri-apps/cli": "^2.10.0",
"@types/bun": "^1.3.8", "@types/bun": "^1.3.8",
"@vitejs/plugin-react": "^5.1.3", "@vitejs/plugin-react": "^5.1.3",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",