1 Commits
ux ... main

Author SHA1 Message Date
b967deb4b1 feat: 迁移数据库至 SQLite 并新增项目文档
- 将 Postgres 数据库替换为 SQLite
- 并同步添加 README 文档以优化项目初始化流程
2026-01-20 16:56:11 +08:00
117 changed files with 6843 additions and 2881 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
### Custom ### ### Custom ###
# SQLite database
data/
# TanStack # TanStack
.tanstack/ .tanstack/

View File

@@ -1,8 +0,0 @@
## Migration Decisions
- **Library**: `@isaacs/ttlcache` v2.1.4
- **Rationale**: Minimal, zero-dependency, specialized for TTL, well-maintained.
- **Cache Key**: Fixed string `'fingerprint'` since we only cache one result.
- **Max Items**: 1 (singleton fingerprint).
- **Default TTL**: 10 minutes (compatible with previous implementation).

View File

@@ -1,8 +0,0 @@
## @isaacs/ttlcache Migration Learnings
- Successfully replaced manual TTL cache with `@isaacs/ttlcache` in `fingerprint.ts`.
- Use `catalog:` for dependency management in the Bun monorepo.
- `TTLCache` does not handle in-flight request deduplication, so the `inFlight` Promise pattern was preserved.
- Alphabetical sorting in `package.json` catalog is important for consistency.
- Biome handles import organization and formatting; `bun fix` should be run after manual edits.

View File

@@ -1,3 +0,0 @@
## Decisions
- Renamed `db` middleware to `dbProvider` as requested, while keeping `db` as an alias to avoid breaking existing routers.

View File

@@ -1,4 +0,0 @@
## Issues encountered
- `db:migrate` failed due to `device_info` table already existing in the local SQLite database. Resolved by using `db:push` which is suitable for dev environments.
- `lsp_diagnostics` was not available, used `bun typecheck` (`tsc --noEmit`) instead.

View File

@@ -1,7 +0,0 @@
## License Activation Implementation
- Created `license_activation` table for better semantic clarity.
- Implemented `ensureLicenseActivationInitialized` with UPSERT logic and singleton pattern.
- Added ORPC endpoints `license.getActivation` and `license.activate`.
- Used `db:push` to handle migration in dev environment when conflicts occur.
- Fixed type mismatch in `device.router.ts` where `Date` was returned instead of `number` for timestamp fields.

View File

@@ -37,8 +37,7 @@
"files.associations": { "files.associations": {
".env": "dotenv", ".env": "dotenv",
".env.*": "dotenv", ".env.*": "dotenv",
"**/tsconfig.json": "jsonc", "**/tsconfig*.json": "jsonc",
"**/tsconfig.*.json": "jsonc",
"**/biome.json": "jsonc", "**/biome.json": "jsonc",
"**/opencode.json": "jsonc" "**/opencode.json": "jsonc"
}, },

View File

@@ -8,42 +8,35 @@
- **运行时**: Bun - **运行时**: Bun
- **语言**: TypeScript (strict mode, ESNext) - **语言**: TypeScript (strict mode, ESNext)
- **样式**: Tailwind CSS v4 - **样式**: Tailwind CSS v4
- **数据库**: SQLite + Drizzle ORM - **数据库**: SQLite (Bun 内置) + Drizzle ORM
- **状态管理**: TanStack Query - **状态管理**: TanStack Query
- **路由**: TanStack Router (文件路由) - **路由**: TanStack Router (文件路由)
- **RPC**: ORPC (类型安全 RPC契约优先) - **RPC**: ORPC (类型安全 RPC契约优先)
- **构建工具**: Vite + Turbo - **构建工具**: Vite + Turbo
- **代码质量**: Biome (格式化 + Lint) - **代码质量**: Biome (格式化 + Lint)
- **桌面壳** (可选): Tauri v2 (详见 `src-tauri/AGENTS.md`)
## 依赖管理
项目使用 **Bun Catalog** 统一管理依赖版本。
> **详细流程**: 加载 skill `bun-catalog-package` 获取完整指南。
**快速参考**
```bash
bun info <pkg> version # 查询最新版本
bun add <pkg>@catalog: # 在子包中安装
```
## 构建、Lint 和测试命令 ## 构建、Lint 和测试命令
### 开发 ### 开发
```bash ```bash
bun dev # 启动 Vite 开发服务器 bun dev # 使用 Turbo 并行启动 Tauri + Vite 开发服务器
bun dev:vite # 仅启动 Vite 开发服务器 (localhost:3000)
bun dev:tauri # 启动 Tauri 桌面应用
bun db:studio # 打开 Drizzle Studio 数据库管理界面 bun db:studio # 打开 Drizzle Studio 数据库管理界面
``` ```
### 构建 ### 构建
```bash ```bash
bun build # 构建 Vite 应用 (输出到 .output/) bun build # 完整构建 (Vite → 编译 → Tauri 打包)
bun compile # 编译为独立可执行文件 (使用 build.ts) bun build:vite # 仅构建 Vite (输出到 .output/)
bun build:compile # 编译为独立可执行文件 (使用 build.ts)
bun build:tauri # 构建 Tauri 桌面安装包
``` ```
### 代码质量 ### 代码质量
```bash ```bash
bun typecheck # 运行 TypeScript 类型检查 bun typecheck # 运行 TypeScript 编译器检查 (tsc -b)
bun fix # 运行 Biome 自动修复格式和 Lint 问题 bun fix # 运行 Biome 自动修复格式和 Lint 问题
biome check . # 检查但不自动修复 biome check . # 检查但不自动修复
biome format --write . # 仅格式化代码 biome format --write . # 仅格式化代码
@@ -51,9 +44,10 @@ biome format --write . # 仅格式化代码
### 数据库 ### 数据库
```bash ```bash
bun db:init # 初始化 SQLite 数据库 (创建表)
bun db:generate # 从 schema 生成迁移文件 bun db:generate # 从 schema 生成迁移文件
bun db:migrate # 执行数据库迁移 bun db:migrate # 执行数据库迁移
bun db:push # 直接推送 schema 变更 (仅开发环境) bun db:studio # 打开 Drizzle Studio 数据库管理界面
``` ```
### 测试 ### 测试
@@ -150,23 +144,27 @@ export const Route = createFileRoute('/')({
### 数据库 Schema (Drizzle) ### 数据库 Schema (Drizzle)
- 在 `src/server/db/schema/*.ts` 定义 schema - 在 `src/db/schema/*.ts` 定义 schema
- 从 `src/server/db/schema/index.ts` 导出 - 从 `src/db/schema/index.ts` 导出
- 使用 `drizzle-orm/sqlite-core` 的 SQLite 类型 - 使用 `drizzle-orm/sqlite-core` 的 SQLite 类型
- 主键使用 `uuidv7()` (TEXT 存储) - 主键使用 `crypto.randomUUID()` 生成 UUID
- 时间戳使用 `integer({ mode: 'timestamp_ms' })` (Unix 毫秒时间戳)
- 始终包含 `createdAt``updatedAt` 时间戳 - 始终包含 `createdAt``updatedAt` 时间戳
示例: 示例:
```typescript ```typescript
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' import { sql } from 'drizzle-orm'
import { v7 as uuidv7 } from 'uuid' import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const myTable = sqliteTable('my_table', { export const myTable = sqliteTable('my_table', {
id: text('id').primaryKey().$defaultFn(() => uuidv7()), id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(), name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()), createdAt: integer('created_at', { mode: 'timestamp' })
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdateFn(() => new Date()), .notNull()
.default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`)
.$onUpdateFn(() => new Date()),
}) })
``` ```
@@ -281,140 +279,5 @@ const mutation = useMutation(orpc.myFeature.create.mutationOptions())
--- ---
## 已知问题与解决方案 **最后更新**: 2026-01-20
### 构建问题
**已解决**: Vite 8.0.0-beta.10 已修复与 Nitro 插件的兼容性问题
- **当前版本**: Vite 8.0.0-beta.10 + nitro-nightly@3.0.1-20260125
- **状态**: 构建稳定,开发和生产环境均正常工作
### 依赖选择经验
**ohash vs crypto.createHash**
在实现硬件指纹功能时,曾误判 `ohash` 不适合用于硬件指纹识别。经深入研究发现:
**事实**
- `ohash` 内部使用**完整的 SHA-256** 算法256 位)
- 输出 43 字符 Base64URL 编码(等价于 64 字符 Hex
- 碰撞概率与 `crypto.createHash('sha256')` **完全相同**2^128
- 自动处理对象序列化,代码更简洁
**对比**
```typescript
// ohash - 推荐用于对象哈希
import { hash } from 'ohash'
const fingerprint = hash(systemInfo) // 一行搞定
// crypto - 需要手动序列化
import { createHash } from 'node:crypto'
const fingerprint = createHash('sha256')
.update(JSON.stringify(systemInfo))
.digest('base64url')
```
**结论**
- ✅ `ohash` 完全适合硬件指纹场景(数据来自系统 API非用户输入
- ✅ 两者安全性等价,选择取决于代码风格偏好
- ⚠️ ohash 文档警告的"序列化安全性"仅针对**用户输入**场景
**经验教训**
- 不要仅凭名称("短哈希")判断库的实现
- 深入研究文档和源码再做技术决策
- 区分"用户输入场景"和"系统数据场景"的安全要求
### 缓存库选择:@isaacs/ttlcache
**决策时间**: 2026-01-26
**背景**
硬件指纹功能最初使用手动实现的 TTL 缓存module-level 变量 + 手动过期检查)。为提高代码可维护性,迁移到专业缓存库。
**选型**
- **选择**: `@isaacs/ttlcache` v2.1.4
- **理由**:
- 专为 TTL 场景优化,无需 LRU 追踪开销
- 零依赖6M+ 周下载量
- 内置 TypeScript 类型
- 自动过期管理,无需手动定时器
- API 简洁: `new TTLCache({ ttl, max })`
**实现细节**
- 保留 `inFlight` Promise 模式用于并发请求去重TTLCache 不提供此功能)
- 使用单一缓存键 `'fingerprint'`单服务器场景opts 不影响输出)
- 默认 TTL: 10 分钟(可通过 `cacheTtlMs` 参数覆盖)
**对比手动实现**
- ✅ 更少自定义代码
- ✅ 更清晰的 TTL 语义
- ✅ 经过充分测试的库
- ⚠️ 仍需手动处理并发去重
**经验教训**
- 专业库不一定解决所有问题(如并发去重)
- 对于简单场景,手动实现 vs 库的选择主要取决于可维护性而非功能
### SQLite 数据库使用说明
**技术栈**:
- **驱动**: `better-sqlite3` v11.8.1 (原生模块,跨平台)
- **类型定义**: `@types/better-sqlite3` v7.6.12
- **数据库文件**: `./data/app.db`
**数据类型策略**:
- **主键**: TEXT 存储 UUIDv7 (36 字符字符串,全局唯一)
- **时间戳**: INTEGER 存储 Unix 毫秒时间戳 (`integer({ mode: 'timestamp_ms' })`)
- Drizzle 自动转换 `Date``number`
- **布尔值**: INTEGER 存储 0/1 (`integer({ mode: 'boolean' })`)
- Drizzle 自动转换 `boolean``0/1`
**数据库连接**:
```typescript
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
export const createDB = () => {
const dbPath = env.DATABASE_URL
if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true })
}
const sqlite = new Database(dbPath)
return drizzle(sqlite, { schema })
}
```
**注意事项**:
- SQLite 是文件数据库,适合嵌入环境和单实例应用
- 不支持多进程并发写入
- 数据库文件和 WAL 文件 (`.db-shm`, `.db-wal`) 已添加到 `.gitignore`
- `better-sqlite3` 是原生模块,构建时需匹配生产环境 OS 和架构
### AGENTS.md 文档维护原则
**核心原则**AGENTS.md 只记录当前项目状态,不记录重构历史
1. **当前状态优先**:只描述项目当前使用的技术栈、架构和最佳实践
2. **无需向后兼容**:不保留旧技术栈的文档,重构后直接更新为新状态
3. **避免历史记录**:不记录"从 X 迁移到 Y"的过程,只记录"当前使用 Y"
4. **保持简洁**:删除过时信息,避免上下文过长影响 AI 理解
5. **及时同步**:架构变更后立即更新文档,确保文档与代码一致
**何时更新 AGENTS.md**
- 更换技术栈(如数据库、框架)
- 修改项目架构或目录结构
- 添加/移除重要依赖
- 发现重要的最佳实践或经验教训
**何时不更新 AGENTS.md**
- 日常功能开发
- Bug 修复
- 代码重构(不涉及架构变更)
- 临时实验或 POC
---
**最后更新**: 2026-01-26
**项目版本**: 基于 package.json 依赖版本 **项目版本**: 基于 package.json 依赖版本

242
README.md Normal file
View File

@@ -0,0 +1,242 @@
# Fullstack Starter (SQLite)
一个基于 **TanStack Start + Bun + Tauri + SQLite** 的全栈桌面应用脚手架。
包含一个完整的 **Todo List** 示例,展示了从前端到后端的完整数据流。
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端框架 | React 19 + TanStack Router (文件路由) |
| 状态管理 | TanStack Query |
| 样式 | Tailwind CSS v4 |
| RPC 通信 | ORPC (类型安全,契约优先) |
| 数据库 | SQLite (Bun 内置) + Drizzle ORM |
| 桌面壳 | Tauri v2 |
| 运行时 | Bun |
| 构建 | Vite + Turbo |
## 快速开始
### 前置要求
- [Bun](https://bun.sh/) >= 1.0
- [Rust](https://www.rust-lang.org/) (仅 Tauri 桌面应用需要)
### 安装与运行
```bash
# 1. 克隆项目
git clone <your-repo-url>
cd fullstack-starter-SQLite
# 2. 安装依赖
bun install
# 3. 初始化数据库
bun run db:init
# 4. 启动开发服务器
bun run dev:vite # 仅 Web (http://localhost:3000)
bun run dev # Tauri 桌面应用 + Web
```
### 构建
```bash
bun run build:vite # 构建 Web 版本
bun run build # 构建 Tauri 桌面安装包
```
## 项目结构
```
├── src/
│ ├── components/ # 可复用组件
│ │ ├── Error.tsx # 错误边界组件
│ │ └── NotFound.tsx # 404 页面组件
│ │
│ ├── db/ # 数据库层
│ │ ├── index.ts # 数据库连接
│ │ └── schema/ # Drizzle 表定义
│ │ ├── index.ts # Schema 导出入口
│ │ └── todo.ts # Todo 表定义
│ │
│ ├── orpc/ # RPC 层 (后端 API)
│ │ ├── contracts/ # 契约定义 (输入/输出 Schema)
│ │ │ └── todo.ts # Todo API 契约
│ │ ├── handlers/ # 业务逻辑实现
│ │ │ └── todo.ts # Todo CRUD 处理器
│ │ ├── middlewares/ # 中间件
│ │ │ └── db.ts # 数据库注入中间件
│ │ ├── contract.ts # 契约聚合
│ │ ├── router.ts # 路由聚合
│ │ ├── client.ts # 同构客户端 (SSR/CSR)
│ │ ├── server.ts # 服务端实例
│ │ └── index.ts # 导出入口
│ │
│ ├── routes/ # 页面路由 (文件路由)
│ │ ├── __root.tsx # 根布局
│ │ ├── index.tsx # 首页 (Todo List)
│ │ └── api/
│ │ └── rpc.$.ts # RPC HTTP 端点
│ │
│ ├── integrations/ # 第三方库集成
│ ├── lib/ # 工具函数
│ ├── env.ts # 环境变量验证
│ ├── router.tsx # 路由配置
│ └── styles.css # 全局样式
├── scripts/
│ └── init-db.ts # 数据库初始化脚本
├── src-tauri/ # Tauri 桌面应用配置
├── data/ # SQLite 数据库文件 (gitignore)
└── drizzle.config.ts # Drizzle 配置
```
## 开发指南
### 添加新功能的步骤
以添加一个 "Note" 功能为例:
#### 1. 定义数据库 Schema
```typescript
// src/db/schema/note.ts
import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const noteTable = sqliteTable('note', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
content: text('content').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
})
```
```typescript
// src/db/schema/index.ts
export * from './todo.ts'
export * from './note.ts' // 添加导出
```
#### 2. 定义 API 契约
```typescript
// src/orpc/contracts/note.ts
import { oc } from '@orpc/contract'
import { z } from 'zod'
export const note = {
list: oc.output(z.array(noteSchema)),
create: oc.input(z.object({ content: z.string() })).output(noteSchema),
}
```
#### 3. 实现业务逻辑
```typescript
// src/orpc/handlers/note.ts
import { os } from '@/orpc/server'
import { dbProvider } from '@/orpc/middlewares/db'
import { noteTable } from '@/db/schema'
export const list = os.note.list
.use(dbProvider)
.handler(async ({ context }) => {
return context.db.select().from(noteTable)
})
export const create = os.note.create
.use(dbProvider)
.handler(async ({ context, input }) => {
const [note] = await context.db.insert(noteTable).values(input).returning()
return note
})
```
#### 4. 注册到路由
```typescript
// src/orpc/contract.ts
import { note } from './contracts/note'
export const contract = { todo, note }
// src/orpc/router.ts
import * as note from './handlers/note'
export const router = os.router({ todo, note })
```
#### 5. 在页面中使用
```typescript
// src/routes/notes.tsx
import { orpc } from '@/orpc'
import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
const NotesPage = () => {
const { data: notes } = useSuspenseQuery(orpc.note.list.queryOptions())
const createNote = useMutation(orpc.note.create.mutationOptions())
// ...
}
```
### 常用命令
| 命令 | 说明 |
|------|------|
| `bun run dev` | 启动 Tauri + Vite 开发服务器 |
| `bun run dev:vite` | 仅启动 Vite 开发服务器 |
| `bun run build` | 完整构建 (Tauri 桌面应用) |
| `bun run build:vite` | 仅构建 Web 版本 |
| `bun run db:init` | 初始化/重置数据库 |
| `bun run db:studio` | 打开 Drizzle Studio |
| `bun run typecheck` | TypeScript 类型检查 |
| `bun run fix` | 自动修复格式和 Lint 问题 |
### 代码规范
- **格式化**: 使用 Biome2 空格缩进,单引号
- **导入**: 使用 `@/*` 路径别名
- **组件**: 箭头函数组件
- **命名**: 文件 kebab-case组件 PascalCase
## 核心概念
### ORPC 数据流
```
前端组件
↓ useSuspenseQuery / useMutation
ORPC 客户端 (src/orpc/client.ts)
↓ 自动选择 SSR 直调 / CSR HTTP
契约验证 (src/orpc/contracts/)
↓ 输入/输出类型安全
处理器 (src/orpc/handlers/)
↓ 业务逻辑
中间件 (src/orpc/middlewares/)
↓ 注入数据库连接
Drizzle ORM
↓ 类型安全查询
SQLite 数据库
```
### 同构渲染
- **SSR**: 服务端直接调用 router无 HTTP 开销
- **CSR**: 客户端通过 `/api/rpc` 端点调用
### 数据库
- SQLite 文件存储在 `./data/app.db`
- 使用 WAL 模式提高并发性能
- 单例模式管理连接
## 许可证
MIT

View File

@@ -1 +0,0 @@
DATABASE_URL=./data/app.db

View File

@@ -1,5 +0,0 @@
# SQLite database files
data/
*.db
*.db-shm
*.db-wal

View File

@@ -1,7 +0,0 @@
{
"$schema": "../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": "//",
"files": {
"includes": ["**", "!**/routeTree.gen.ts"]
}
}

View File

@@ -1,18 +0,0 @@
CREATE TABLE `device_info` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`fingerprint` text NOT NULL,
`fingerprint_quality` text NOT NULL,
`license` text,
`license_activated_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `device_info_fingerprint_unique` ON `device_info` (`fingerprint`);--> statement-breakpoint
CREATE TABLE `todo` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`title` text NOT NULL,
`completed` integer DEFAULT false NOT NULL
);

View File

@@ -1,10 +0,0 @@
CREATE TABLE `license_activation` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`fingerprint` text NOT NULL,
`license` text,
`license_activated_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `license_activation_fingerprint_unique` ON `license_activation` (`fingerprint`);

View File

@@ -1,2 +0,0 @@
DROP TABLE `device_info`;--> statement-breakpoint
DROP TABLE `todo`;

View File

@@ -1,129 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "09c29147-479a-4490-8223-db64c71c910f",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"device_info": {
"name": "device_info",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fingerprint_quality": {
"name": "fingerprint_quality",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"license": {
"name": "license",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"license_activated_at": {
"name": "license_activated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"device_info_fingerprint_unique": {
"name": "device_info_fingerprint_unique",
"columns": ["fingerprint"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"todo": {
"name": "todo",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed": {
"name": "completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,187 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "14bad572-1fc5-489d-90f2-7560a7cad1f4",
"prevId": "09c29147-479a-4490-8223-db64c71c910f",
"tables": {
"device_info": {
"name": "device_info",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fingerprint_quality": {
"name": "fingerprint_quality",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"license": {
"name": "license",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"license_activated_at": {
"name": "license_activated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"device_info_fingerprint_unique": {
"name": "device_info_fingerprint_unique",
"columns": ["fingerprint"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"license_activation": {
"name": "license_activation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"license": {
"name": "license",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"license_activated_at": {
"name": "license_activated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"license_activation_fingerprint_unique": {
"name": "license_activation_fingerprint_unique",
"columns": ["fingerprint"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"todo": {
"name": "todo",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed": {
"name": "completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,76 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2bdd10cc-8e14-4843-931a-37b1d00f21a6",
"prevId": "14bad572-1fc5-489d-90f2-7560a7cad1f4",
"tables": {
"license_activation": {
"name": "license_activation",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"license": {
"name": "license",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"license_activated_at": {
"name": "license_activated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"license_activation_fingerprint_unique": {
"name": "license_activation_fingerprint_unique",
"columns": ["fingerprint"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -1,27 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1769409553227,
"tag": "0000_messy_goliath",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1769409970060,
"tag": "0001_watery_mongu",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1769412006512,
"tag": "0002_dizzy_kingpin",
"breakpoints": true
}
]
}

View File

@@ -1,61 +0,0 @@
{
"name": "@furtherverse/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"compile": "bun build.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "vite dev",
"fix": "biome check --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@isaacs/ttlcache": "catalog:",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/openapi": "catalog:",
"@orpc/server": "catalog:",
"@orpc/tanstack-query": "catalog:",
"@orpc/zod": "catalog:",
"@t3-oss/env-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:",
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"ohash": "catalog:",
"better-sqlite3": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"systeminformation": "catalog:",
"uuid": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@effect/platform": "catalog:",
"@effect/schema": "catalog:",
"@furtherverse/tsconfig": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@tanstack/devtools-vite": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-router-devtools": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/bun": "catalog:",
"@vitejs/plugin-react": "catalog:",
"babel-plugin-react-compiler": "catalog:",
"drizzle-kit": "catalog:",
"effect": "catalog:",
"nitro": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "catalog:"
}
}

View File

@@ -1,24 +0,0 @@
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { createRouterClient } from '@orpc/server'
import { createIsomorphicFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { router } from '@/server/api/routers'
import type { RouterClient } from '@/server/api/types'
const getORPCClient = createIsomorphicFn()
.server(() =>
createRouterClient(router, {
context: () => ({
headers: getRequestHeaders(),
}),
}),
)
.client(() => {
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
})
return createORPCClient<RouterClient>(link)
})
export const orpc: RouterClient = getORPCClient()

View File

@@ -1,6 +0,0 @@
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { orpc as orpcClient } from './orpc.client'
export const orpc = createTanstackQueryUtils(orpcClient, {
experimental_defaults: {},
})

View File

@@ -1,109 +0,0 @@
import {
constants,
createPrivateKey,
createPublicKey,
privateDecrypt,
publicEncrypt,
} from 'node:crypto'
// 对应 Java: RSA/ECB/OAEPWithSHA-256AndMGF1Padding
const OAEP_HASH = 'sha256'
/**
* 使用 Base64 编码的公钥加密明文
*
* 加密标准: RSA/ECB/OAEPWithSHA-256AndMGF1Padding (兼容 Java)
* 公钥格式: SPKI/DER (对应 Java X509EncodedKeySpec)
*
* @param plainText - 待加密的明文字符串
* @param publicKeyBase64 - Base64 编码的公钥 (DER/SPKI 格式)
* @returns Base64 编码的加密数据
* @throws {Error} 加密失败时抛出错误
*
* @example
* ```typescript
* const publicKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...'
* const encrypted = encrypt('sensitive data', publicKey)
* console.log(encrypted) // "a3f5e8c2d1b4..."
* ```
*/
export function encrypt(plainText: string, publicKeyBase64: string): string {
const buffer = Buffer.from(plainText, 'utf-8')
const keyBuffer = Buffer.from(publicKeyBase64, 'base64')
try {
// 1. 先创建 KeyObject在这里指定密钥的格式 (DER/SPKI)
const publicKey = createPublicKey({
key: keyBuffer,
format: 'der',
type: 'spki', // 对应 Java X509EncodedKeySpec
})
// 2. 使用 KeyObject 进行加密,在这里指定 Padding 模式
const encrypted = publicEncrypt(
{
key: publicKey,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: OAEP_HASH,
},
buffer,
)
return encrypted.toString('base64')
} catch (error) {
throw new Error(
`Encryption failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* 使用 Base64 编码的私钥解密数据
*
* 解密标准: RSA/ECB/OAEPWithSHA-256AndMGF1Padding (兼容 Java)
* 私钥格式: PKCS8/DER (对应 Java PKCS8EncodedKeySpec)
*
* @param encryptedData - Base64 编码的加密数据
* @param privateKeyBase64 - Base64 编码的私钥 (DER/PKCS8 格式)
* @returns 解密后的明文字符串
* @throws {Error} 解密失败时抛出错误
*
* @example
* ```typescript
* const privateKey = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...'
* const decrypted = decrypt('a3f5e8c2d1b4...', privateKey)
* console.log(decrypted) // "sensitive data"
* ```
*/
export function decrypt(
encryptedData: string,
privateKeyBase64: string,
): string {
const buffer = Buffer.from(encryptedData, 'base64')
const keyBuffer = Buffer.from(privateKeyBase64, 'base64')
try {
// 1. 先创建 KeyObject在这里指定密钥的格式 (DER/PKCS8)
const privateKey = createPrivateKey({
key: keyBuffer,
format: 'der',
type: 'pkcs8', // 对应 Java PKCS8EncodedKeySpec
})
// 2. 使用 KeyObject 进行解密
const decrypted = privateDecrypt(
{
key: privateKey,
padding: constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: OAEP_HASH,
},
buffer,
)
return decrypted.toString('utf-8')
} catch (error) {
throw new Error(
`Decryption failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

View File

@@ -1,233 +0,0 @@
import { TTLCache } from '@isaacs/ttlcache'
import { hash } from 'ohash'
import si from 'systeminformation'
/**
* 硬件指纹质量等级
* - strong: 2+ 个强标识符可用(推荐用于生产授权)
* - medium: 1 个强标识符可用(可用但不理想)
* - weak: 无强标识符(仅适合开发/测试)
*/
export type FingerprintQuality = 'strong' | 'medium' | 'weak'
/**
* 标准化的系统信息(用于机器码生成)
*/
export type NormalizedSystemInfo = {
/** 系统 UUID(最稳定的硬件标识符) */
systemUuid: string | null
/** 系统序列号 */
systemSerial: string | null
/** 主板序列号 */
baseboardSerial: string | null
/** 主板制造商 */
baseboardManufacturer: string | null
/** BIOS 版本 */
biosVersion: string | null
/** BIOS 供应商 */
biosVendor: string | null
/** CPU 品牌标识(用于质量评估) */
cpuBrand: string | null
/** 主硬盘序列号(可选,高稳定性) */
primaryDiskSerial?: string | null
}
/**
* 硬件指纹配置选项
*/
export type HardwareFingerprintOptions = {
/**
* 缓存 TTL(毫秒),默认 10 分钟
* 硬件信息变化频率极低,缓存可大幅提升性能
*/
cacheTtlMs?: number
/**
* 是否包含主硬盘序列号(默认 true)
* 注意:在容器/虚拟机环境可能获取失败
*/
includePrimaryDisk?: boolean
}
/**
* 硬件指纹响应
*/
export type HardwareFingerprintResult = {
/** 机器码(HMAC-SHA256 哈希,64 字符十六进制) */
fingerprint: string
/** 指纹质量等级 */
quality: FingerprintQuality
/** 可用的强标识符数量 */
strongIdentifiersCount: number
/** 生成时间戳 */
timestamp: number
}
// 缓存实例
const cache = new TTLCache<'fingerprint', HardwareFingerprintResult>({
ttl: 10 * 60 * 1000, // 10 minutes default
max: 1, // Only one fingerprint cached
})
// 防止并发重复请求
let inFlight: Promise<HardwareFingerprintResult> | null = null
/**
* 计算指纹质量
*/
function computeQuality(info: NormalizedSystemInfo): {
quality: FingerprintQuality
count: number
} {
const strongKeys = [
info.systemUuid,
info.systemSerial,
info.baseboardSerial,
info.primaryDiskSerial,
].filter(Boolean).length
if (strongKeys >= 2) return { quality: 'strong', count: strongKeys }
if (strongKeys === 1) return { quality: 'medium', count: strongKeys }
return { quality: 'weak', count: 0 }
}
/**
* 安全地收集标准化系统信息(容错处理)
*/
async function collectNormalizedInfo(
opts: HardwareFingerprintOptions,
): Promise<NormalizedSystemInfo> {
// 使用 Promise.allSettled 避免单点失败
const tasks = await Promise.allSettled([
si.uuid(),
si.system(),
si.baseboard(),
si.bios(),
si.cpu(),
opts.includePrimaryDisk !== false ? si.diskLayout() : Promise.resolve([]),
])
const [uuidRes, systemRes, baseboardRes, biosRes, cpuRes, diskRes] = tasks
const uuid = uuidRes.status === 'fulfilled' ? uuidRes.value : null
const system = systemRes.status === 'fulfilled' ? systemRes.value : null
const baseboard =
baseboardRes.status === 'fulfilled' ? baseboardRes.value : null
const bios = biosRes.status === 'fulfilled' ? biosRes.value : null
const cpu = cpuRes.status === 'fulfilled' ? cpuRes.value : null
// 提取主硬盘序列号(通常是第一个物理磁盘)
let primaryDiskSerial: string | null = null
if (diskRes.status === 'fulfilled' && Array.isArray(diskRes.value)) {
const disks = diskRes.value as Array<{ serialNum?: string; type?: string }>
const physicalDisk = disks.find(
(d) => d.type !== 'USB' && d.serialNum && d.serialNum.trim(),
)
primaryDiskSerial = physicalDisk?.serialNum?.trim() || null
}
return {
// 系统级标识符(最稳定)
systemUuid: (system?.uuid ?? uuid?.hardware ?? null) || null,
systemSerial: (system?.serial ?? null) || null,
// 主板标识符(次稳定)
baseboardSerial: (baseboard?.serial ?? null) || null,
baseboardManufacturer: (baseboard?.manufacturer ?? null) || null,
// BIOS 信息(辅助识别)
biosVersion: (bios?.version ?? null) || null,
biosVendor: (bios?.vendor ?? null) || null,
// CPU 信息(辅助识别)
cpuBrand: (cpu?.brand ?? null) || null,
// 磁盘序列号(可选,高稳定性)
...(opts.includePrimaryDisk !== false ? { primaryDiskSerial } : {}),
}
}
/**
/**
* 获取硬件指纹(机器码)
*
* 适用场景:客户端部署的软件授权、机器绑定
*
* 安全说明:
* - 返回 SHA-256 哈希(Base64URL 编码,43 字符),不可逆推原始硬件信息
* - 使用 ohash 自动处理对象序列化和哈希
* - 客户端部署场景:客户可以看到代码,无法使用密钥加密
* - 安全性依赖硬件信息本身的不可伪造性(来自操作系统)
* - 自动缓存减少系统调用开销
*
* 稳定性:
* - 优先使用系统 UUID、序列号等不易变更的标识符
* - 避免网络接口等易变信息
* - 容错处理,部分信息缺失不影响生成
*
* @example
* ```typescript
* const result = await getHardwareFingerprint({
* cacheTtlMs: 600000, // 10 分钟
* includePrimaryDisk: true,
* })
*
* console.log(result.fingerprint) // "a3f5e8c2d1b4..."
* console.log(result.quality) // "strong"
* ```
*/
export async function getHardwareFingerprint(
opts: HardwareFingerprintOptions,
): Promise<HardwareFingerprintResult> {
const ttl = opts.cacheTtlMs ?? 10 * 60 * 1000
const now = Date.now()
// 返回缓存结果
const cached = cache.get('fingerprint')
if (cached) {
return cached
}
// 防止并发重复请求
if (inFlight) {
return inFlight
}
inFlight = (async () => {
// 收集标准化信息
const info = await collectNormalizedInfo(opts)
// 计算质量
const { quality, count } = computeQuality(info)
// 使用 ohash 生成指纹(自动序列化 + SHA-256 + Base64URL)
const fingerprint = hash({
v: 1, // 版本号,未来如需变更采集策略可递增
info,
})
const result: HardwareFingerprintResult = {
fingerprint,
quality,
strongIdentifiersCount: count,
timestamp: now,
}
// 更新缓存
cache.set('fingerprint', result, { ttl })
return result
})().finally(() => {
inFlight = null
})
return inFlight
}
/**
* 清除指纹缓存(用于测试或强制刷新)
*/
export function clearFingerprintCache(): void {
cache.clear()
inFlight = null
}

View File

@@ -1,40 +0,0 @@
import { getDB } from '@/server/db'
import { licenseActivationTable } from '@/server/db/schema'
import { getHardwareFingerprint } from './fingerprint'
let initPromise: Promise<void> | null = null
export async function ensureLicenseActivationInitialized(): Promise<void> {
if (initPromise) return initPromise
initPromise = (async () => {
try {
const db = getDB()
const result = await getHardwareFingerprint({
cacheTtlMs: 10 * 60 * 1000,
includePrimaryDisk: true,
})
await db
.insert(licenseActivationTable)
.values({
fingerprint: result.fingerprint,
license: null,
licenseActivatedAt: null,
})
.onConflictDoUpdate({
target: licenseActivationTable.fingerprint,
set: {
updatedAt: new Date(),
},
})
} catch (error) {
console.error('Failed to initialize license activation:', error)
// 重置 promise 以便下次重试
initPromise = null
throw error
}
})()
return initPromise
}

View File

@@ -1,86 +0,0 @@
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { ORPCError, onError, ValidationError } from '@orpc/server'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { name, version } from '@/../package.json'
import { router } from '@/server/api/routers'
const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
docsProvider: 'scalar',
schemaConverters: [new ZodToJsonSchemaConverter()],
specGenerateOptions: {
info: {
title: name,
version,
},
// components: {
// securitySchemes: {
// bearerAuth: {
// type: 'http',
// scheme: 'bearer',
// },
// },
// },
},
docsPath: '/docs',
specPath: '/spec.json',
}),
],
interceptors: [
onError((error) => {
console.error(error)
}),
],
clientInterceptors: [
onError((error) => {
if (
error instanceof ORPCError &&
error.code === 'BAD_REQUEST' &&
error.cause instanceof ValidationError
) {
// If you only use Zod you can safely cast to ZodIssue[]
const zodError = new z.ZodError(
error.cause.issues as z.core.$ZodIssue[],
)
throw new ORPCError('INPUT_VALIDATION_FAILED', {
status: 422,
message: z.prettifyError(zodError),
data: z.flattenError(zodError),
cause: error.cause,
})
}
if (
error instanceof ORPCError &&
error.code === 'INTERNAL_SERVER_ERROR' &&
error.cause instanceof ValidationError
) {
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
cause: error.cause,
})
}
}),
],
})
export const Route = createFileRoute('/api/$')({
server: {
handlers: {
ANY: async ({ request }) => {
const { response } = await handler.handle(request, {
prefix: '/api',
context: {
headers: request.headers,
},
})
return response ?? new Response('Not Found', { status: 404 })
},
},
},
})

View File

@@ -1,301 +0,0 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { orpc } from '@/client/query-client'
export const Route = createFileRoute('/fingerprint')({
component: FingerprintPage,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(
orpc.fingerprint.get.queryOptions(),
)
},
})
function FingerprintPage() {
const query = useSuspenseQuery(orpc.fingerprint.get.queryOptions())
const [copied, setCopied] = useState(false)
const data = query.data
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [copied])
const handleCopy = async () => {
await navigator.clipboard.writeText(data.fingerprint)
setCopied(true)
}
const qualityConfig = {
strong: {
label: '强',
color: 'text-green-600',
bg: 'bg-green-50',
border: 'border-green-200',
icon: '✓',
description: '推荐用于生产授权',
},
medium: {
label: '中',
color: 'text-yellow-600',
bg: 'bg-yellow-50',
border: 'border-yellow-200',
icon: '!',
description: '可用但不理想',
},
weak: {
label: '弱',
color: 'text-red-600',
bg: 'bg-red-50',
border: 'border-red-200',
icon: '×',
description: '仅适合开发/测试',
},
}
const config = qualityConfig[data.quality]
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center space-y-3">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">
</h1>
<p className="text-slate-500 text-lg"></p>
</div>
{/* Main Card */}
<div className="bg-white rounded-3xl shadow-xl border border-slate-100 overflow-hidden">
{/* Quality Badge */}
<div className={`px-8 py-6 border-b ${config.bg} ${config.border}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`w-12 h-12 rounded-full ${config.bg} border-2 ${config.border} flex items-center justify-center text-2xl font-bold ${config.color}`}
>
{config.icon}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600">
</span>
<span
className={`px-3 py-1 rounded-full text-sm font-semibold ${config.bg} ${config.color} border ${config.border}`}
>
{config.label}
</span>
</div>
<p className="text-xs text-slate-500 mt-1">
{config.description}
</p>
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-slate-900">
{data.strongIdentifiersCount}
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
</div>
</div>
</div>
</div>
{/* Fingerprint Display */}
<div className="px-8 py-8">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-slate-700 uppercase tracking-wider">
</div>
<button
type="button"
onClick={handleCopy}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-all ${
copied
? 'bg-green-100 text-green-700 border-2 border-green-300'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 border-2 border-slate-200'
}`}
>
{copied ? '已复制 ✓' : '复制'}
</button>
</div>
<div className="relative group">
<div className="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl blur-sm opacity-0 group-hover:opacity-20 transition-opacity" />
<div className="relative bg-slate-900 rounded-xl p-6 font-mono text-sm break-all leading-relaxed text-slate-100 shadow-inner border-2 border-slate-800">
{data.fingerprint}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<title></title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>SHA-256 43 Base64URL </span>
</div>
</div>
</div>
{/* Metadata */}
<div className="px-8 py-6 bg-slate-50 border-t border-slate-100">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
</div>
<div className="text-lg font-medium text-slate-900">
{new Date(data.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider">
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-lg font-medium text-slate-900">
10
</span>
</div>
</div>
</div>
</div>
</div>
{/* Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-2xl shadow-md border border-slate-100 p-6 space-y-3">
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<title></title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h3 className="font-semibold text-slate-900"></h3>
<p className="text-sm text-slate-600 leading-relaxed">
使 HMAC-SHA256
</p>
</div>
<div className="bg-white rounded-2xl shadow-md border border-slate-100 p-6 space-y-3">
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
<svg
className="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<title></title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<h3 className="font-semibold text-slate-900"></h3>
<p className="text-sm text-slate-600 leading-relaxed">
UUID
</p>
</div>
<div className="bg-white rounded-2xl shadow-md border border-slate-100 p-6 space-y-3">
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center">
<svg
className="w-6 h-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<title></title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<h3 className="font-semibold text-slate-900"></h3>
<p className="text-sm text-slate-600 leading-relaxed">
</p>
</div>
</div>
{/* Usage Hint */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-2xl p-6">
<div className="flex gap-4">
<div className="flex-shrink-0">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<title></title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="space-y-2">
<h4 className="font-semibold text-blue-900">使</h4>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li></li>
<li></li>
<li>便</li>
</ul>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,46 +0,0 @@
import { createFileRoute, Link } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: Home,
})
function Home() {
return (
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-2xl mx-auto space-y-8 text-center">
<h1 className="text-4xl font-bold text-slate-900 tracking-tight">
License
</h1>
<p className="text-slate-500 text-lg">
使 TanStack Start + ORPC License
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-8">
<Link
to="/fingerprint"
className="p-6 bg-white rounded-2xl shadow-sm border border-slate-100 hover:shadow-md hover:border-indigo-500/50 transition-all text-left group"
>
<h2 className="text-xl font-semibold text-slate-900 group-hover:text-indigo-600 transition-colors">
</h2>
<p className="text-slate-500 mt-2 text-sm">
License
</p>
</Link>
<Link
to="/license"
className="p-6 bg-white rounded-2xl shadow-sm border border-slate-100 hover:shadow-md hover:border-indigo-500/50 transition-all text-left group"
>
<h2 className="text-xl font-semibold text-slate-900 group-hover:text-indigo-600 transition-colors">
License
</h2>
<p className="text-slate-500 mt-2 text-sm">
License
</p>
</Link>
</div>
</div>
</div>
)
}

View File

@@ -1,463 +0,0 @@
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { orpc } from '@/client/query-client'
export const Route = createFileRoute('/license')({
component: License,
})
function License() {
const [licenseInput, setLicenseInput] = useState('')
const [copySuccess, setCopySuccess] = useState(false)
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false)
const queryClient = useQueryClient()
// 获取激活状态
const { data } = useSuspenseQuery(orpc.license.getActivation.queryOptions())
// 激活 mutation
const activateMutation = useMutation({
...orpc.license.activate.mutationOptions(),
onSuccess: () => {
// 刷新数据
queryClient.invalidateQueries({
queryKey: orpc.license.getActivation.key(),
})
// 清空输入
setLicenseInput('')
},
})
// 反激活 mutation
const deactivateMutation = useMutation({
...orpc.license.deactivate.mutationOptions(),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: orpc.license.getActivation.key(),
})
setShowDeactivateConfirm(false)
},
})
const handleActivate = () => {
if (!licenseInput.trim()) return
activateMutation.mutate({ license: licenseInput.trim() })
}
const handleDeactivate = () => {
deactivateMutation.mutate()
}
const handleCopyFingerprint = async () => {
try {
await navigator.clipboard.writeText(data.fingerprint)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
} catch (err) {
console.error('Failed to copy fingerprint:', err)
}
}
const isActivated = !!data.license
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4 font-sans">
<div className="w-full max-w-2xl space-y-6">
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
License
</h1>
<p className="text-gray-500 mt-2"></p>
</div>
{/* 设备信息卡片 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-blue-500"
>
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>
</h2>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-100">
<p className="text-sm text-gray-500 mb-1">
(Device Fingerprint)
</p>
<div className="flex items-center gap-3">
<code className="flex-1 font-mono text-sm text-gray-700 break-all select-all">
{data.fingerprint}
</code>
<button
type="button"
onClick={handleCopyFingerprint}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5 ${
copySuccess
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
{copySuccess ? (
<>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</>
) : (
<>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
ry="2"
></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* License 激活卡片 */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-purple-500"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
License
</h2>
<div className="space-y-4">
<div>
<label
htmlFor="license-key"
className="block text-sm font-medium text-gray-700 mb-1"
>
License Key
</label>
<input
id="license-key"
type="text"
value={licenseInput}
onChange={(e) => setLicenseInput(e.target.value)}
disabled={isActivated || activateMutation.isPending}
placeholder={
isActivated ? '已激活,无需输入' : '请输入您的 License Key'
}
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 outline-none transition-all disabled:bg-gray-100 disabled:text-gray-500 placeholder:text-gray-400"
/>
</div>
{activateMutation.isError && (
<div className="p-3 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm flex items-start gap-2">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mt-0.5 shrink-0"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>激活失败: 请检查 License </span>
</div>
)}
<button
type="button"
onClick={handleActivate}
disabled={
isActivated ||
activateMutation.isPending ||
!licenseInput.trim()
}
className={`w-full px-6 py-2.5 rounded-lg font-medium text-white shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 ${
isActivated
? 'bg-gray-300 cursor-not-allowed'
: activateMutation.isPending
? 'bg-purple-500 opacity-80 cursor-wait'
: 'bg-purple-600 hover:bg-purple-700 hover:shadow-md active:scale-[0.99] focus:ring-purple-500'
}`}
>
{activateMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<svg
aria-hidden="true"
className="animate-spin h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
...
</span>
) : isActivated ? (
'已完成激活'
) : (
'立即激活'
)}
</button>
</div>
</div>
</div>
{/* 激活状态卡片 */}
<div
className={`rounded-xl shadow-sm border overflow-hidden transition-colors ${
isActivated
? 'bg-green-50/50 border-green-100'
: 'bg-white border-gray-100'
}`}
>
<div className="p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={isActivated ? 'text-green-500' : 'text-gray-400'}
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
</h2>
{isActivated ? (
<div className="space-y-3">
<div className="flex items-center gap-2 text-green-700 font-medium text-lg">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-green-500"
>
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path>
<path d="M9 12l2 2 4-4"></path>
</svg>
<span>License </span>
</div>
<div className="pl-8 space-y-1">
<p className="text-gray-600 text-sm">
<span className="font-medium text-gray-700">
License:{' '}
</span>
<span className="font-mono">{data.license}</span>
</p>
{data.licenseActivatedAt && (
<p className="text-gray-500 text-sm">
<span className="font-medium text-gray-700">
:{' '}
</span>
{new Date(data.licenseActivatedAt).toLocaleString(
'zh-CN',
{
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
},
)}
</p>
)}
</div>
{!showDeactivateConfirm ? (
<button
type="button"
onClick={() => setShowDeactivateConfirm(true)}
className="mt-4 px-4 py-2 bg-red-500 text-white rounded-lg text-sm font-medium hover:bg-red-600 transition-colors shadow-sm"
>
</button>
) : (
<div className="mt-4 p-4 bg-red-50 rounded-lg border border-red-200">
<p className="text-red-700 text-sm mb-3 flex items-center gap-2">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
License
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleDeactivate}
disabled={deactivateMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 disabled:bg-gray-300 transition-colors"
>
{deactivateMutation.isPending
? '反激活中...'
: '确认反激活'}
</button>
<button
type="button"
onClick={() => setShowDeactivateConfirm(false)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-300 transition-colors"
>
</button>
</div>
{deactivateMutation.isError && (
<p className="text-red-500 mt-2 text-xs">
</p>
)}
</div>
)}
</div>
) : (
<div className="flex items-start gap-3">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-orange-500 shrink-0 mt-0.5"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<div>
<p className="text-orange-700 font-medium text-lg mb-1">
</p>
<p className="text-gray-500 text-sm">
License Key
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import type { DB } from '@/server/db'
/**
* 基础 Context - 所有请求都包含的上下文
*/
export interface BaseContext {
headers: Headers
}
/**
* 数据库 Context - 通过 db middleware 扩展
*/
export interface DBContext extends BaseContext {
db: DB
}
/**
* 认证 Context - 通过 auth middleware 扩展(未来使用)
*
* @example
* export interface AuthContext extends DBContext {
* userId: string
* user: User
* }
*/

View File

@@ -1,26 +0,0 @@
import { oc } from '@orpc/contract'
import { z } from 'zod'
/**
* 硬件指纹质量等级
*/
const fingerprintQualitySchema = z.enum(['strong', 'medium', 'weak'])
/**
* 硬件指纹响应 Schema
*/
const fingerprintResultSchema = z.object({
/** 机器码HMAC-SHA256 哈希) */
fingerprint: z.string(),
/** 指纹质量等级 */
quality: fingerprintQualitySchema,
/** 可用的强标识符数量 */
strongIdentifiersCount: z.number(),
/** 生成时间戳 */
timestamp: z.number(),
})
/**
* 获取硬件指纹契约
*/
export const get = oc.input(z.void()).output(fingerprintResultSchema)

View File

@@ -1,9 +0,0 @@
import * as fingerprint from './fingerprint.contract'
import * as license from './license.contract'
export const contract = {
fingerprint,
license,
}
export type Contract = typeof contract

View File

@@ -1,18 +0,0 @@
import { oc } from '@orpc/contract'
import { z } from 'zod'
export const getActivation = oc.input(z.void()).output(
z.object({
fingerprint: z.string(),
license: z.string().nullable(),
licenseActivatedAt: z.number().nullable(),
}),
)
export const activate = oc
.input(z.object({ license: z.string().min(1) }))
.output(z.object({ success: z.boolean() }))
export const deactivate = oc
.input(z.void())
.output(z.object({ success: z.boolean() }))

View File

@@ -1,15 +0,0 @@
import { os } from '@orpc/server'
import { ensureLicenseActivationInitialized } from '@/lib/license-init'
import { getDB } from '@/server/db'
export const dbProvider = os.middleware(async ({ context, next }) => {
await ensureLicenseActivationInitialized()
return next({
context: {
...context,
db: getDB(),
},
})
})
export const db = dbProvider

View File

@@ -1 +0,0 @@
export * from './db.middleware'

View File

@@ -1,11 +0,0 @@
import { getHardwareFingerprint } from '@/lib/fingerprint'
import { os } from '../server'
export const get = os.fingerprint.get.handler(async () => {
const result = await getHardwareFingerprint({
cacheTtlMs: 10 * 60 * 1000, // 10 分钟缓存
includePrimaryDisk: true, // 包含主硬盘序列号以提高稳定性
})
return result
})

View File

@@ -1,8 +0,0 @@
import { os } from '../server'
import * as fingerprint from './fingerprint.router'
import * as license from './license.router'
export const router = os.router({
fingerprint,
license,
})

View File

@@ -1,67 +0,0 @@
import { eq } from 'drizzle-orm'
import { ensureLicenseActivationInitialized } from '@/lib/license-init'
import { licenseActivationTable } from '@/server/db/schema'
import { dbProvider } from '../middlewares'
import { os } from '../server'
export const getActivation = os.license.getActivation
.use(dbProvider)
.handler(async ({ context }) => {
await ensureLicenseActivationInitialized()
const record = await context.db.query.licenseActivationTable.findFirst()
if (!record) {
throw new Error('License activation record not found')
}
return {
fingerprint: record.fingerprint,
license: record.license,
licenseActivatedAt: record.licenseActivatedAt?.getTime() ?? null,
}
})
export const activate = os.license.activate
.use(dbProvider)
.handler(async ({ context, input }) => {
await ensureLicenseActivationInitialized()
const record = await context.db.query.licenseActivationTable.findFirst()
if (!record) {
throw new Error('License activation record not found')
}
await context.db
.update(licenseActivationTable)
.set({
license: input.license,
licenseActivatedAt: new Date(),
})
.where(eq(licenseActivationTable.id, record.id))
return { success: true }
})
export const deactivate = os.license.deactivate
.use(dbProvider)
.handler(async ({ context }) => {
await ensureLicenseActivationInitialized()
const record = await context.db.query.licenseActivationTable.findFirst()
if (!record) {
throw new Error('License activation record not found')
}
await context.db
.update(licenseActivationTable)
.set({
license: null,
licenseActivatedAt: null,
})
.where(eq(licenseActivationTable.id, record.id))
return { success: true }
})

View File

@@ -1,5 +0,0 @@
import { implement } from '@orpc/server'
import type { BaseContext } from './context'
import { contract } from './contracts'
export const os = implement(contract).$context<BaseContext>()

View File

@@ -1,30 +0,0 @@
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { env } from '@/env'
import * as schema from '@/server/db/schema'
export const createDB = () => {
const dbPath = env.DATABASE_URL
if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true })
}
const sqlite = new Database(dbPath)
return drizzle(sqlite, { schema })
}
export type DB = ReturnType<typeof createDB>
export const getDB = (() => {
let db: DB | null = null
return (singleton = true): DB => {
if (!singleton) {
return createDB()
}
db ??= createDB()
return db
}
})()

View File

@@ -1 +0,0 @@
export * from './license-activation'

View File

@@ -1,9 +0,0 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { generatedFields } from './utils/field'
export const licenseActivationTable = sqliteTable('license_activation', {
...generatedFields,
fingerprint: text('fingerprint').notNull().unique(),
license: text('license'),
licenseActivatedAt: integer('license_activated_at', { mode: 'timestamp_ms' }),
})

View File

@@ -1,46 +0,0 @@
import { integer, text } from 'drizzle-orm/sqlite-core'
import { v7 as uuidv7 } from 'uuid'
// id
export const id = (name: string) => text(name)
export const pk = (name: string) =>
id(name)
.primaryKey()
.$defaultFn(() => uuidv7())
// timestamp
export const createdAt = (name = 'created_at') =>
integer(name, { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
export const updatedAt = (name = 'updated_at') =>
integer(name, { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
.$onUpdateFn(() => new Date())
// generated fields
export const generatedFields = {
id: pk('id'),
createdAt: createdAt('created_at'),
updatedAt: updatedAt('updated_at'),
}
// Helper to create omit keys from generatedFields
const createGeneratedFieldKeys = <T extends Record<string, unknown>>(
fields: T,
): Record<keyof T, true> => {
return Object.keys(fields).reduce(
(acc, key) => {
acc[key as keyof T] = true
return acc
},
{} as Record<keyof T, true>,
)
}
export const generatedFieldKeys = createGeneratedFieldKeys(generatedFields)

View File

@@ -1,9 +0,0 @@
{
"extends": "@furtherverse/tsconfig/react.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -1,10 +0,0 @@
{
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
"compile": {
"dependsOn": ["build"],
"outputs": ["out/**"]
}
}
}

View File

@@ -6,6 +6,7 @@
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"includes": ["**", "!**/routeTree.gen.ts"],
"ignoreUnknown": false "ignoreUnknown": false
}, },
"formatter": { "formatter": {

View File

@@ -6,6 +6,14 @@ import { Console, Context, Data, Effect, Layer } from 'effect'
// Domain Models & Schema // Domain Models & Schema
// ============================================================================ // ============================================================================
const targetMap = {
'bun-windows-x64': 'x86_64-pc-windows-msvc',
'bun-darwin-arm64': 'aarch64-apple-darwin',
'bun-darwin-x64': 'x86_64-apple-darwin',
'bun-linux-x64': 'x86_64-unknown-linux-gnu',
'bun-linux-arm64': 'aarch64-unknown-linux-gnu',
} as const
const BunTargetSchema = Schema.Literal( const BunTargetSchema = Schema.Literal(
'bun-windows-x64', 'bun-windows-x64',
'bun-darwin-arm64', 'bun-darwin-arm64',
@@ -14,19 +22,11 @@ const BunTargetSchema = Schema.Literal(
'bun-linux-arm64', 'bun-linux-arm64',
) )
/**
* bun target ( 'bun-' )
*/
const getTargetSuffix = (target: BunTarget): string => {
return target.replace('bun-', '')
}
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema> type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
const BuildConfigSchema = Schema.Struct({ const BuildConfigSchema = Schema.Struct({
entrypoint: Schema.String.pipe(Schema.nonEmptyString()), entrypoint: Schema.String.pipe(Schema.nonEmptyString()),
outputDir: Schema.String.pipe(Schema.nonEmptyString()), outputDir: Schema.String.pipe(Schema.nonEmptyString()),
outfile: Schema.String.pipe(Schema.nonEmptyString()),
targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)), targets: Schema.Array(BunTargetSchema).pipe(Schema.minItems(1)),
}) })
@@ -94,10 +94,10 @@ class BuildConfigService extends Context.Tag('BuildConfigService')<
BuildConfigService, BuildConfigService,
BuildConfigService.fromRaw({ BuildConfigService.fromRaw({
entrypoint: '.output/server/index.mjs', entrypoint: '.output/server/index.mjs',
outputDir: 'out', // outputDir: 'out',
outfile: 'server', outputDir: 'src-tauri/binaries',
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'], targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
} satisfies BuildConfig), }),
) )
} }
@@ -150,7 +150,7 @@ class BuildService extends Context.Tag('BuildService')<
Bun.build({ Bun.build({
entrypoints: [config.entrypoint], entrypoints: [config.entrypoint],
compile: { compile: {
outfile: `${config.outfile}-${getTargetSuffix(target)}`, outfile: `app-${targetMap[target]}`,
target: target, target: target,
}, },
outdir: config.outputDir, outdir: config.outputDir,
@@ -181,7 +181,7 @@ class BuildService extends Context.Tag('BuildService')<
Bun.build({ Bun.build({
entrypoints: [config.entrypoint], entrypoints: [config.entrypoint],
compile: { compile: {
outfile: `${config.outfile}-${getTargetSuffix(target)}`, outfile: `app-${targetMap[target]}`,
target: target, target: target,
}, },
outdir: config.outputDir, outdir: config.outputDir,

735
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
[install]
publicHoistPattern = ["@types/*", "bun-types", "nitro*"]

View File

@@ -1,11 +1,10 @@
import { defineConfig } from 'drizzle-kit' import { defineConfig } from 'drizzle-kit'
import { env } from '@/env'
export default defineConfig({ export default defineConfig({
out: './drizzle', out: './drizzle',
schema: './src/server/db/schema/index.ts', schema: './src/db/schema/index.ts',
dialect: 'sqlite', dialect: 'sqlite',
dbCredentials: { dbCredentials: {
url: env.DATABASE_URL, url: './data/app.db',
}, },
}) })

0
drizzle/.gitkeep Normal file
View File

View File

@@ -1,4 +1,4 @@
[tools] [tools]
node = "latest" node = "24"
bun = "1" bun = "1"
rust = 'latest' rust = 'latest'

View File

@@ -1,65 +1,59 @@
{ {
"name": "@furtherverse/monorepo", "name": "fullstack-starter",
"version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo build:tauri",
"compile": "turbo run compile", "build:compile": "bun build.ts",
"deploy": "turbo run deploy", "build:tauri": "tauri build",
"dev": "turbo run dev", "build:vite": "vite build",
"fix": "turbo run fix", "db:generate": "drizzle-kit generate",
"typecheck": "turbo run typecheck" "db:init": "bun run scripts/init-db.ts",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "turbo dev:tauri",
"dev:tauri": "tauri dev",
"dev:vite": "vite dev",
"fix": "biome check --write",
"typecheck": "tsc -b"
}, },
"devDependencies": { "dependencies": {
"@biomejs/biome": "^2.3.12",
"turbo": "^2.7.6"
},
"catalog": {
"@biomejs/biome": "^2.3.11",
"@effect/platform": "^0.94.2",
"@effect/schema": "^0.75.5",
"@isaacs/ttlcache": "^2.1.4",
"@orpc/client": "^1.13.4", "@orpc/client": "^1.13.4",
"@orpc/contract": "^1.13.4", "@orpc/contract": "^1.13.4",
"@orpc/openapi": "^1.13.4",
"@orpc/server": "^1.13.4", "@orpc/server": "^1.13.4",
"@orpc/tanstack-query": "^1.13.4", "@orpc/tanstack-query": "^1.13.4",
"@orpc/zod": "^1.13.4", "@orpc/zod": "^1.13.4",
"@t3-oss/env-core": "^0.13.10", "@t3-oss/env-core": "^0.13.10",
"@tanstack/react-query": "^5.90.18",
"@tanstack/react-router": "^1.151.0",
"@tanstack/react-router-ssr-query": "^1.151.0",
"@tanstack/react-start": "^1.151.0",
"@tauri-apps/api": "^2.9.1",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/devtools-vite": "^0.4.1", "@tanstack/devtools-vite": "^0.4.1",
"@tanstack/react-devtools": "^0.9.2", "@tanstack/react-devtools": "^0.9.2",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-query-devtools": "^5.91.2",
"@tanstack/react-router": "^1.157.14", "@tanstack/react-router-devtools": "^1.151.0",
"@tanstack/react-router-devtools": "^1.157.14", "@tauri-apps/cli": "^2.9.6",
"@tanstack/react-router-ssr-query": "^1.157.14",
"@tanstack/react-start": "^1.157.14",
"@types/better-sqlite3": "^7.6.12",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.6",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1", "effect": "^3.19.14",
"drizzle-zod": "^0.8.3", "nitro": "npm:nitro-nightly@latest",
"effect": "^3.19.15",
"nitro": "npm:nitro-nightly@3.0.1-20260125-215009-8882bc9e",
"ohash": "^2.0.11",
"better-sqlite3": "^11.8.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"turbo": "^2.7.5", "turbo": "^2.7.5",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"uuid": "^13.0.0", "vite": "^8.0.0-beta.8",
"systeminformation": "^5.30.6", "vite-tsconfig-paths": "^6.0.4"
"vite": "^8.0.0-beta.10",
"vite-tsconfig-paths": "^6.0.5",
"zod": "^4.3.6"
} }
} }

View File

@@ -1,26 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Base",
"compilerOptions": {
"target": "esnext",
"lib": ["ESNext"],
"module": "preserve",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
},
"exclude": ["node_modules"]
}

View File

@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Bun",
"extends": "./base.json",
"compilerOptions": {
"types": ["bun-types"]
}
}

View File

@@ -1,11 +0,0 @@
{
"name": "@furtherverse/tsconfig",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
"./base.json": "./base.json",
"./bun.json": "./bun.json",
"./react.json": "./react.json"
}
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "React",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react-jsx"
}
}

22
scripts/init-db.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Database } from 'bun:sqlite'
import { mkdir } from 'node:fs/promises'
// Ensure data directory exists
await mkdir('./data', { recursive: true })
const db = new Database('./data/app.db', { create: true })
db.exec('PRAGMA journal_mode = WAL;')
// Create todo table
db.exec(`
CREATE TABLE IF NOT EXISTS todo (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
`)
console.log('Database initialized successfully!')
db.close()

10
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# 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/

357
src-tauri/AGENTS.md Normal file
View File

@@ -0,0 +1,357 @@
# AGENTS.md - Tauri Shell 项目开发指南
本文档为 AI 编程助手和开发者提供项目规范、构建命令和代码风格指南。
## 项目概览
- **项目类型**: Tauri v2 桌面应用(轻量级壳子)
- **后端**: Rust (Edition 2021)
- **架构**: Sidecar 模式 - Sidecar App 承载主要业务逻辑
- **设计理念**: Tauri 仅提供原生桌面能力文件对话框、系统通知等Web 逻辑全部由 Sidecar App 处理
- **开发模式**: 使用 localhost:3000需手动启动开发服务器
- **生产模式**: 自动启动 Sidecar 二进制
- **异步运行时**: Tokio
- **Rust 版本**: 1.92.0+
- **工具管理**: 使用 mise 管理 Rust 和 Tauri CLI 版本(见 `mise.toml`
## 构建、测试、运行命令
### 开发运行
```bash
# 开发模式运行 (需要先启动开发服务器)
# 终端 1: 启动前端开发服务器
bun run dev
# 终端 2: 启动 Tauri 应用
tauri dev
# 或者使用单命令并行启动(需要配置 package.json
bun run dev:tauri
```
**开发模式说明**
- 开发模式下Tauri 直接连接到 `localhost:3000`(不启动 sidecar 二进制)
- 需要手动运行 `bun run dev` 来启动开发服务器
- 支持热重载HMR无需重启 Tauri 应用
### 构建
```bash
# 开发构建 (debug mode)
cargo build
# 生产构建
cargo build --release
# Tauri 应用打包 (生成安装程序)
tauri build
```
### 代码检查
```bash
# 编译检查 (不生成二进制)
cargo check
# Clippy 代码质量检查
cargo clippy
# Clippy 严格模式 (所有警告视为错误)
cargo clippy -- -D warnings
# 代码格式化检查
cargo fmt -- --check
# 自动格式化代码
cargo fmt
```
### 测试
```bash
# 运行所有测试
cargo test
# 运行单个测试 (按名称过滤)
cargo test test_function_name
# 运行特定模块的测试
cargo test module_name::
# 显示测试输出 (包括 println!)
cargo test -- --nocapture
# 运行单个测试并显示输出
cargo test test_name -- --nocapture
```
### 清理
```bash
# 清理构建产物
cargo clean
```
## 项目结构
```
app-desktop/
├── src/
│ ├── main.rs # 入口文件 (仅调用 lib::run)
│ ├── lib.rs # 核心应用逻辑 (注册插件、命令、状态)
│ ├── commands/
│ │ └── mod.rs # 原生桌面功能命令 (文件对话框、通知等)
│ └── sidecar.rs # Sidecar 进程管理 (启动、端口扫描、清理)
├── binaries/ # Sidecar 二进制文件
│ └── app-* # Sidecar App 可执行文件 (示例: app)
├── capabilities/ # Tauri v2 权限配置
│ └── default.json
├── icons/ # 应用图标资源
├── gen/schemas/ # 自动生成的 Schema (不要手动编辑)
├── Cargo.toml # Rust 项目配置
├── tauri.conf.json # Tauri 应用配置
├── build.rs # Rust 构建脚本
└── mise.toml # 开发工具版本管理
```
## Rust 代码风格指南
### 导入 (Imports)
- 使用标准库、外部 crate、当前 crate 的顺序,用空行分隔
- 按字母顺序排列
- 优先使用具体导入而非通配符 `*`
```rust
// ✅ 推荐
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::{CommandEvent, CommandChild};
// ❌ 避免
use tauri::*;
```
### 命名规范
- **函数和变量**: `snake_case`
- **类型、结构体、枚举、Trait**: `PascalCase`
- **常量和静态变量**: `SCREAMING_SNAKE_CASE`
- **生命周期参数**: 简短小写字母,如 `'a`, `'b`
```rust
// ✅ 推荐
struct SidecarProcess(Mutex<Option<CommandChild>>);
const DEFAULT_PORT: u16 = 3000;
async fn find_available_port(start: u16) -> u16 { }
// ❌ 避免
struct sidecar_process { }
const defaultPort: u16 = 3000;
```
### 类型注解
- 函数参数必须有类型注解
- 函数返回值必须明确声明 (除非返回 `()`)
- 优先使用具体类型而非 `impl Trait` (除非必要)
- 使用 `&str` 而非 `String` 作为只读字符串参数
```rust
// ✅ 推荐
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
async fn is_port_available(port: u16) -> bool {
tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.is_ok()
}
```
### 错误处理
- 使用 `Result<T, E>` 返回可能失败的操作
- 使用 `expect()` 时提供有意义的错误消息 (中文)
- 避免 `unwrap()` 在生产代码中,除非逻辑上保证不会 panic
- 使用 `?` 操作符传播错误
- 记录关键错误信息到控制台
```rust
// ✅ 推荐
let sidecar = app_handle
.shell()
.sidecar("app")
.expect("无法找到 app sidecar");
let (mut rx, child) = sidecar.spawn().expect("启动 sidecar 失败");
// 日志记录
eprintln!("✗ Sidecar App 启动失败");
println!("✓ Sidecar App 启动成功!");
// ❌ 避免
let data = read_file().unwrap(); // 无上下文信息
```
### 异步代码
- 使用 `async/await` 而非手动创建 Future
- Tauri 内部使用 `tauri::async_runtime::spawn` 启动异步任务
- 使用 Tokio 的异步 API (如 `tokio::net::TcpListener`)
- 避免阻塞异步运行时 (使用 `tokio::task::spawn_blocking`)
```rust
// ✅ 推荐
tauri::async_runtime::spawn(async move {
let port = find_available_port(3000).await;
// ...
});
```
### 格式化
- 使用 `cargo fmt` 自动格式化
- 缩进: 4 空格
- 行宽: 100 字符 (rustfmt 默认)
- 结构体和枚举的字段每行一个 (如果超过一定长度)
- 链式调用适当换行提高可读性
### 注释
- 使用中文注释说明复杂逻辑
- 代码块前添加简短说明注释
- 避免显而易见的注释
```rust
// ✅ 推荐
// 全局状态:存储 Sidecar App 进程句柄
struct SidecarProcess(Mutex<Option<CommandChild>>);
// 检查端口是否可用
async fn is_port_available(port: u16) -> bool { }
```
## Tauri 特定规范
### 模块组织
- **`lib.rs`**: 主入口,负责注册插件、命令、状态管理
- **`commands/mod.rs`**: 所有 Tauri 命令集中定义,命令必须是 `pub fn`
- **`sidecar.rs`**: Sidecar 进程管理逻辑,导出公共 API`spawn_sidecar`, `cleanup_sidecar_process`
```rust
// lib.rs - 模块声明
mod commands;
mod sidecar;
use sidecar::SidecarProcess;
// 注册命令时使用模块路径
.invoke_handler(tauri::generate_handler![commands::greet])
```
### 命令定义
- 使用 `#[tauri::command]` 宏标记命令
- 命令函数必须是公开的或在 `invoke_handler` 中注册
- 参数类型必须实现 `serde::Deserialize`
- 返回类型必须实现 `serde::Serialize`
```rust
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// 在 Builder 中注册
.invoke_handler(tauri::generate_handler![greet])
```
### 状态管理
- 使用 `app.manage()` 注册全局状态
- 状态必须实现 `Send + Sync`
- 使用 `Mutex``RwLock` 保证线程安全
```rust
struct SidecarProcess(Mutex<Option<CommandChild>>);
// 注册状态
app.manage(SidecarProcess(Mutex::new(None)));
// 访问状态
if let Some(state) = app_handle.try_state::<SidecarProcess>() {
*state.0.lock().unwrap() = Some(child);
}
```
### Sidecar 进程管理
- Sidecar 二进制必须在 `tauri.conf.json``bundle.externalBin` 中声明
- 使用 `app.shell().sidecar()` 启动 sidecar
- 在应用退出时清理子进程 (监听 `RunEvent::ExitRequested`)
```rust
// 启动 sidecar
let sidecar = app_handle
.shell()
.sidecar("app")
.expect("无法找到 app sidecar")
.env("PORT", port.to_string());
// 清理进程
match event {
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => {
if let Some(child) = process.take() {
let _ = child.kill();
}
}
_ => {}
}
```
## 依赖管理
-`Cargo.toml` 中明确声明依赖版本
- 使用语义化版本 (如 `"2"` 表示兼容 2.x.x)
- 仅启用需要的 feature 以减少编译时间和二进制大小
```toml
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["net"] }
```
## 开发工具
推荐安装以下 VSCode 扩展:
- `tauri-apps.tauri-vscode` - Tauri 官方支持
- `rust-lang.rust-analyzer` - Rust 语言服务器
## 最佳实践
1. **开发环境配置**:
- 开发模式下需先启动前端开发服务器(`bun run dev`),再启动 Tauri`tauri dev`
- 生产构建自动打包 sidecar 二进制,无需额外配置
2. **进程生命周期**: 始终在应用退出时清理子进程和资源
3. **端口管理**:
- 开发模式固定使用 3000 端口(与开发服务器匹配)
- 生产模式使用端口扫描避免硬编码端口冲突
4. **超时处理**: 异步操作设置合理的超时时间 (如 5 秒)
5. **日志**: 使用表情符号 (✓/✗/🔧/🚀) 和中文消息提供清晰的状态反馈
6. **错误退出**: 关键错误时调用 `std::process::exit(1)`
7. **窗口配置**: 使用 `WebviewWindowBuilder` 动态创建窗口
## 提交代码前检查清单
- [ ] `cargo fmt` 格式化通过
- [ ] `cargo clippy` 无警告
- [ ] `cargo check` 编译通过
- [ ] `cargo test` 测试通过
- [ ] 更新相关注释和文档
- [ ] 检查是否有 `unwrap()` 需要替换为 `expect()`
- [ ] 验证 Tauri 应用正常启动和退出

4773
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,24 @@
[package]
name = "app-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 = "app_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"] }

3
src-tauri/build.rs Normal file
View File

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

View File

@@ -0,0 +1,27 @@
{
"$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
}
]
}
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

33
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,33 @@
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);
}
});
}

6
src-tauri/src/main.rs Normal file
View File

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

166
src-tauri/src/sidecar.rs Normal file
View File

@@ -0,0 +1,166 @@
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("app")
.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 进程已终止");
}
}
}
}

25
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "app-desktop",
"version": "0.1.0",
"identifier": "com.imbytecat.app-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/app"]
}
}

69
src/db/index.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* 数据库连接模块
*
* 使用 Bun 内置的 SQLite 驱动,无需额外安装原生模块。
* 数据库文件存储在可执行文件同级的 data/app.db
*/
import { Database } from 'bun:sqlite'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { drizzle } from 'drizzle-orm/bun-sqlite'
import * as schema from '@/db/schema'
/**
* 获取数据库路径
* - 在打包后的 sidecar 中,使用可执行文件所在目录
* - 在开发模式下,使用项目根目录
*/
function getDbPath(): string {
const execPath = process.execPath
const isBundled = !execPath.includes('node') && !execPath.includes('bun')
const baseDir = isBundled ? dirname(execPath) : process.cwd()
const dataDir = join(baseDir, 'data')
// 确保 data 目录存在
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true })
}
return join(dataDir, 'app.db')
}
/** 数据库文件路径 */
const DB_PATH = getDbPath()
/**
* 初始化数据库表结构
*/
function initTables(sqlite: Database) {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS todo (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
`)
}
/**
* 创建数据库连接
*
* 启用 WAL (Write-Ahead Logging) 模式以提高并发读写性能。
* 如果数据库文件不存在,会自动创建并初始化表结构。
*/
export function createDb() {
const sqlite = new Database(DB_PATH, { create: true })
sqlite.exec('PRAGMA journal_mode = WAL;')
// 自动初始化表结构
initTables(sqlite)
return drizzle(sqlite, { schema })
}
/** 数据库实例类型 */
export type Db = ReturnType<typeof createDb>

1
src/db/schema/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './todo'

33
src/db/schema/todo.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Todo 表 Schema
*
* 使用 SQLite 数据类型:
* - text: 字符串类型
* - integer: 整数类型 (可配置为 boolean/timestamp 模式)
*/
import { sql } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const todoTable = sqliteTable('todo', {
/** 主键 UUID */
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
/** 待办事项标题 */
title: text('title').notNull(),
/** 是否已完成 (SQLite 用 0/1 表示布尔值) */
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
/** 创建时间 (Unix 时间戳) */
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
/** 更新时间 (Unix 时间戳,自动更新) */
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`)
.$onUpdateFn(() => new Date()),
})

View File

@@ -2,9 +2,7 @@ import { createEnv } from '@t3-oss/env-core'
import { z } from 'zod' import { z } from 'zod'
export const env = createEnv({ export const env = createEnv({
server: { server: {},
DATABASE_URL: z.string().min(1),
},
clientPrefix: 'VITE_', clientPrefix: 'VITE_',
client: { client: {
VITE_APP_TITLE: z.string().min(1).optional(), VITE_APP_TITLE: z.string().min(1).optional(),

View File

@@ -0,0 +1,7 @@
import type { TanStackDevtoolsReactPlugin } from '@tanstack/react-devtools'
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
export const devtools = {
name: 'TanStack Query',
render: <ReactQueryDevtoolsPanel />,
} satisfies TanStackDevtoolsReactPlugin

View File

@@ -0,0 +1 @@
export * from './devtools'

View File

@@ -0,0 +1,7 @@
import type { TanStackDevtoolsReactPlugin } from '@tanstack/react-devtools'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
export const devtools = {
name: 'TanStack Router',
render: <TanStackRouterDevtoolsPanel />,
} satisfies TanStackDevtoolsReactPlugin

View File

@@ -0,0 +1 @@
export * from './devtools'

0
src/lib/utils.ts Normal file
View File

84
src/orpc/client.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* ORPC 同构客户端
*
* 根据运行环境自动选择最优调用方式:
* - SSR (服务端): 直接调用 router无 HTTP 开销
* - CSR (客户端): 通过 /api/rpc 端点 HTTP 调用
*
* 同时配置了 TanStack Query 集成mutation 成功后自动刷新相关查询。
*/
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import { createRouterClient } from '@orpc/server'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { createIsomorphicFn } from '@tanstack/react-start'
import { getRequestHeaders } from '@tanstack/react-start/server'
import { router } from './router'
import type { RouterClient } from './types'
/**
* 创建同构 ORPC 客户端
*
* 服务端: 直接调用路由处理器
* 客户端: 通过 HTTP 调用 /api/rpc 端点
*/
const getORPCClient = createIsomorphicFn()
.server(() =>
createRouterClient(router, {
context: () => ({
headers: getRequestHeaders(),
}),
}),
)
.client(() => {
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
})
return createORPCClient<RouterClient>(link)
})
const client: RouterClient = getORPCClient()
/**
* ORPC + TanStack Query 工具
*
* 使用方式:
* ```tsx
* // 查询
* const { data } = useSuspenseQuery(orpc.todo.list.queryOptions())
*
* // 变更
* const mutation = useMutation(orpc.todo.create.mutationOptions())
* mutation.mutate({ title: '新任务' })
* ```
*
* 配置了自动缓存失效: 创建/更新/删除操作后自动刷新列表
*/
export const orpc = createTanstackQueryUtils(client, {
// 配置 mutation 成功后自动刷新相关查询
experimental_defaults: {
todo: {
create: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
update: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
remove: {
mutationOptions: {
onSuccess: (_, __, ___, ctx) => {
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
},
},
},
},
},
})

5
src/orpc/contract.ts Normal file
View File

@@ -0,0 +1,5 @@
import * as todo from './contracts/todo'
export const contract = {
todo,
}

Some files were not shown because too many files have changed in this diff Show More