57 Commits

Author SHA1 Message Date
10c2d61523 fix(desktop): use CJS for preload script to fix sandbox loading error 2026-02-09 02:17:57 +08:00
18ce05854a feat(server): add NODE_ENV to shared env schema 2026-02-09 01:59:45 +08:00
7eccef5d8f chore(desktop): remove redundant config fields for KISS 2026-02-09 01:41:34 +08:00
41667cb33b refactor(desktop): simplify main process logic and improve naming
- Remove logLifecycle wrapper, inline the conditional logging
- Remove redundant shouldAbortWindowLoad check before final loadURL
- Rename getServerUrl to resolveServerUrl to reflect side effects
- Add .catch on createWindow to prevent silent async failures
2026-02-09 01:27:29 +08:00
00c944e1b5 refactor(desktop): 精简主进程启动与退出逻辑并减少打包态日志噪音 2026-02-09 01:13:08 +08:00
f9edfd0058 fix(desktop): guard shutdown race and kill sidecar process tree 2026-02-09 00:57:30 +08:00
9aea89e16d fix(desktop): force app exit on windows window close 2026-02-09 00:40:01 +08:00
26b74b25f2 fix(desktop): use stdio ignore for sidecar to prevent process hang on quit
Piped stdio handles kept the event loop alive on Windows after killing
the sidecar process, preventing the Electron app from exiting.
2026-02-09 00:23:05 +08:00
ccf220fc29 fix(desktop): ensure sidecar process stops on app shutdown 2026-02-08 23:59:54 +08:00
a585069cdc refactor: rename compile:mac/win to compile:darwin/windows to match Bun target names 2026-02-08 23:39:30 +08:00
b149cc5dc0 refactor: decentralize turbo task config — move compile/dist to workspace turbo.json
Sink package-specific tasks from root turbo.json into workspace configs:
- compile/compile:* → apps/server/turbo.json (only server compiles binaries)
- dist/dist:* → apps/desktop/turbo.json (only desktop distributes)
- Cross-package deps (desktop→server#compile) owned by desktop config
- Desktop dist scripts no longer bypass Turbo by calling bun run build

Root turbo.json reduced from 16 to 4 generic lifecycle tasks.
2026-02-08 23:26:24 +08:00
9f38636d76 refactor(server): use Bun.Build.CompileTarget and derive host target instead of manual map 2026-02-08 22:47:39 +08:00
63906ec09b refactor(server): use util.parseArgs for declarative CLI arg parsing in compile.ts 2026-02-08 22:38:49 +08:00
8c4e4ad150 refactor(server): use type guard to eliminate as Target casts in compile.ts 2026-02-08 22:33:02 +08:00
e171db8196 refactor: simplify compile.ts to single-target and add per-platform compile scripts
- Rewrite compile.ts from 112 to 66 lines: single target with auto-detect host, remove multi-target batch logic
- Add compile:linux/mac/win scripts to server, root, and turbo configs
- Wire desktop dist:* to depend on matching server compile:* (avoid unnecessary cross-platform compilation)
- Update AGENTS.md docs across root, server, and desktop
2026-02-08 22:25:30 +08:00
dac6bb1643 refactor: 统一打包命令为 dist 体系,build 仅编译不打包
- build:linux/mac/win → dist/dist:linux/dist:mac/dist:win
- Turbo 任务依赖:desktop#dist:* → server#compile → server#build
- 根目录 bun dist 一条命令完成完整打包流水线
- 更新 AGENTS.md 文档同步命令变更
2026-02-08 20:48:58 +08:00
8c0ea632d7 style(desktop): 规范 package.json 字段排序 2026-02-08 20:31:17 +08:00
db23ee42fc chore: 更新 lockfile 依赖版本 2026-02-08 20:26:16 +08:00
0784546e50 fix(desktop): 修复 artifactName 路径错误并移除 deb 构建目标
scoped package name 中的 / 导致 ${name} 作为路径出错,改用 ${productName};
移除 deb target(fpm 依赖不可用);补充 package.json 元信息字段
2026-02-08 20:03:47 +08:00
2fe3e15659 refactor: 重命名 build.ts → compile.ts,统一脚本与文件命名
- compile 脚本调用 compile.ts,消除与 build 的歧义
- desktop turbo.json 添加 build outputs 缓存配置
2026-02-08 19:46:04 +08:00
ed02993350 style(desktop): loading 页面改为白色轻盈主题 2026-02-08 19:40:20 +08:00
e4e5ff2211 chore: 移除 useSortedClasses 规则,等待 nursery 毕业后再启用 2026-02-08 19:19:18 +08:00
d69a573a33 feat: 为 desktop/server 添加 Tailwind CSS 支持和 Biome 集成
- desktop renderer 接入 @tailwindcss/vite,loading 页面改用 Tailwind 类
- 两个 app 添加 biome.json 继承配置:tailwindDirectives + useSortedClasses
2026-02-08 19:17:53 +08:00
6cc1bc6834 refactor(desktop): 用类型收窄替代 as AddressInfo 断言 2026-02-08 19:03:13 +08:00
894fd17d1a fix(desktop): 动态分配 sidecar 端口替代硬编码,避免端口冲突
使用 net.createServer().listen(0) 探测可用端口,通过 PORT 环境变量
传递给 sidecar binary(VS Code language server 同款模式)
2026-02-08 18:38:45 +08:00
888f20fdab fix(desktop): 加载 loading 页面作为启动屏,配置平台级 extraResources 对接交叉编译
- main process 启动时先加载 renderer/index.html 显示 spinner
- electron-builder 按平台引用 server/out/ 下对应架构的 sidecar binary
- 移除 mise.toml 中无关的 rust 工具配置
2026-02-08 18:21:40 +08:00
7318600e20 refactor(desktop): 替换 WebUI 为 Electron + electron-vite 桌面壳方案
- 使用 electron-vite 构建 main/preload,electron-builder 打包分发
- main process: dev 模式直连 localhost:3000,生产模式 spawn sidecar binary
- 添加 loading 页面,server 就绪前显示加载动画
- 更新 catalog 依赖: electron, electron-vite, electron-builder
- 移除 @webui-dev/bun-webui 依赖
2026-02-08 18:16:13 +08:00
e8e473b357 refactor(desktop): 替换 Electrobun 为 WebUI 作为桌面窗口方案
Electrobun 太不稳定,改用 webui-dev/webui(轻量 C 库,~300KB)通过
系统浏览器或 WebView 提供桌面窗口。已验证 bun:ffi 加载和
bun build --compile 均正常工作。

- 移除 electrobun 依赖和配置
- 添加 @webui-dev/bun-webui 依赖
- 重写桌面入口为 WebUI 窗口方案
- 移除 Conveyor 打包工具(mise.toml)
2026-02-08 04:15:34 +08:00
41d97ca312 refactor(deps): 将 @hydraulic/conveyor 从 bun 依赖迁移到 mise 全局工具 2026-02-08 03:21:38 +08:00
cfe7de2a70 chore(deps): 添加 @hydraulic/conveyor 依赖到 desktop 应用 2026-02-08 03:11:43 +08:00
b87de26e17 chore(deps): 升级 TanStack devtools 和 vite-tsconfig-paths 依赖版本 2026-02-08 02:41:22 +08:00
b8d38872ad refactor(desktop): 优化 Electrobun 构建配置
- 从 package.json 动态读取版本号替代硬编码
- 启用所有平台的 CEF 捆绑
- 将构建目标从配置文件移至 CLI 参数
- 启用 asar 打包并为 dev 脚本添加 --env=dev 标志
2026-02-08 02:38:37 +08:00
7450c685d3 chore: 移除已完成的 electrobun 生产模式计划文档 2026-02-08 00:40:43 +08:00
2048f73155 refactor(server): 按照官方推荐顺序重排 Vite 插件并清理冗余配置 2026-02-07 22:13:16 +08:00
70b5d27493 chore(desktop): 添加 win-x64 构建目标 2026-02-07 21:04:39 +08:00
5d5d3a51f6 chore(desktop): 禁用 mac 和 win 平台的 CEF 捆绑 2026-02-07 20:57:58 +08:00
3306e18395 refactor(desktop): 使用预分配端口替代 stdout 解析获取服务器端口 2026-02-07 19:32:56 +08:00
14bcdb33af chore(deps): 升级 TanStack 路由和 Start 依赖版本 2026-02-07 19:11:28 +08:00
cc81d95178 chore(desktop): 升级 electrobun 至 1.12.0-beta.1 2026-02-07 19:10:33 +08:00
55d45e6a49 docs(desktop): 更新 AGENTS.md 文档与开发计划以反映最新实现 2026-02-07 18:49:50 +08:00
b7a6a793a3 feat(desktop): 实现生产模式下的内嵌服务器子进程支持 2026-02-07 18:49:41 +08:00
6b12745e50 chore(desktop): 更新应用名称、标识符和版本号 2026-02-07 17:46:00 +08:00
989d8973f5 chore(desktop): 简化构建和开发脚本 2026-02-07 17:16:32 +08:00
41e79449ce docs: 更新 AGENTS.md 适配 Electrobun 替代 Tauri 2026-02-07 17:00:52 +08:00
4bbb0c4a16 refactor(server): simplify build script, remove Effect dependency 2026-02-07 16:44:56 +08:00
2b3026cf69 chore(turbo): simplify and optimize monorepo configuration 2026-02-07 16:35:30 +08:00
adb14cff77 chore: 重构 Turbo 构建配置并强化 Bun 专用说明
将应用特定的构建输出配置下沉至各自 turbo.json,根级 build 任务添加拓扑依赖;AGENTS.md 统一添加 Bun 专用运行时警告;桌面端启用 Linux CEF 渲染器。
2026-02-07 16:14:55 +08:00
44ca7a0f5e chore: 扩展 Turbo build 任务的输出目录配置 2026-02-07 07:06:55 +08:00
59b4edc2d2 chore(desktop): 降级 electrobun 至 0.8.0 稳定版 2026-02-07 06:59:19 +08:00
9d0e9a6aac chore(desktop): 添加 .gitignore 和多平台构建脚本 2026-02-07 06:29:08 +08:00
f758fd5947 chore: 更新 bun.lock 锁文件 2026-02-07 06:15:52 +08:00
26f9421130 chore: 统一 Node/TypeScript 配置并修复桌面端类型环境 2026-02-07 05:53:38 +08:00
29969550ed 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 文档
2026-02-07 05:04:53 +08:00
9aa3b46ee5 chore(desktop): 更新 Cargo 依赖 2026-02-07 03:45:08 +08:00
f3ea0f0789 chore: 更新依赖版本 2026-02-07 03:32:42 +08:00
bde325d9ae chore: 更新 biome 和 turbo 依赖版本 2026-02-07 03:31:01 +08:00
e41c4e4515 docs: 更新 AGENTS.md 文档结构和内容
- 新增根目录 AGENTS.md 作为 monorepo 总览
- 移动 desktop AGENTS.md 从 src-tauri/ 到 apps/desktop/
- 修正 server AGENTS.md 目录结构 (src/server/api/ 而非 src/orpc/)
- 明确 desktop 为纯 Tauri 壳子,无前端代码,通过 sidecar 加载 server
2026-02-07 03:29:51 +08:00
65 changed files with 2163 additions and 2886 deletions

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.

195
AGENTS.md Normal file
View File

@@ -0,0 +1,195 @@
# AGENTS.md - AI Coding Agent Guidelines
Guidelines for AI agents working in this Bun monorepo.
## Project Overview
> **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. All commands start with `bun` — use `bun install` for dependencies and `bun run` / `bun <script>` for scripts. Never use `npm`, `npx`, or `node`.**
- **Monorepo**: Bun workspaces + Turborepo orchestration
- **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js**
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Apps**:
- `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
- `apps/desktop` - Electron desktop shell, sidecar server pattern (see `apps/desktop/AGENTS.md`)
- **Packages**: `packages/utils`, `packages/tsconfig` (shared configs)
## Build / Lint / Test Commands
### Root Commands (via Turbo)
```bash
bun dev # Start all apps in dev mode
bun build # Build all apps
bun compile # Compile server to standalone binary (current platform, depends on build)
bun compile:linux # Compile server for Linux x64
bun compile:mac # Compile server for macOS arm64
bun compile:win # Compile server for Windows x64
bun dist # Full packaging pipeline: server build → compile → desktop distributable (current platform)
bun dist:linux # Full pipeline → Linux distributable
bun dist:mac # Full pipeline → macOS distributable
bun dist:win # Full pipeline → Windows distributable
bun fix # Lint + format (Biome auto-fix)
bun typecheck # TypeScript check across monorepo
```
### Server App (`apps/server`)
```bash
bun dev # Vite dev server (localhost:3000)
bun build # Production build -> .output/
bun compile # Compile to standalone binary (current platform)
bun compile:linux # Compile for Linux x64
bun compile:mac # Compile for macOS arm64
bun compile:win # Compile for Windows x64
bun fix # Biome auto-fix
bun typecheck # TypeScript check
# Database (Drizzle)
bun db:generate # Generate migrations from schema
bun db:migrate # Run migrations
bun db:push # Push schema (dev only)
bun db:studio # Open Drizzle Studio
```
### Desktop App (`apps/desktop`)
```bash
bun dev # electron-vite dev mode (requires server dev running)
bun build # electron-vite build (main + preload)
bun dist # Build + package for current platform
bun dist:linux # Build + package for Linux
bun dist:mac # Build + package for macOS
bun dist:win # Build + package for Windows
bun fix # Biome auto-fix
bun typecheck # TypeScript check
```
### Testing
No test framework configured yet. When adding tests:
```bash
bun test path/to/test.ts # Run single test file
bun test -t "pattern" # Run tests matching pattern
```
## Code Style (TypeScript)
### Formatting (Biome)
- **Indent**: 2 spaces | **Line endings**: LF
- **Quotes**: Single `'` | **Semicolons**: Omit (ASI)
- **Arrow parentheses**: Always `(x) => x`
### Imports
Biome auto-organizes. Order: 1) External packages → 2) Internal `@/*` aliases → 3) Type imports (`import type { ... }`)
```typescript
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { db } from '@/server/db'
import type { ReactNode } from 'react'
```
### TypeScript Strictness
- `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`, `verbatimModuleSyntax: true`
- Use `@/*` path aliases (maps to `src/*`)
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Files (utils) | kebab-case | `auth-utils.ts` |
| Files (components) | PascalCase | `UserProfile.tsx` |
| Components | PascalCase arrow | `const Button = () => {}` |
| Functions | camelCase | `getUserById` |
| Constants | UPPER_SNAKE | `MAX_RETRIES` |
| Types/Interfaces | PascalCase | `UserProfile` |
### React Patterns
- Components: arrow functions (enforced by Biome)
- Routes: TanStack Router file conventions (`export const Route = createFileRoute(...)`)
- Data fetching: `useSuspenseQuery(orpc.feature.list.queryOptions())`
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
### Error Handling
- Use `try-catch` for async operations; throw descriptive errors
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`)
- Never use empty catch blocks
## Database (Drizzle ORM)
```typescript
export const myTable = pgTable('my_table', {
id: uuid().primaryKey().default(sql`uuidv7()`),
name: text().notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
})
```
## Environment Variables
- Use `@t3-oss/env-core` with Zod validation in `src/env.ts`
- Server vars: no prefix | Client vars: `VITE_` prefix required
- Never commit `.env` files
## Dependency Management
- All versions centralized in root `package.json` `catalog` field
- Workspace packages use `"catalog:"` — never hardcode versions
- Internal packages use `"workspace:*"` references
## Critical Rules
**DO:**
- Run `bun fix` before committing
- Use `@/*` path aliases (not relative imports)
- Include `createdAt`/`updatedAt` on all tables
- Use `catalog:` for dependency versions
**DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
- Edit `src/routeTree.gen.ts` (auto-generated)
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
- Commit `.env` files
- Use empty catch blocks `catch(e) {}`
- Hardcode dependency versions in workspace packages
## Git Workflow
1. Make changes following style guide
2. `bun fix` - auto-format and lint
3. `bun typecheck` - verify types
4. `bun dev` - test locally
5. Commit with descriptive message
## Directory Structure
```
.
├── apps/
│ ├── server/ # TanStack Start fullstack app
│ │ ├── src/
│ │ │ ├── client/ # ORPC client, Query client
│ │ │ ├── components/
│ │ │ ├── routes/ # File-based routing
│ │ │ └── server/ # API layer + database
│ │ │ ├── api/ # ORPC contracts, routers, middlewares
│ │ │ └── db/ # Drizzle schema
│ │ └── AGENTS.md
│ └── desktop/ # Electron desktop shell
│ ├── src/
│ │ ├── main/
│ │ │ └── index.ts # Main process entry
│ │ └── preload/
│ │ └── index.ts # Preload script
│ ├── electron.vite.config.ts
│ ├── electron-builder.yml # Packaging config
│ └── AGENTS.md
├── packages/
│ ├── tsconfig/ # Shared TS configs
│ └── utils/ # Shared utilities
├── biome.json # Linting/formatting config
├── turbo.json # Turbo task orchestration
└── package.json # Workspace root + dependency catalog
```
## See Also
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns
- `apps/desktop/AGENTS.md` - Electron desktop development guide

7
apps/desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Electron-vite build output
out/
dist/
# Sidecar binary (copied from apps/server build)
resources/server
resources/server.exe

81
apps/desktop/AGENTS.md Normal file
View File

@@ -0,0 +1,81 @@
# AGENTS.md - Desktop App Guidelines
Thin Electron shell hosting the fullstack server app.
## Tech Stack
> **⚠️ This project uses Bun as the package manager. Runtime is Electron (Node.js). Never use `npm`, `npx`, `yarn`, or `pnpm`.**
- **Type**: Electron desktop shell
- **Design**: Server-driven desktop (thin native window hosting web app)
- **Runtime**: Electron (Main/Renderer) + Sidecar server binary (Bun-compiled)
- **Build Tool**: electron-vite (Vite-based, handles main + preload builds)
- **Packager**: electron-builder (installers, signing, auto-update)
- **Orchestration**: Turborepo
## Architecture
- **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it opens a BrowserWindow pointing to the `apps/server` TanStack Start application.
- **Dev mode**: Opens a BrowserWindow pointing to `localhost:3000`. Requires `apps/server` to be running separately (Turbo handles this).
- **Production mode**: Spawns a compiled server binary (from `resources/`) as a sidecar process, waits for readiness, then loads its URL.
## Commands
```bash
bun dev # electron-vite dev (requires server dev running)
bun build # electron-vite build (main + preload)
bun dist # Build + package for current platform
bun dist:linux # Build + package for Linux
bun dist:mac # Build + package for macOS
bun dist:win # Build + package for Windows
bun fix # Biome auto-fix
bun typecheck # TypeScript check
```
## Directory Structure
```
.
├── src/
│ ├── main/
│ │ └── index.ts # Main process (server lifecycle + BrowserWindow)
│ └── preload/
│ └── index.ts # Preload script (security isolation)
├── resources/ # Sidecar binaries (gitignored, copied from server build)
├── out/ # electron-vite build output (gitignored)
├── electron.vite.config.ts
├── electron-builder.yml # Packaging configuration
├── package.json
├── turbo.json
└── AGENTS.md
```
## Development Workflow
1. **Start server**: `bun dev` in `apps/server` (or use root `bun dev` via Turbo).
2. **Start desktop**: `bun dev` in `apps/desktop`.
3. **Connection**: Main process polls `localhost:3000` until responsive, then opens BrowserWindow.
## Production Build Workflow
From monorepo root, run `bun dist` to execute the full pipeline automatically (via Turbo task dependencies):
1. **Build server**: `apps/server``vite build``.output/`
2. **Compile server**: `apps/server``bun compile.ts``out/server-{os}-{arch}`
3. **Package desktop**: `apps/desktop``electron-vite build` + `electron-builder` → distributable
The `electron-builder.yml` `extraResources` config reads binaries directly from `../server/out/`, no manual copy needed.
To build for a specific platform explicitly, use `bun dist:linux` / `bun dist:mac` / `bun dist:win` in `apps/desktop`.
## Critical Rules
**DO:**
- Use arrow functions for all utility functions.
- Keep the desktop app as a thin shell — no UI or business logic.
- Use `catalog:` for all dependency versions in `package.json`.
**DON'T:**
- Use `npm`, `npx`, `yarn`, or `pnpm`. Use `bun` for package management.
- Include UI components or business logic in the desktop app.
- Use `as any` or `@ts-ignore`.

9
apps/desktop/biome.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": "//",
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

View File

@@ -0,0 +1,36 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/electron-userland/electron-builder/refs/heads/master/packages/app-builder-lib/scheme.json
appId: com.furtherverse.app
productName: Furtherverse
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.env,.env.*,bun.lock}"
- "!{tsconfig.json,tsconfig.node.json}"
- "!{AGENTS.md,README.md,CHANGELOG.md}"
asarUnpack:
- resources/**
win:
extraResources:
- from: ../server/out/server-windows-x64.exe
to: server.exe
nsis:
artifactName: ${productName}-${version}-setup.${ext}
mac:
category: public.app-category.productivity
extraResources:
- from: ../server/out/server-darwin-arm64
to: server
dmg:
artifactName: ${productName}-${version}.${ext}
linux:
target:
- AppImage
maintainer: furtherverse.com
category: Utility
extraResources:
- from: ../server/out/server-linux-x64
to: server
appImage:
artifactName: ${productName}-${version}.${ext}
npmRebuild: false

View File

@@ -0,0 +1,10 @@
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'electron-vite'
export default defineConfig({
main: {},
preload: {},
renderer: {
plugins: [tailwindcss()],
},
})

29
apps/desktop/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@furtherverse/desktop",
"version": "1.0.0",
"private": true,
"main": "out/main/index.js",
"scripts": {
"build": "electron-vite build",
"dev": "electron-vite dev",
"dist": "electron-builder --config",
"dist:linux": "electron-builder --linux --config",
"dist:mac": "electron-builder --mac --config",
"dist:win": "electron-builder --win --config",
"fix": "biome check --write",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"tree-kill": "catalog:"
},
"devDependencies": {
"@furtherverse/tsconfig": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
"electron": "catalog:",
"electron-builder": "catalog:",
"electron-vite": "catalog:",
"tailwindcss": "catalog:"
}
}

View File

@@ -0,0 +1,191 @@
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { join } from 'node:path'
import { app, BrowserWindow, shell } from 'electron'
import killProcessTree from 'tree-kill'
const DEV_SERVER_URL = 'http://localhost:3000'
let mainWindow: BrowserWindow | null = null
let serverProcess: ReturnType<typeof spawn> | null = null
let isQuitting = false
const shouldAbortWindowLoad = (): boolean =>
isQuitting || !mainWindow || mainWindow.isDestroyed()
const getAvailablePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const addr = server.address()
if (!addr || typeof addr === 'string') {
server.close()
reject(new Error('Failed to resolve port'))
return
}
server.close(() => resolve(addr.port))
})
server.on('error', reject)
})
const isServerReady = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, { method: 'HEAD' })
return response.ok
} catch {
return false
}
}
const waitForServer = async (
url: string,
timeoutMs = 15_000,
): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < timeoutMs && !isQuitting) {
if (await isServerReady(url)) return true
await new Promise<void>((resolve) => setTimeout(resolve, 200))
}
return false
}
const stopServerProcess = () => {
if (!serverProcess) {
return
}
const runningServer = serverProcess
serverProcess = null
if (!runningServer.pid || runningServer.exitCode !== null) {
return
}
killProcessTree(runningServer.pid, (error?: Error) => {
if (error) {
console.error('Failed to stop server process:', error)
}
})
}
const spawnServer = (port: number): string => {
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
const binaryPath = join(process.resourcesPath, binaryName)
serverProcess = spawn(binaryPath, [], {
env: {
...process.env,
PORT: String(port),
HOST: '127.0.0.1',
},
stdio: 'ignore',
})
serverProcess.unref()
serverProcess.on('error', (err) => {
console.error('Failed to start server:', err)
})
serverProcess.on('exit', () => {
serverProcess = null
})
return `http://127.0.0.1:${port}`
}
const resolveServerUrl = async (): Promise<string | null> => {
if (!app.isPackaged) {
return DEV_SERVER_URL
}
const port = await getAvailablePort()
if (isQuitting) {
return null
}
return spawnServer(port)
}
const createWindow = async () => {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
},
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.on('closed', () => {
mainWindow = null
})
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.show()
const serverUrl = await resolveServerUrl()
if (!serverUrl || shouldAbortWindowLoad()) {
stopServerProcess()
return
}
if (!app.isPackaged) console.log(`Waiting for server at ${serverUrl}...`)
const ready = await waitForServer(serverUrl)
if (shouldAbortWindowLoad()) {
stopServerProcess()
return
}
if (!ready) {
console.error(
app.isPackaged
? 'Server binary did not start in time.'
: 'Dev server not responding. Run `bun dev` in apps/server first.',
)
app.quit()
return
}
mainWindow.loadURL(serverUrl)
}
const beginQuit = () => {
isQuitting = true
stopServerProcess()
}
app
.whenReady()
.then(createWindow)
.catch((e) => {
console.error('Failed to create window:', e)
app.quit()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
beginQuit()
app.quit()
}
})
app.on('activate', () => {
if (!isQuitting && BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
app.on('before-quit', () => {
beginQuit()
})

View File

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

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furtherverse</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body class="bg-white h-screen w-screen flex flex-col items-center justify-center overflow-hidden select-none cursor-default font-sans antialiased m-0">
<div class="flex flex-col items-center gap-8 animate-fade-in">
<h1 class="text-3xl font-medium tracking-tight text-zinc-900">Furtherverse</h1>
<div class="w-24 h-[2px] bg-zinc-100 rounded-full overflow-hidden relative">
<div class="h-full w-full bg-zinc-800 origin-left animate-loading-bar"></div>
</div>
<div class="text-[10px] uppercase tracking-widest text-zinc-400 font-medium animate-pulse-slow">Starting</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,40 @@
@import "tailwindcss";
@theme {
--animate-fade-in: fade-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-pulse-slow: pulse-slow 3s ease-in-out infinite;
--animate-loading-bar: loading-bar 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse-slow {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 0.8;
}
}
@keyframes loading-bar {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(0);
}
100% {
transform: translateX(100%);
}
}

View File

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

25
apps/desktop/turbo.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": ["out/**"]
},
"dist": {
"dependsOn": ["build", "@furtherverse/server#compile"],
"outputs": ["dist/**"]
},
"dist:linux": {
"dependsOn": ["build", "@furtherverse/server#compile:linux"],
"outputs": ["dist/**"]
},
"dist:mac": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin"],
"outputs": ["dist/**"]
},
"dist:win": {
"dependsOn": ["build", "@furtherverse/server#compile:windows"],
"outputs": ["dist/**"]
}
}
}

View File

@@ -1 +1 @@
DATABASE_URL=./data/app.db
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres

View File

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

View File

@@ -1,420 +1,208 @@
# AGENTS.md - AI Coding Agent Guidelines
# AGENTS.md - Server App Guidelines
本文档为 AI 编程助手提供此 TanStack Start 全栈项目的开发规范和指南。
TanStack Start fullstack web app with ORPC (contract-first RPC).
## 项目概览
## Tech Stack
- **框架**: TanStack Start (React SSR 框架,文件路由)
- **运行时**: Bun
- **语言**: TypeScript (strict mode, ESNext)
- **样式**: Tailwind CSS v4
- **数据库**: SQLite + Drizzle ORM
- **状态管理**: TanStack Query
- **路由**: TanStack Router (文件路由)
- **RPC**: ORPC (类型安全 RPC契约优先)
- **构建工具**: Vite + Turbo
- **代码质量**: Biome (格式化 + Lint)
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Never use `npm`, `npx`, or `node`.**
## 依赖管理
- **Framework**: TanStack Start (React 19 SSR, file-based routing)
- **Runtime**: Bun — **NOT Node.js**
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
- **Language**: TypeScript (strict mode)
- **Styling**: Tailwind CSS v4
- **Database**: PostgreSQL + Drizzle ORM
- **State**: TanStack Query v5
- **RPC**: ORPC (contract-first, type-safe)
- **Build**: Vite + Nitro
项目使用 **Bun Catalog** 统一管理依赖版本。
## Commands
> **详细流程**: 加载 skill `bun-catalog-package` 获取完整指南。
**快速参考**
```bash
bun info <pkg> version # 查询最新版本
bun add <pkg>@catalog: # 在子包中安装
# Development
bun dev # Vite dev server (localhost:3000)
bun db:studio # Drizzle Studio GUI
# Build
bun build # Production build → .output/
bun compile # Compile to standalone binary (current platform, depends on build)
bun compile:linux # Compile for Linux x64
bun compile:mac # Compile for macOS arm64
bun compile:win # Compile for Windows x64
# Code Quality
bun fix # Biome auto-fix
bun typecheck # TypeScript check
# Database
bun db:generate # Generate migrations from schema
bun db:migrate # Run migrations
bun db:push # Push schema directly (dev only)
# Testing (not yet configured)
bun test path/to/test.ts # Run single test
bun test -t "pattern" # Run tests matching pattern
```
## 构建、Lint 和测试命令
## Directory Structure
### 开发
```bash
bun dev # 启动 Vite 开发服务器
bun db:studio # 打开 Drizzle Studio 数据库管理界面
```
src/
├── client/ # Client-side code
│ ├── orpc.client.ts # ORPC isomorphic client
│ └── query-client.ts # TanStack Query client
├── components/ # React components
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout
│ ├── index.tsx # Home page
│ └── api/
│ └── rpc.$.ts # ORPC HTTP endpoint
├── server/ # Server-side code
│ ├── api/ # ORPC layer
│ │ ├── contracts/ # Input/output schemas (Zod)
│ │ ├── middlewares/ # Middleware (db provider, auth)
│ │ ├── routers/ # Handler implementations
│ │ ├── context.ts # Request context
│ │ ├── server.ts # ORPC server instance
│ │ └── types.ts # Type exports
│ └── db/
│ ├── schema/ # Drizzle table definitions
│ └── index.ts # Database instance
├── env.ts # Environment variable validation
├── router.tsx # Router configuration
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind entry
```
### 构建
```bash
bun build # 构建 Vite 应用 (输出到 .output/)
bun compile # 编译为独立可执行文件 (使用 build.ts)
```
## ORPC Pattern
### 代码质量
```bash
bun typecheck # 运行 TypeScript 类型检查
bun fix # 运行 Biome 自动修复格式和 Lint 问题
biome check . # 检查但不自动修复
biome format --write . # 仅格式化代码
```
### 数据库
```bash
bun db:generate # 从 schema 生成迁移文件
bun db:migrate # 执行数据库迁移
bun db:push # 直接推送 schema 变更 (仅开发环境)
```
### 测试
**注意**: 当前未配置测试框架。添加测试时:
- 使用 Vitest 或 Bun 内置测试运行器
- 运行单个测试文件: `bun test path/to/test.ts`
- 运行特定测试: `bun test -t "测试名称模式"`
## 代码风格指南
### 格式化 (Biome)
**缩进**: 2 空格 (不使用 tab)
**换行符**: LF (Unix 风格)
**引号**: 单引号 `'string'`
**分号**: 按需 (ASI - 自动分号插入)
**箭头函数括号**: 始终使用 `(x) => x`
示例:
### 1. Define Contract (`src/server/api/contracts/feature.contract.ts`)
```typescript
const myFunc = (value: string) => {
return value.toUpperCase()
}
import { oc } from '@orpc/contract'
import { createSelectSchema } from 'drizzle-zod'
import { z } from 'zod'
import { featureTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(featureTable)
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
```
### 导入组织
### 2. Implement Router (`src/server/api/routers/feature.router.ts`)
```typescript
import { ORPCError } from '@orpc/server'
import { db } from '../middlewares'
import { os } from '../server'
Biome 自动组织导入。顺序:
1. 外部依赖
2. 内部导入 (使用 `@/*` 别名)
3. 类型导入 (仅导入类型时使用 `type` 关键字)
export const list = os.feature.list.use(db).handler(async ({ context }) => {
return await context.db.query.featureTable.findMany()
})
```
### 3. Register in Index Files
```typescript
// src/server/api/contracts/index.ts
import * as feature from './feature.contract'
export const contract = { feature }
// src/server/api/routers/index.ts
import * as feature from './feature.router'
export const router = os.router({ feature })
```
### 4. Use in Components
```typescript
import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
import { orpc } from '@/client/orpc.client'
const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
const mutation = useMutation(orpc.feature.create.mutationOptions())
```
## Database Schema (Drizzle)
```typescript
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
export const myTable = pgTable('my_table', {
id: uuid().primaryKey().default(sql`uuidv7()`),
name: text().notNull(),
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
})
```
## Code Style
### Formatting (Biome)
- **Indent**: 2 spaces
- **Quotes**: Single `'`
- **Semicolons**: Omit (ASI)
- **Arrow parens**: Always `(x) => x`
### Imports
Biome auto-organizes:
1. External packages
2. Internal `@/*` aliases
3. Type imports (`import type { ... }`)
示例:
```typescript
import { createFileRoute } from '@tanstack/react-router'
import { oc } from '@orpc/contract'
import { z } from 'zod'
import { db } from '@/db'
import { todoTable } from '@/db/schema'
import { db } from '@/server/db'
import type { ReactNode } from 'react'
```
### TypeScript
**严格模式**: 启用了额外的严格检查
- `strict: true`
- `noUncheckedIndexedAccess: true` - 数组/对象索引返回 `T | undefined`
- `noImplicitOverride: true`
- `noFallthroughCasesInSwitch: true`
- `noUncheckedIndexedAccess: true` - array access returns `T | undefined`
- Use `@/*` path aliases (maps to `src/*`)
**模块解析**: `bundler` 模式 + `verbatimModuleSyntax`
- 导入时始终使用 `.ts`/`.tsx` 扩展名
- 使用 `@/*` 路径别名指向 `src/*`
### Naming
| Type | Convention | Example |
|------|------------|---------|
| Files (utils) | kebab-case | `auth-utils.ts` |
| Files (components) | PascalCase | `UserProfile.tsx` |
| Components | PascalCase arrow | `const Button = () => {}` |
| Functions | camelCase | `getUserById` |
| Types | PascalCase | `UserProfile` |
**类型注解**:
- 公共 API 的函数参数和返回类型必须注解
- 优先使用显式类型而非 `any`
- 对象形状用 `type`,可扩展契约用 `interface`
- 不可变 props 使用 `Readonly<T>`
### React
- Use arrow functions for components (Biome enforced)
- Use `useSuspenseQuery` for guaranteed data
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
### 命名规范
## Environment Variables
- **文件**: 工具函数用 kebab-case组件用 PascalCase
- `utils.ts`, `todo.tsx`, `NotFound.tsx`
- **路由**: 遵循 TanStack Router 约定
- `routes/index.tsx``/`
- `routes/__root.tsx` → 根布局
- **组件**: PascalCase 箭头函数 (Biome 规则 `useArrowFunction` 强制)
- **函数**: camelCase
- **常量**: 真常量用 UPPER_SNAKE_CASE配置对象用 camelCase
- **类型/接口**: PascalCase
### React 模式
**组件**: 使用箭头函数
```typescript
const MyComponent = ({ title }: { title: string }) => {
return <div>{title}</div>
}
```
**路由**: 使用 `createFileRoute` 定义路由
```typescript
export const Route = createFileRoute('/')({
component: Home,
})
```
**数据获取**: 使用 TanStack Query hooks
- `useSuspenseQuery` - 保证有数据
- `useQuery` - 数据可能为空
**Props**: 禁止直接修改 props (Biome 规则 `noReactPropAssignments`)
### 数据库 Schema (Drizzle)
-`src/server/db/schema/*.ts` 定义 schema
-`src/server/db/schema/index.ts` 导出
- 使用 `drizzle-orm/sqlite-core` 的 SQLite 类型
- 主键使用 `uuidv7()` (TEXT 存储)
- 时间戳使用 `integer({ mode: 'timestamp_ms' })` (Unix 毫秒时间戳)
- 始终包含 `createdAt``updatedAt` 时间戳
示例:
```typescript
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { v7 as uuidv7 } from 'uuid'
export const myTable = sqliteTable('my_table', {
id: text('id').primaryKey().$defaultFn(() => uuidv7()),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdateFn(() => new Date()),
})
```
### 环境变量
- 使用 `@t3-oss/env-core` 进行类型安全的环境变量验证
-`src/env.ts` 定义 schema
- 服务端变量: 无前缀
- 客户端变量: 必须有 `VITE_` 前缀
- 使用 Zod schema 验证
### 错误处理
- 异步操作使用 try-catch
- 抛出带有描述性消息的错误
- 用户界面错误优先使用 Result 类型或错误边界
- 适当记录错误 (避免记录敏感数据)
### 样式 (Tailwind CSS)
- 使用 Tailwind v4 工具类
- 通过 `@/styles.css?url` 导入样式
- 优先使用组合而非自定义 CSS
- 响应式修饰符: `sm:`, `md:`, `lg:`
- UI 文本适当使用中文
## 目录结构
```
src/
├── components/ # 可复用 React 组件
├── db/
│ ├── schema/ # Drizzle schema 定义
│ └── index.ts # 数据库实例
├── integrations/ # 第三方集成 (TanStack Query/Router)
├── lib/ # 工具函数
├── orpc/ # ORPC (RPC 层)
│ ├── contracts/ # 契约定义 (input/output schemas)
│ ├── handlers/ # 服务端过程实现
│ ├── middlewares/ # 中间件 (如 DB provider)
│ ├── contract.ts # 契约聚合
│ ├── router.ts # 路由组合
│ ├── server.ts # 服务端实例
│ └── client.ts # 同构客户端
├── routes/ # TanStack Router 文件路由
│ ├── __root.tsx # 根布局
│ ├── index.tsx # 首页
│ └── api/rpc.$.ts # ORPC HTTP 端点
├── env.ts # 环境变量验证
└── router.tsx # 路由配置
```
## 重要提示
- **禁止** 编辑 `src/routeTree.gen.ts` - 自动生成
- **禁止** 提交 `.env` 文件 - 使用 `.env.example` 作为模板
- **必须** 在提交前运行 `bun fix`
- **必须** 使用 `@/*` 路径别名而非相对导入
- **必须** 利用 React Compiler (babel-plugin-react-compiler) - 避免手动 memoization
## Git 工作流
1. 按照上述风格指南进行修改
2. 运行 `bun fix` 自动格式化和 lint
3. 运行 `bun typecheck` 确保类型安全
4. 使用 `bun dev` 本地测试变更
5. 使用清晰的描述性消息提交
## 常见模式
### 创建 ORPC 过程
**步骤 1: 定义契约** (`src/orpc/contracts/my-feature.ts`)
```typescript
import { oc } from '@orpc/contract'
// src/env.ts - using @t3-oss/env-core
import { createEnv } from '@t3-oss/env-core'
import { z } from 'zod'
export const myContract = {
get: oc.input(z.object({ id: z.uuid() })).output(mySchema),
create: oc.input(createSchema).output(mySchema),
}
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
clientPrefix: 'VITE_',
client: {
VITE_API_URL: z.string().optional(),
},
})
```
**步骤 2: 实现处理器** (`src/orpc/handlers/my-feature.ts`)
```typescript
import { os } from '@/orpc/server'
import { dbProvider } from '@/orpc/middlewares'
## Critical Rules
export const get = os.myFeature.get
.use(dbProvider)
.handler(async ({ context, input }) => {
return await context.db.query.myTable.findFirst(...)
})
```
**DO:**
- Run `bun fix` before committing
- Use `@/*` path aliases
- Include `createdAt`/`updatedAt` on all tables
- Use `ORPCError` with proper codes
**步骤 3: 注册到契约和路由**
```typescript
// src/orpc/contract.ts
export const contract = { myFeature: myContract }
// src/orpc/router.ts
import * as myFeature from './handlers/my-feature'
export const router = os.router({ myFeature })
```
**步骤 4: 在组件中使用**
```typescript
import { orpc } from '@/orpc'
const query = useSuspenseQuery(orpc.myFeature.get.queryOptions({ id }))
const mutation = useMutation(orpc.myFeature.create.mutationOptions())
```
---
## 已知问题与解决方案
### 构建问题
**已解决**: 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 依赖版本
**DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
- Edit `src/routeTree.gen.ts` (auto-generated)
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
- Commit `.env` files
- Use empty catch blocks

View File

@@ -3,5 +3,10 @@
"extends": "//",
"files": {
"includes": ["**", "!**/routeTree.gen.ts"]
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

View File

@@ -1,289 +0,0 @@
import { Schema } from '@effect/schema'
import { $ } from 'bun'
import { Console, Context, Data, Effect, Layer } from 'effect'
// ============================================================================
// Domain Models & Schema
// ============================================================================
const BunTargetSchema = Schema.Literal(
'bun-windows-x64',
'bun-darwin-arm64',
'bun-darwin-x64',
'bun-linux-x64',
'bun-linux-arm64',
)
/**
* 将 bun target 转换为文件后缀 (去掉 'bun-' 前缀)
*/
const getTargetSuffix = (target: BunTarget): string => {
return target.replace('bun-', '')
}
type BunTarget = Schema.Schema.Type<typeof BunTargetSchema>
const BuildConfigSchema = Schema.Struct({
entrypoint: 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)),
})
type BuildConfig = Schema.Schema.Type<typeof BuildConfigSchema>
const BuildResultSchema = Schema.Struct({
target: BunTargetSchema,
outputs: Schema.Array(Schema.String),
})
type BuildResult = Schema.Schema.Type<typeof BuildResultSchema>
// ============================================================================
// Error Models (使用 Data.TaggedError)
// ============================================================================
class CleanError extends Data.TaggedError('CleanError')<{
readonly dir: string
readonly cause: unknown
}> {}
class BuildError extends Data.TaggedError('BuildError')<{
readonly target: BunTarget
readonly cause: unknown
}> {}
class ConfigError extends Data.TaggedError('ConfigError')<{
readonly message: string
readonly cause: unknown
}> {}
// ============================================================================
// Services
// ============================================================================
/**
* 配置服务
*/
class BuildConfigService extends Context.Tag('BuildConfigService')<
BuildConfigService,
BuildConfig
>() {
/**
* 从原始数据创建并验证配置
*/
static fromRaw = (raw: unknown) =>
Effect.gen(function* () {
const decoded = yield* Schema.decodeUnknown(BuildConfigSchema)(raw)
return decoded
}).pipe(
Effect.catchAll((error) =>
Effect.fail(
new ConfigError({
message: '配置验证失败',
cause: error,
}),
),
),
)
/**
* 默认配置 Layer
*/
static readonly Live = Layer.effect(
BuildConfigService,
BuildConfigService.fromRaw({
entrypoint: '.output/server/index.mjs',
outputDir: 'out',
outfile: 'server',
targets: ['bun-windows-x64', 'bun-darwin-arm64', 'bun-linux-x64'],
} satisfies BuildConfig),
)
}
/**
* 文件系统服务
*/
class FileSystemService extends Context.Tag('FileSystemService')<
FileSystemService,
{
readonly cleanDir: (dir: string) => Effect.Effect<void, CleanError>
}
>() {
static readonly Live = Layer.succeed(FileSystemService, {
cleanDir: (dir: string) =>
Effect.tryPromise({
try: async () => {
await $`rm -rf ${dir}`
},
catch: (cause: unknown) =>
new CleanError({
dir,
cause,
}),
}),
})
}
/**
* 构建服务
*/
class BuildService extends Context.Tag('BuildService')<
BuildService,
{
readonly buildForTarget: (
config: BuildConfig,
target: BunTarget,
) => Effect.Effect<BuildResult, BuildError>
readonly buildAll: (
config: BuildConfig,
) => Effect.Effect<ReadonlyArray<BuildResult>, BuildError>
}
>() {
static readonly Live = Layer.succeed(BuildService, {
buildForTarget: (config: BuildConfig, target: BunTarget) =>
Effect.gen(function* () {
yield* Console.log(`🔨 开始构建: ${target}`)
const output = yield* Effect.tryPromise({
try: () =>
Bun.build({
entrypoints: [config.entrypoint],
compile: {
outfile: `${config.outfile}-${getTargetSuffix(target)}`,
target: target,
},
outdir: config.outputDir,
}),
catch: (cause: unknown) =>
new BuildError({
target,
cause,
}),
})
const paths = output.outputs.map((item: { path: string }) => item.path)
return {
target,
outputs: paths,
} satisfies BuildResult
}),
buildAll: (config: BuildConfig) =>
Effect.gen(function* () {
const effects = config.targets.map((target) =>
Effect.gen(function* () {
yield* Console.log(`🔨 开始构建: ${target}`)
const output = yield* Effect.tryPromise({
try: () =>
Bun.build({
entrypoints: [config.entrypoint],
compile: {
outfile: `${config.outfile}-${getTargetSuffix(target)}`,
target: target,
},
outdir: config.outputDir,
}),
catch: (cause: unknown) =>
new BuildError({
target,
cause,
}),
})
const paths = output.outputs.map(
(item: { path: string }) => item.path,
)
return {
target,
outputs: paths,
} satisfies BuildResult
}),
)
return yield* Effect.all(effects, { concurrency: 'unbounded' })
}),
})
}
/**
* 报告服务
*/
class ReporterService extends Context.Tag('ReporterService')<
ReporterService,
{
readonly printSummary: (
results: ReadonlyArray<BuildResult>,
) => Effect.Effect<void>
}
>() {
static readonly Live = Layer.succeed(ReporterService, {
printSummary: (results: ReadonlyArray<BuildResult>) =>
Effect.gen(function* () {
yield* Console.log('\n📦 构建完成:')
for (const result of results) {
yield* Console.log(` ${result.target}:`)
for (const path of result.outputs) {
yield* Console.log(` - ${path}`)
}
}
}),
})
}
// ============================================================================
// Main Program
// ============================================================================
const program = Effect.gen(function* () {
const config = yield* BuildConfigService
const fs = yield* FileSystemService
const builder = yield* BuildService
const reporter = yield* ReporterService
// 1. 清理输出目录
yield* fs.cleanDir(config.outputDir)
yield* Console.log(`✓ 已清理输出目录: ${config.outputDir}`)
// 2. 并行构建所有目标
const results = yield* builder.buildAll(config)
// 3. 输出构建摘要
yield* reporter.printSummary(results)
return results
})
// ============================================================================
// Layer Composition
// ============================================================================
const MainLayer = Layer.mergeAll(
BuildConfigService.Live,
FileSystemService.Live,
BuildService.Live,
ReporterService.Live,
)
// ============================================================================
// Runner
// ============================================================================
const runnable = program.pipe(
Effect.provide(MainLayer),
Effect.catchTags({
CleanError: (error) =>
Console.error(`❌ 清理目录失败: ${error.dir}`, error.cause),
BuildError: (error) =>
Console.error(`❌ 构建失败 [${error.target}]:`, error.cause),
ConfigError: (error) =>
Console.error(`❌ 配置错误: ${error.message}`, error.cause),
}),
Effect.tapErrorCause((cause) => Console.error('❌ 未预期的错误:', cause)),
)
Effect.runPromise(runnable).catch(() => {
process.exit(1)
})

64
apps/server/compile.ts Normal file
View File

@@ -0,0 +1,64 @@
import { rm } from 'node:fs/promises'
import { parseArgs } from 'node:util'
const ENTRYPOINT = '.output/server/index.mjs'
const OUTDIR = 'out'
const SUPPORTED_TARGETS: readonly Bun.Build.CompileTarget[] = [
'bun-windows-x64',
'bun-darwin-arm64',
'bun-linux-x64',
'bun-linux-arm64',
]
const isSupportedTarget = (value: string): value is Bun.Build.CompileTarget =>
(SUPPORTED_TARGETS as readonly string[]).includes(value)
const { values } = parseArgs({
options: { target: { type: 'string' } },
strict: true,
allowPositionals: false,
})
const resolveTarget = (): Bun.Build.CompileTarget => {
if (values.target !== undefined) {
if (!isSupportedTarget(values.target)) {
throw new Error(
`Invalid target: ${values.target}\nAllowed: ${SUPPORTED_TARGETS.join(', ')}`,
)
}
return values.target
}
const os = process.platform === 'win32' ? 'windows' : process.platform
const candidate = `bun-${os}-${process.arch}`
if (!isSupportedTarget(candidate)) {
throw new Error(`Unsupported host: ${process.platform}-${process.arch}`)
}
return candidate
}
const main = async () => {
const target = resolveTarget()
const suffix = target.replace('bun-', '')
const outfile = `server-${suffix}`
await rm(OUTDIR, { recursive: true, force: true })
const result = await Bun.build({
entrypoints: [ENTRYPOINT],
outdir: OUTDIR,
compile: { outfile, target },
})
if (!result.success) {
throw new Error(result.logs.map(String).join('\n'))
}
console.log(`${target}${OUTDIR}/${outfile}`)
}
main().catch((err) => {
console.error('❌', err instanceof Error ? err.message : err)
process.exit(1)
})

View File

@@ -4,7 +4,7 @@ import { env } from '@/env'
export default defineConfig({
out: './drizzle',
schema: './src/server/db/schema/index.ts',
dialect: 'sqlite',
dialect: 'postgresql',
dbCredentials: {
url: env.DATABASE_URL,
},

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

@@ -5,17 +5,20 @@
"type": "module",
"scripts": {
"build": "vite build",
"compile": "bun build.ts",
"compile": "bun compile.ts",
"compile:darwin": "bun compile.ts --target bun-darwin-arm64",
"compile:linux": "bun compile.ts --target bun-linux-x64",
"compile:windows": "bun compile.ts --target bun-windows-x64",
"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 .",
"fix": "biome check --write",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@isaacs/ttlcache": "catalog:",
"@furtherverse/utils": "workspace:*",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/openapi": "catalog:",
@@ -29,32 +32,25 @@
"@tanstack/react-start": "catalog:",
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"ohash": "catalog:",
"better-sqlite3": "catalog:",
"postgres": "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

@@ -2,5 +2,29 @@ import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { orpc as orpcClient } from './orpc.client'
export const orpc = createTanstackQueryUtils(orpcClient, {
experimental_defaults: {},
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() })
},
},
},
},
},
})

View File

@@ -3,12 +3,15 @@ import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().min(1),
DATABASE_URL: z.url(),
},
clientPrefix: 'VITE_',
client: {
VITE_APP_TITLE: z.string().min(1).optional(),
},
shared: {
NODE_ENV: z.enum(['development', 'production', 'test']),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
})

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

@@ -9,22 +9,10 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as LicenseRouteImport } from './routes/license'
import { Route as FingerprintRouteImport } from './routes/fingerprint'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
const LicenseRoute = LicenseRouteImport.update({
id: '/license',
path: '/license',
getParentRoute: () => rootRouteImport,
} as any)
const FingerprintRoute = FingerprintRouteImport.update({
id: '/fingerprint',
path: '/fingerprint',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -43,58 +31,36 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/fingerprint': typeof FingerprintRoute
'/license': typeof LicenseRoute
'/api/$': typeof ApiSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/fingerprint': typeof FingerprintRoute
'/license': typeof LicenseRoute
'/api/$': typeof ApiSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/fingerprint': typeof FingerprintRoute
'/license': typeof LicenseRoute
'/api/$': typeof ApiSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
fullPaths: '/' | '/api/$' | '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
id: '__root__' | '/' | '/fingerprint' | '/license' | '/api/$' | '/api/rpc/$'
to: '/' | '/api/$' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/rpc/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
FingerprintRoute: typeof FingerprintRoute
LicenseRoute: typeof LicenseRoute
ApiSplatRoute: typeof ApiSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/license': {
id: '/license'
path: '/license'
fullPath: '/license'
preLoaderRoute: typeof LicenseRouteImport
parentRoute: typeof rootRouteImport
}
'/fingerprint': {
id: '/fingerprint'
path: '/fingerprint'
fullPath: '/fingerprint'
preLoaderRoute: typeof FingerprintRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -121,8 +87,6 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
FingerprintRoute: FingerprintRoute,
LicenseRoute: LicenseRoute,
ApiSplatRoute: ApiSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
}

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,44 +1,206 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import type { ChangeEventHandler, FormEventHandler } from 'react'
import { useState } from 'react'
import { orpc } from '@/client/query-client'
export const Route = createFileRoute('/')({
component: Home,
component: Todos,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
},
})
function Home() {
function Todos() {
const [newTodoTitle, setNewTodoTitle] = useState('')
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
const createMutation = useMutation(orpc.todo.create.mutationOptions())
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
const handleCreateTodo: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
if (newTodoTitle.trim()) {
createMutation.mutate({ title: newTodoTitle.trim() })
setNewTodoTitle('')
}
}
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
setNewTodoTitle(e.target.value)
}
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
updateMutation.mutate({
id,
data: { completed: !currentCompleted },
})
}
const handleDeleteTodo = (id: string) => {
deleteMutation.mutate({ id })
}
const todos = listQuery.data
const completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
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
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
</h1>
<p className="text-slate-500 text-lg">
使 TanStack Start + ORPC License
</p>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-900">
{completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span>
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">
</div>
</div>
</div>
<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"
{/* Add Todo Form */}
<form onSubmit={handleCreateTodo} className="relative group z-10">
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
<input
type="text"
value={newTodoTitle}
onChange={handleInputChange}
placeholder="添加新任务..."
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
disabled={createMutation.isPending}
/>
<button
type="submit"
disabled={createMutation.isPending || !newTodoTitle.trim()}
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
>
<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>
{createMutation.isPending ? '添加中' : '添加'}
</button>
</div>
</form>
<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"
{/* Progress Bar (Only visible when there are tasks) */}
{totalCount > 0 && (
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Todo List */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="py-20 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<svg
className="w-8 h-8 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<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
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</div>
<p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1">
</p>
</Link>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
todo.completed ? 'bg-slate-50/50' : ''
}`}
>
<button
type="button"
onClick={() => handleToggleTodo(todo.id, todo.completed)}
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
todo.completed
? 'bg-indigo-500 border-indigo-500'
: 'border-slate-300 hover:border-indigo-500 bg-white'
}`}
>
{todo.completed && (
<svg
className="w-3.5 h-3.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-lg transition-all duration-200 truncate ${
todo.completed
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
: 'text-slate-700'
}`}
>
{todo.title}
</p>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
</span>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
title="删除"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))
)}
</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,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 +1,7 @@
import * as fingerprint from './fingerprint.contract'
import * as license from './license.contract'
import * as todo from './todo.contract'
export const contract = {
fingerprint,
license,
todo,
}
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

@@ -0,0 +1,43 @@
import { oc } from '@orpc/contract'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from 'drizzle-zod'
import { z } from 'zod'
import { todoTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(todoTable)
const insertSchema = createInsertSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
const updateSchema = createUpdateSchema(todoTable).omit({
id: true,
createdAt: true,
updatedAt: true,
})
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
export const update = oc
.input(
z.object({
id: z.uuid(),
data: updateSchema,
}),
)
.output(selectSchema)
export const remove = oc
.input(
z.object({
id: z.uuid(),
}),
)
.output(z.void())

View File

@@ -1,9 +1,7 @@
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()
export const db = os.middleware(async ({ context, next }) => {
return next({
context: {
...context,
@@ -11,5 +9,3 @@ export const dbProvider = os.middleware(async ({ context, next }) => {
},
})
})
export const db = dbProvider

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 +1,6 @@
import { os } from '../server'
import * as fingerprint from './fingerprint.router'
import * as license from './license.router'
import * as todo from './todo.router'
export const router = os.router({
fingerprint,
license,
todo,
})

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

@@ -0,0 +1,49 @@
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
import { todoTable } from '@/server/db/schema'
import { db } from '../middlewares'
import { os } from '../server'
export const list = os.todo.list.use(db).handler(async ({ context }) => {
const todos = await context.db.query.todoTable.findMany({
orderBy: (todos, { desc }) => [desc(todos.createdAt)],
})
return todos
})
export const create = os.todo.create
.use(db)
.handler(async ({ context, input }) => {
const [newTodo] = await context.db
.insert(todoTable)
.values(input)
.returning()
if (!newTodo) {
throw new ORPCError('NOT_FOUND')
}
return newTodo
})
export const update = os.todo.update
.use(db)
.handler(async ({ context, input }) => {
const [updatedTodo] = await context.db
.update(todoTable)
.set(input.data)
.where(eq(todoTable.id, input.id))
.returning()
if (!updatedTodo) {
throw new ORPCError('NOT_FOUND')
}
return updatedTodo
})
export const remove = os.todo.remove
.use(db)
.handler(async ({ context, input }) => {
await context.db.delete(todoTable).where(eq(todoTable.id, input.id))
})

View File

@@ -1,18 +1,15 @@
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { drizzle } from 'drizzle-orm/postgres-js'
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 const createDB = () =>
drizzle({
connection: {
url: env.DATABASE_URL,
prepare: true,
},
schema,
})
export type DB = ReturnType<typeof createDB>

View File

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

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

@@ -0,0 +1,8 @@
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
import { generatedFields } from './utils/field'
export const todoTable = pgTable('todo', {
...generatedFields,
title: text('title').notNull(),
completed: boolean('completed').notNull().default(false),
})

View File

@@ -1,25 +1,37 @@
import { integer, text } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
import { timestamp, uuid } from 'drizzle-orm/pg-core'
import { v7 as uuidv7 } from 'uuid'
// id
export const id = (name: string) => text(name)
export const pk = (name: string) =>
id(name)
export const id = (name: string) => uuid(name)
export const pk = (name: string, strategy?: 'native' | 'extension') => {
switch (strategy) {
// PG 18+
case 'native':
return id(name).primaryKey().default(sql`uuidv7()`)
// PG 13+ with extension
case 'extension':
return id(name).primaryKey().default(sql`uuid_generate_v7()`)
// Any PG version
default:
return id(name)
.primaryKey()
.$defaultFn(() => uuidv7())
}
}
// timestamp
export const createdAt = (name = 'created_at') =>
integer(name, { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
timestamp(name, { withTimezone: true }).notNull().defaultNow()
export const updatedAt = (name = 'updated_at') =>
integer(name, { mode: 'timestamp_ms' })
timestamp(name, { withTimezone: true })
.notNull()
.$defaultFn(() => new Date())
.defaultNow()
.$onUpdateFn(() => new Date())
// generated fields

View File

@@ -2,9 +2,25 @@
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"env": ["NODE_ENV", "VITE_*"],
"outputs": [".output/**"]
},
"compile": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:windows": {
"dependsOn": ["build"],
"outputs": ["out/**"]
}
}
}

View File

@@ -10,24 +10,21 @@ export default defineConfig({
clearScreen: false,
plugins: [
tanstackDevtools(),
nitro({
preset: 'bun',
serveStatic: 'inline',
}),
tsconfigPaths(),
tailwindcss(),
tsconfigPaths(),
tanstackStart(),
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
nitro({
preset: 'bun',
serveStatic: 'inline',
}),
],
server: {
port: 3000,
strictPort: true,
watch: {
ignored: ['**/src-tauri/**'],
},
},
})

1066
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,20 +10,24 @@
"scripts": {
"build": "turbo run build",
"compile": "turbo run compile",
"deploy": "turbo run deploy",
"compile:linux": "turbo run compile:linux",
"compile:mac": "turbo run compile:mac",
"compile:win": "turbo run compile:win",
"dev": "turbo run dev",
"dist": "turbo run dist",
"dist:linux": "turbo run dist:linux",
"dist:mac": "turbo run dist:mac",
"dist:win": "turbo run dist:win",
"fix": "turbo run fix",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"@biomejs/biome": "^2.3.12",
"turbo": "^2.7.6"
"@biomejs/biome": "^2.3.14",
"turbo": "^2.8.3",
"typescript": "^5.9.3"
},
"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/contract": "^1.13.4",
"@orpc/openapi": "^1.13.4",
@@ -32,34 +36,40 @@
"@orpc/zod": "^1.13.4",
"@t3-oss/env-core": "^0.13.10",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/devtools-vite": "^0.4.1",
"@tanstack/react-devtools": "^0.9.2",
"@tanstack/devtools-vite": "^0.5.1",
"@tanstack/react-devtools": "^0.9.5",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-query-devtools": "^5.91.2",
"@tanstack/react-router": "^1.157.14",
"@tanstack/react-router-devtools": "^1.157.14",
"@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",
"@vitejs/plugin-react": "^5.1.2",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router": "^1.158.4",
"@tanstack/react-router-devtools": "^1.158.4",
"@tanstack/react-router-ssr-query": "^1.158.4",
"@tanstack/react-start": "^1.159.0",
"@types/bun": "^1.3.8",
"@types/node": "^24.3.0",
"@vitejs/plugin-react": "^5.1.3",
"babel-plugin-react-compiler": "^1.0.0",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"effect": "^3.19.15",
"nitro": "npm:nitro-nightly@3.0.1-20260125-215009-8882bc9e",
"electron": "^34.0.0",
"electron-builder": "^26.0.0",
"electron-vite": "^5.0.0",
"nitro": "npm:nitro-nightly@3.0.1-20260206-171553-bc737c0c",
"ohash": "^2.0.11",
"better-sqlite3": "^11.8.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"postgres": "^3.4.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"systeminformation": "^5.30.7",
"tailwindcss": "^4.1.18",
"tree-kill": "^1.2.2",
"turbo": "^2.7.5",
"typescript": "^5.9.3",
"uuid": "^13.0.0",
"systeminformation": "^5.30.6",
"vite": "^8.0.0-beta.10",
"vite-tsconfig-paths": "^6.0.5",
"vite": "^8.0.0-beta.13",
"vite-tsconfig-paths": "^6.1.0",
"zod": "^4.3.6"
},
"overrides": {
"@types/node": "catalog:"
}
}

View File

@@ -0,0 +1,20 @@
{
"name": "@furtherverse/utils",
"version": "1.0.0",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"dependencies": {
"ohash": "catalog:",
"systeminformation": "catalog:"
},
"devDependencies": {
"@furtherverse/tsconfig": "workspace:*"
}
}

View File

@@ -0,0 +1,29 @@
import { hash } from 'ohash'
import si from 'systeminformation'
async function getSystemInfo() {
const [uuid, baseboard, bios, system, diskLayout, networkInterfaces] =
await Promise.all([
si.uuid(),
si.baseboard(),
si.bios(),
si.system(),
si.diskLayout(),
si.networkInterfaces(),
])
return {
uuid,
baseboard,
bios,
system,
diskLayout,
networkInterfaces,
}
}
export async function getHardwareFingerprint() {
const systemInfo = await getSystemInfo()
return hash(systemInfo)
}

View File

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

View File

@@ -1,30 +1,25 @@
{
"$schema": "./node_modules/turbo/schema.json",
"dangerouslyDisablePackageManagerCheck": true,
"globalDependencies": [
"package.json",
"bun.lock",
"turbo.json",
"biome.json"
],
"globalDependencies": ["bun.lock"],
"tasks": {
"build": {
"outputs": ["dist/**", ".output/**"]
},
"compile": {
"dependsOn": ["build"],
"outputs": ["out/**"]
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
},
"fix": {
"outputs": []
"cache": false
},
"typecheck": {
"dependsOn": ["^typecheck"],
"inputs": ["tsconfig.json", "tsconfig.*.json", "**/*.{ts,tsx}"],
"inputs": [
"package.json",
"tsconfig.json",
"tsconfig.*.json",
"**/*.{ts,tsx,d.ts}"
],
"outputs": []
}
},