[next-tauri]plan p0

This commit is contained in:
RememBerBer
2026-07-29 22:44:54 +08:00
parent 0b79dc6b45
commit e0efe0efd8
100 changed files with 15518 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
src-tauri/target/
.DS_Store
*.log
View File
+29
View File
@@ -0,0 +1,29 @@
# MooTool Next Tauri
MooTool Next Tauri 是基于 Tauri 2、Rust、React 和 TypeScript 的独立桌面产品线。它与 MooTool Java、MooTool Next Electron 独立安装、独立存储、独立发布和独立演进。
## 本地开发
前置条件:
- Node.js 20+
- Rust stable
- Tauri 对应平台的系统依赖
```bash
npm install
npm run dev
```
常用检查:
```bash
npm run typecheck
npm test
npm run check
npm run build:desktop
```
当前原生 P0 能力可在“系统工具 → WebView 实验台”中验证:创建一个 Rust 管理的子 WebView,在主窗口与独立原生窗口之间分离、收回,并执行 100 次状态保持压力测试。
实现边界和分阶段计划见 [`doc/independent-product-implementation-plan.md`](doc/independent-product-implementation-plan.md),实测进度见 [`doc/p0-validation.md`](doc/p0-validation.md)。
@@ -1,6 +1,6 @@
# MooTool Next Tauri 独立产品线实现方案
> - 状态:方案草案
> - 状态:已批准,执行中
> - 更新日期:2026-07-29
> - 产品 ID`next-tauri`
> - 产品名称:MooTool Next Tauri
@@ -297,6 +297,15 @@ Tauri 官方文档:
- [Tauri WebView API](https://v2.tauri.app/reference/javascript/api/namespacewebview/)
截至 2026-07-29 的 P0 实现结论:
- 当前锁定的 Tauri `2.11.5` 中,`Webview::reparent()` 是公开 API;但创建普通原生 `Window`、添加子 WebView 所需的 `WindowBuilder``WebviewBuilder``Window::add_child()` 仍要求开启 Tauri 的 `unstable` feature。
- 低层窗口能力集中在 Rust `ToolWebviewManager` 和领域 Command 中,Shell 只传递停靠区域和生命周期意图,工具子页面无权调用生命周期 Command。
- macOS 26.6 / x86_64 / WKWebView 原生实测中,同一个子 WebView 完成手工分离/收回和 100 个往返周期,累计 202 次 `reparent` 操作;页面加载次数保持为 1,会话 ID、内存计数器和草稿均保持。
- 上述结果仅判定 macOS 机制可行,不替代 Windows WebView2、Linux WebKitGTK 验证,也不代表可以把 `unstable` API 当作无升级成本的长期承诺。
进入正式工具开发前,应锁定精确 Tauri/Wry 版本;每次升级必须重复 reparent 回归。在相关 API 稳定前,发布评审需把 `unstable` feature 视为明确的架构风险。
如果 `reparent()` 在任一首发平台无法达到稳定性门槛,必须在以下方案中作出明确产品决策:
- 推迟“工具分离”功能,不阻塞其他工具。
+99
View File
@@ -0,0 +1,99 @@
# P0 技术验证记录
> 状态:独立工程基线完成,macOS 多 WebView / reparent 机制验证通过,P0 继续
> 日期:2026-07-29
> 产品:MooTool Next Tauri `0.1.0`
> 验证环境:macOS 26.6 / x86_64 / Apple Clang 17 / Rust 1.97.1 / Tauri 2.11.5
## 1. 本轮目标
本轮先建立一个可以持续扩展的独立产品基线,不以迁移 Electron 源码为实现路径:
- `next-tauri` 独立维护 NPM 包、锁文件、Rust crate、Tauri 配置与构建产物。
- 固定产品名 `MooTool Next Tauri` 和应用 ID `com.rememberber.mootool.next.tauri`
- 建立 React 工作台、独立工具注册表与 Tauri-owned API。
- 完成 Calculator 首个纵向切片。
- 由 Rust Command 返回产品身份和平台信息。
- 建立前端单元测试、Rust 单元测试和产品边界检查。
- 建立 Rust-owned 工具 WebView 生命周期管理器和独立子 WebView 状态探针。
- 验证 macOS 上停靠、分离、收回、隐藏/恢复、关闭以及 100 次 reparent 循环。
## 2. 已实现验证面
| 验证项 | 当前实现 | 验收方式 | 状态 |
| --- | --- | --- | --- |
| 独立工程 | 自有 `package.json``package-lock.json``Cargo.toml``Cargo.lock`、Tauri 配置 | `npm run check:boundaries` | 通过 |
| 产品身份 | 独立 product name / app ID / crate name | 边界脚本 + Rust 测试 | 通过 |
| Tauri-owned API | `RuntimeApi``ToolWebviewApi` 封装领域 Command | Vitest | 通过 |
| Rust Command | 返回运行时;管理工具 WebView 生命周期与状态探针 | Cargo test + release 桌面运行 | 通过 |
| 工作台 | 首页、分组导航、搜索、最近使用、紧凑导航 | 原生窗口视觉与交互检查 | 基线通过 |
| 会话保持 | Home 与 Calculator 切换时保留已挂载状态 | 将 `9*9 = 81` 会话切出再切回 | 通过 |
| Calculator | 表达式、进制、GCD/LCM、排列组合、记录 | Vitest + 原生窗口交互 | 通过 |
| 多 WebView 原型 | Shell 控制层 + Rust-owned 子 WebView 状态探针 | macOS 原生窗口 | 通过 |
| reparent 压力验证 | 普通原生 Window 与主 Window 之间移动同一个 WKWebView | 100 个往返周期 | macOS 通过 |
| WebView 权限边界 | Shell 与 `p0-tool-probe` 分配独立 CapabilityRust 校验调用方 label | 配置检查 + 原生 IPC 上报 | 通过 |
| 系统主题 | 跟随 macOS 浅色/深色 | 原生窗口视觉检查 | 深色通过,浅色待补 |
## 3. P0 Go/No-Go 矩阵
| 风险项 | macOS | Windows | Linux | P0 结论 |
| --- | --- | --- | --- | --- |
| 主窗口与系统 WebView | release 构建与原生窗口启动通过 | 未验证 | 未验证 | macOS 基线通过 |
| 工具 WebView 停靠/分离与 `reparent()` | 状态探针创建、分离、收回、关闭通过;累计 202 次 reparent | 未验证 | 未验证 | macOS 机制 Go |
| WebView 状态在切换/分离后保持 | 100 个往返周期后页面加载仍为 1,会话、计数器、草稿保持 | 未验证 | 未验证 | macOS 机制 Go |
| 工具 WebView 隐藏/恢复 | 切换到 Calculator 时隐藏,返回实验台后恢复且状态保持 | 未验证 | 未验证 | macOS 机制 Go |
| 页面截图 | 未验证 | 未验证 | 未验证 | 待定 |
| 屏幕截图与区域选择 | 未验证 | 未验证 | 未验证 | 待定 |
| 屏幕取色 | 未验证 | 未验证 | 未验证 | 待定 |
| 多语言与最小窗口 | 中文基线进行中 | 未验证 | 未验证 | 待定 |
注意:
- 本轮验证的是独立状态探针子 WebView,证明 macOS 窗口机制可行;Calculator 还没有改为独立工具 WebView,不能据此宣称工具架构已经完成。
- Tauri `2.11.5``reparent()` 已公开,但创建普通原生 Window 和子 WebView 的 Rust API 需要 `tauri/unstable`。当前版本必须精确锁定,升级时重复压力验证。
- Windows WebView2 与 Linux WebKitGTK 尚无真实环境结论,P0 的跨平台 Go/No-Go 仍未完成。
## 4. 独立性证据
- 前端通过 `src/platform/api` 访问 Tauri Command,不声明或读取 `window.mootool`
- `src``src-tauri/src` 不引用 `next/src``next/electron``next/out`
- NPM 依赖中不存在 `electron``electron-*`
- Rust 产品常量、Tauri 应用 ID 与包名均使用 `next-tauri` 身份。
- 边界检查被纳入 `npm run check`,后续回归会阻止明显的 Electron 源码耦合。
## 5. 待完成
1. 将 Calculator 接入正式工具 WebView 会话模型,并验证焦点、快捷键和窗口关闭恢复。
2. 在 Windows、Linux 真实环境重复 100 次 reparent 压力验证。
3. 验证页面截图、区域截图、系统取色和权限恢复路径。
4. 补齐 macOS 浅色、最小窗口和英文/日文布局验证。
5. P0 评审后决定是否进入 P1 基础设施开发。
## 6. 本轮命令结果
```text
npm run check
product boundary check: passed
TypeScript: passed
Vitest: 3 files / 5 tests passed
Vite production build: passed
Cargo tests: 3 passed
npm run build:desktop
release profile: passed
output: src-tauri/target/release/mootool-next-tauri
artifact: x86_64 Mach-O, 9.9 MB, local unsigned build
```
原生窗口检查结果:
- 窗口标题为 `MooTool Next Tauri`,单实例测试窗口成功显示。
- 首页读取到 `v0.1.0``next-tauri``tauri``macos · x86_64`,证明前端到 Rust Command 的通路正常。
- Calculator 将 `9*9` 计算为 `81` 并追加记录。
- Home → Calculator 往返后,`9*9 = 81` 的输入、输出与记录保持。
- 深色模式下完成一次对比度复查并修正计划中导航及次级按钮颜色。
- 工具状态探针首次加载后会话 ID 为 `225d0abf-f717-4a2f-94c2-59ea6db821b2`,将计数器改为 `3`、草稿改为 `p0-macos-reparent-225d`
- 手工分离到普通原生 Window 后,会话 ID、计数器和草稿保持;收回主窗口后仍保持。
- 随后执行 100 个分离/收回往返周期,Rust Manager 记录累计 `202` 次 reparent,页面加载始终为 `1`,压力结论为通过。
- 从实验台切到 Calculator 时,停靠子 WebView 正确隐藏;返回实验台后恢复,状态和压力结论不变。
- 关闭工具子 WebView 后,原生内容消失且生命周期按钮回到未创建状态。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f5f6f8" />
<title>MooTool Next Tauri</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2868
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "mootool-next-tauri",
"version": "0.1.0",
"private": true,
"description": "MooTool Next Tauri — an independent Tauri desktop product.",
"license": "MIT",
"author": {
"name": "Zhou Bo",
"email": "rememberber@163.com"
},
"homepage": "https://github.com/rememberber/MooTool",
"repository": {
"type": "git",
"url": "https://github.com/rememberber/MooTool.git"
},
"type": "module",
"scripts": {
"dev": "tauri dev",
"web:dev": "vite",
"web:build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"check:boundaries": "node scripts/check-product-boundaries.mjs",
"check": "npm run check:boundaries && npm run typecheck && npm run test && npm run web:build && cargo test --manifest-path src-tauri/Cargo.toml",
"tauri": "tauri",
"build:desktop": "tauri build --no-bundle"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"lucide-react": "^1.27.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^26.1.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^7.0.2",
"vite": "^7.3.6",
"vitest": "^4.1.10"
}
}
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title">
<title id="title">MooTool Next Tauri</title>
<defs>
<linearGradient id="background" x1="80" y1="56" x2="432" y2="456" gradientUnits="userSpaceOnUse">
<stop stop-color="#4B8BFF"/>
<stop offset="1" stop-color="#2358C5"/>
</linearGradient>
<linearGradient id="highlight" x1="152" y1="135" x2="353" y2="371" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#DDE9FF"/>
</linearGradient>
</defs>
<rect x="24" y="24" width="464" height="464" rx="112" fill="url(#background)"/>
<path d="M256 116 362 174 256 232 150 174 256 116Z" fill="url(#highlight)"/>
<path d="m145 190 101 55v112l-101-56V190Z" fill="#FFFFFF" fill-opacity=".93"/>
<path d="m266 245 101-55v111l-101 56V245Z" fill="#CFE0FF"/>
<path d="m145 318 101 55v31l-101-56v-30Z" fill="#FFFFFF" fill-opacity=".72"/>
<path d="m266 373 101-55v30l-101 56v-31Z" fill="#B9D1FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,58 @@
import { readFile, readdir } from 'node:fs/promises'
import { extname, join } from 'node:path'
const productRoot = new URL('../', import.meta.url)
const sourceRoots = ['src', 'src-tauri/src']
const sourceExtensions = new Set(['.ts', '.tsx', '.rs'])
const violations = []
for (const sourceRoot of sourceRoots) {
for (const file of await walk(new URL(`${sourceRoot}/`, productRoot))) {
if (!sourceExtensions.has(extname(file.pathname))) continue
const contents = await readFile(file, 'utf8')
for (const [label, pattern] of [
['Electron preload API', /\bwindow\.mootool\b/],
['Electron source path', /(?:\.\.\/)+(?:next\/(?:src|electron|out))\b/],
['Electron IPC channel', /\bipcRenderer\b|\bipcMain\b/]
]) {
if (pattern.test(contents)) violations.push(`${file.pathname}: ${label}`)
}
}
}
const packageJson = JSON.parse(await readFile(new URL('package.json', productRoot), 'utf8'))
const dependencyNames = [
...Object.keys(packageJson.dependencies ?? {}),
...Object.keys(packageJson.devDependencies ?? {})
]
for (const name of dependencyNames) {
if (name === 'electron' || name.startsWith('electron-')) {
violations.push(`package.json: forbidden Electron dependency "${name}"`)
}
}
const tauriConfig = JSON.parse(await readFile(new URL('src-tauri/tauri.conf.json', productRoot), 'utf8'))
if (tauriConfig.identifier !== 'com.rememberber.mootool.next.tauri') {
violations.push('src-tauri/tauri.conf.json: independent application identifier changed')
}
if (tauriConfig.productName !== 'MooTool Next Tauri') {
violations.push('src-tauri/tauri.conf.json: independent product name changed')
}
if (violations.length > 0) {
console.error(`Product boundary check failed:\n${violations.map((item) => `- ${item}`).join('\n')}`)
process.exitCode = 1
} else {
console.log('Product boundary check passed: next-tauri remains independent.')
}
async function walk(directoryUrl) {
const entries = await readdir(directoryUrl, { withFileTypes: true })
const files = []
for (const entry of entries) {
const entryUrl = new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, directoryUrl)
if (entry.isDirectory()) files.push(...await walk(entryUrl))
else files.push(entryUrl)
}
return files
}
+4370
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "mootool-next-tauri"
version = "0.1.0"
description = "MooTool Next Tauri independent desktop product"
authors = ["Zhou Bo <rememberber@163.com>"]
license = "MIT"
repository = "https://github.com/rememberber/MooTool"
edition = "2024"
rust-version = "1.85"
[lib]
name = "mootool_next_tauri_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["unstable"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Least-privilege capability for the main MooTool Tauri workbench.",
"webviews": ["main"],
"permissions": ["core:default"]
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "tool-probe",
"description": "IPC boundary for the P0 owned child WebView probe.",
"webviews": ["p0-tool-probe"],
"permissions": []
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Least-privilege capability for the main MooTool Tauri workbench.","local":true,"webviews":["main"],"permissions":["core:default"]},"tool-probe":{"identifier":"tool-probe","description":"IPC boundary for the P0 owned child WebView probe.","local":true,"webviews":["p0-tool-probe"],"permissions":[]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+2
View File
@@ -0,0 +1,2 @@
pub mod runtime;
pub mod tool_webview;
@@ -0,0 +1,6 @@
use crate::contracts::runtime::RuntimeInfo;
#[tauri::command]
pub fn get_runtime_info(app: tauri::AppHandle) -> RuntimeInfo {
RuntimeInfo::collect(app.package_info().version.to_string())
}
@@ -0,0 +1,355 @@
use tauri::{
AppHandle, LogicalPosition, LogicalSize, Manager, State, Webview, WebviewUrl,
webview::{PageLoadEvent, WebviewBuilder},
window::WindowBuilder,
};
use crate::{
contracts::tool_webview::{
ToolProbeReport, ToolWebviewBounds, ToolWebviewPlacement, ToolWebviewSnapshot,
},
state::ToolWebviewManager,
};
const MAIN_WINDOW_LABEL: &str = "main";
const SHELL_WEBVIEW_LABEL: &str = "main";
const TOOL_WEBVIEW_LABEL: &str = "p0-tool-probe";
const DETACHED_WINDOW_LABEL: &str = "p0-tool-detached";
#[tauri::command]
pub async fn get_tool_webview_snapshot(
caller: Webview,
state: State<'_, ToolWebviewManager>,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
Ok(state.snapshot())
}
#[tauri::command]
pub async fn open_tool_webview(
caller: Webview,
app: AppHandle,
state: State<'_, ToolWebviewManager>,
bounds: ToolWebviewBounds,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
let bounds = bounds.validate()?;
if app.get_webview(TOOL_WEBVIEW_LABEL).is_some() {
return Ok(state.snapshot());
}
let main = main_window(&app)?;
state.begin_open(bounds);
let builder = WebviewBuilder::new(
TOOL_WEBVIEW_LABEL,
WebviewUrl::App("index.html?surface=tool-probe".into()),
)
.on_page_load(|webview, payload| {
if payload.event() == PageLoadEvent::Finished {
webview
.app_handle()
.state::<ToolWebviewManager>()
.record_page_load();
}
});
let result = main.add_child(
builder,
LogicalPosition::new(bounds.x, bounds.y),
LogicalSize::new(bounds.width, bounds.height),
);
let webview = match result {
Ok(webview) => webview,
Err(error) => {
state.mark_open_failed();
return Err(format!("failed to create tool WebView: {error}"));
}
};
webview
.set_auto_resize(false)
.map_err(|error| format!("failed to configure tool WebView resize: {error}"))?;
Ok(state.snapshot())
}
#[tauri::command]
pub async fn update_tool_webview_bounds(
caller: Webview,
app: AppHandle,
state: State<'_, ToolWebviewManager>,
bounds: ToolWebviewBounds,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
let bounds = bounds.validate()?;
let snapshot = state.snapshot();
if snapshot.exists && snapshot.placement == ToolWebviewPlacement::Docked {
let webview = tool_webview(&app)?;
apply_docked_bounds(&webview, bounds)?;
state.update_bounds(bounds);
}
Ok(state.snapshot())
}
#[tauri::command]
pub async fn set_tool_webview_visible(
caller: Webview,
app: AppHandle,
state: State<'_, ToolWebviewManager>,
visible: bool,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
let snapshot = state.snapshot();
if snapshot.exists && snapshot.placement == ToolWebviewPlacement::Docked {
let webview = tool_webview(&app)?;
if visible {
webview
.show()
.map_err(|error| format!("failed to show tool WebView: {error}"))?;
} else {
webview
.hide()
.map_err(|error| format!("failed to hide tool WebView: {error}"))?;
}
state.mark_visible(visible);
}
Ok(state.snapshot())
}
#[tauri::command]
pub async fn detach_tool_webview(
caller: Webview,
app: AppHandle,
state: State<'_, ToolWebviewManager>,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
let snapshot = state.snapshot();
if !snapshot.exists {
return Err("tool WebView has not been created".into());
}
if snapshot.placement == ToolWebviewPlacement::Detached {
return Ok(snapshot);
}
let detached = ensure_detached_window(&app)?;
let webview = tool_webview(&app)?;
webview
.reparent(&detached)
.map_err(|error| format!("failed to detach tool WebView: {error}"))?;
fill_window(&webview, &detached)?;
detached
.show()
.map_err(|error| format!("failed to show detached tool window: {error}"))?;
detached
.set_focus()
.map_err(|error| format!("failed to focus detached tool window: {error}"))?;
state.record_reparent_operations(1);
state.mark_detached();
Ok(state.snapshot())
}
#[tauri::command]
pub async fn dock_tool_webview(
caller: Webview,
app: AppHandle,
state: State<'_, ToolWebviewManager>,
bounds: ToolWebviewBounds,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
let bounds = bounds.validate()?;
let snapshot = state.snapshot();
if !snapshot.exists {
return Err("tool WebView has not been created".into());
}
if snapshot.placement == ToolWebviewPlacement::Docked {
let webview = tool_webview(&app)?;
apply_docked_bounds(&webview, bounds)?;
state.mark_docked(bounds);
return Ok(state.snapshot());
}
let main = main_window(&app)?;
let webview = tool_webview(&app)?;
webview
.set_auto_resize(false)
.map_err(|error| format!("failed to disable detached auto-resize: {error}"))?;
webview
.reparent(&main)
.map_err(|error| format!("failed to dock tool WebView: {error}"))?;
apply_docked_bounds(&webview, bounds)?;
if let Some(detached) = app.get_window(DETACHED_WINDOW_LABEL) {
detached
.destroy()
.map_err(|error| format!("failed to destroy detached tool window: {error}"))?;
}
state.record_reparent_operations(1);
state.mark_docked(bounds);
Ok(state.snapshot())
}
#[tauri::command]
pub async fn stress_tool_webview_reparent(
caller: Webview,
app: AppHandle,
state: State<'_, ToolWebviewManager>,
bounds: ToolWebviewBounds,
cycles: u32,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
let bounds = bounds.validate()?;
if !(1..=500).contains(&cycles) {
return Err("stress cycles must be between 1 and 500".into());
}
let before = state.snapshot();
if !before.exists {
return Err("tool WebView has not been created".into());
}
let main = main_window(&app)?;
let detached = ensure_detached_window(&app)?;
let webview = tool_webview(&app)?;
if before.placement == ToolWebviewPlacement::Detached {
webview
.set_auto_resize(false)
.map_err(|error| format!("failed to disable detached auto-resize: {error}"))?;
webview
.reparent(&main)
.map_err(|error| format!("failed to prepare docked stress state: {error}"))?;
apply_docked_bounds(&webview, bounds)?;
state.record_reparent_operations(1);
}
for cycle in 0..cycles {
webview
.reparent(&detached)
.map_err(|error| format!("detach failed during stress cycle {}: {error}", cycle + 1))?;
fill_window(&webview, &detached)?;
webview.set_auto_resize(false).map_err(|error| {
format!(
"auto-resize reset failed during stress cycle {}: {error}",
cycle + 1
)
})?;
webview
.reparent(&main)
.map_err(|error| format!("dock failed during stress cycle {}: {error}", cycle + 1))?;
apply_docked_bounds(&webview, bounds)?;
}
detached
.destroy()
.map_err(|error| format!("failed to destroy stress window: {error}"))?;
state.record_reparent_operations(cycles.saturating_mul(2));
state.mark_docked(bounds);
state.finish_stress(cycles, before.page_loads, before.session_id);
webview
.set_focus()
.map_err(|error| format!("failed to focus tool WebView after stress: {error}"))?;
Ok(state.snapshot())
}
#[tauri::command]
pub async fn close_tool_webview(
caller: Webview,
app: AppHandle,
state: State<'_, ToolWebviewManager>,
) -> Result<ToolWebviewSnapshot, String> {
require_shell(&caller)?;
if let Some(webview) = app.get_webview(TOOL_WEBVIEW_LABEL) {
webview
.close()
.map_err(|error| format!("failed to close tool WebView: {error}"))?;
}
if let Some(detached) = app.get_window(DETACHED_WINDOW_LABEL) {
detached
.destroy()
.map_err(|error| format!("failed to destroy detached tool window: {error}"))?;
}
state.mark_closed();
Ok(state.snapshot())
}
#[tauri::command]
pub async fn report_tool_webview_probe(
caller: Webview,
state: State<'_, ToolWebviewManager>,
report: ToolProbeReport,
) -> Result<ToolWebviewSnapshot, String> {
if caller.label() != TOOL_WEBVIEW_LABEL {
return Err("probe state can only be reported by the owned tool WebView".into());
}
if report.session_id.trim().is_empty() || report.session_id.len() > 128 {
return Err("invalid tool WebView session ID".into());
}
if report.draft.len() > 10_000 {
return Err("tool WebView probe draft is too large".into());
}
state.report_probe(report);
Ok(state.snapshot())
}
fn require_shell(caller: &Webview) -> Result<(), String> {
if caller.label() == SHELL_WEBVIEW_LABEL {
Ok(())
} else {
Err("tool WebView lifecycle commands are restricted to the main shell".into())
}
}
fn main_window(app: &AppHandle) -> Result<tauri::Window, String> {
app.get_window(MAIN_WINDOW_LABEL)
.ok_or_else(|| "main window is not available".into())
}
fn tool_webview(app: &AppHandle) -> Result<Webview, String> {
app.get_webview(TOOL_WEBVIEW_LABEL)
.ok_or_else(|| "tool WebView is not available".into())
}
fn ensure_detached_window(app: &AppHandle) -> Result<tauri::Window, String> {
if let Some(window) = app.get_window(DETACHED_WINDOW_LABEL) {
return Ok(window);
}
WindowBuilder::new(app, DETACHED_WINDOW_LABEL)
.title("MooTool WebView Reparent Probe")
.inner_size(900.0, 680.0)
.min_inner_size(520.0, 400.0)
.center()
.visible(false)
.closable(false)
.build()
.map_err(|error| format!("failed to create detached tool window: {error}"))
}
fn fill_window(webview: &Webview, window: &tauri::Window) -> Result<(), String> {
webview
.set_position(LogicalPosition::new(0.0, 0.0))
.map_err(|error| format!("failed to position detached tool WebView: {error}"))?;
webview
.set_size(
window
.inner_size()
.map_err(|error| format!("failed to read detached tool window size: {error}"))?,
)
.map_err(|error| format!("failed to size detached tool WebView: {error}"))?;
webview
.set_auto_resize(true)
.map_err(|error| format!("failed to enable detached auto-resize: {error}"))
}
fn apply_docked_bounds(webview: &Webview, bounds: ToolWebviewBounds) -> Result<(), String> {
webview
.set_auto_resize(false)
.map_err(|error| format!("failed to disable docked auto-resize: {error}"))?;
webview
.set_position(LogicalPosition::new(bounds.x, bounds.y))
.map_err(|error| format!("failed to position docked tool WebView: {error}"))?;
webview
.set_size(LogicalSize::new(bounds.width, bounds.height))
.map_err(|error| format!("failed to size docked tool WebView: {error}"))?;
webview
.show()
.map_err(|error| format!("failed to show docked tool WebView: {error}"))
}
@@ -0,0 +1,2 @@
pub mod runtime;
pub mod tool_webview;
@@ -0,0 +1,43 @@
use serde::Serialize;
pub const PRODUCT_ID: &str = "next-tauri";
pub const PRODUCT_NAME: &str = "MooTool Next Tauri";
#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeInfo {
pub product_id: &'static str,
pub product_name: &'static str,
pub version: String,
pub platform: &'static str,
pub architecture: &'static str,
pub runtime: &'static str,
}
impl RuntimeInfo {
pub fn collect(version: impl Into<String>) -> Self {
Self {
product_id: PRODUCT_ID,
product_name: PRODUCT_NAME,
version: version.into(),
platform: std::env::consts::OS,
architecture: std::env::consts::ARCH,
runtime: "tauri",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_the_tauri_product_identity_independent() {
let info = RuntimeInfo::collect("0.1.0");
assert_eq!(info.product_id, "next-tauri");
assert_eq!(info.product_name, "MooTool Next Tauri");
assert_eq!(info.version, "0.1.0");
assert_eq!(info.runtime, "tauri");
}
}
@@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolWebviewBounds {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl ToolWebviewBounds {
pub fn validate(self) -> Result<Self, String> {
if !self.x.is_finite()
|| !self.y.is_finite()
|| !self.width.is_finite()
|| !self.height.is_finite()
{
return Err("tool WebView bounds must contain finite values".into());
}
if self.width < 320.0 || self.height < 240.0 {
return Err("tool WebView bounds must be at least 320 × 240".into());
}
Ok(self)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ToolWebviewPlacement {
#[default]
Closed,
Docked,
Detached,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolProbeReport {
pub session_id: String,
pub counter: i64,
pub draft: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolWebviewSnapshot {
pub exists: bool,
pub visible: bool,
pub placement: ToolWebviewPlacement,
pub reparent_operations: u32,
pub page_loads: u32,
pub session_id: Option<String>,
pub counter: i64,
pub draft: String,
pub last_stress_cycles: u32,
pub last_stress_passed: Option<bool>,
}
+23
View File
@@ -0,0 +1,23 @@
mod commands;
mod contracts;
mod state;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(state::ToolWebviewManager::default())
.invoke_handler(tauri::generate_handler![
commands::runtime::get_runtime_info,
commands::tool_webview::get_tool_webview_snapshot,
commands::tool_webview::open_tool_webview,
commands::tool_webview::update_tool_webview_bounds,
commands::tool_webview::set_tool_webview_visible,
commands::tool_webview::detach_tool_webview,
commands::tool_webview::dock_tool_webview,
commands::tool_webview::stress_tool_webview_reparent,
commands::tool_webview::close_tool_webview,
commands::tool_webview::report_tool_webview_probe
])
.run(tauri::generate_context!())
.expect("failed to run MooTool Next Tauri");
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
mootool_next_tauri_lib::run();
}
+193
View File
@@ -0,0 +1,193 @@
use std::sync::Mutex;
use crate::contracts::tool_webview::{
ToolProbeReport, ToolWebviewBounds, ToolWebviewPlacement, ToolWebviewSnapshot,
};
#[derive(Default)]
pub struct ToolWebviewManager {
inner: Mutex<ToolWebviewRuntime>,
}
#[derive(Debug, Default)]
struct ToolWebviewRuntime {
exists: bool,
visible: bool,
placement: ToolWebviewPlacement,
bounds: Option<ToolWebviewBounds>,
reparent_operations: u32,
page_loads: u32,
session_id: Option<String>,
counter: i64,
draft: String,
last_stress_cycles: u32,
last_stress_passed: Option<bool>,
stress_expected_page_loads: u32,
stress_expected_session_id: Option<String>,
}
impl ToolWebviewManager {
pub fn begin_open(&self, bounds: ToolWebviewBounds) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
*state = ToolWebviewRuntime {
exists: true,
visible: true,
placement: ToolWebviewPlacement::Docked,
bounds: Some(bounds),
draft: "state survives reparent".into(),
..Default::default()
};
}
pub fn mark_open_failed(&self) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
*state = ToolWebviewRuntime::default();
}
pub fn mark_docked(&self, bounds: ToolWebviewBounds) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
state.exists = true;
state.visible = true;
state.placement = ToolWebviewPlacement::Docked;
state.bounds = Some(bounds);
}
pub fn mark_detached(&self) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
state.exists = true;
state.visible = true;
state.placement = ToolWebviewPlacement::Detached;
}
pub fn mark_visible(&self, visible: bool) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
state.visible = visible;
}
pub fn update_bounds(&self, bounds: ToolWebviewBounds) {
self.inner
.lock()
.expect("tool WebView state poisoned")
.bounds = Some(bounds);
}
pub fn mark_closed(&self) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
state.exists = false;
state.visible = false;
state.placement = ToolWebviewPlacement::Closed;
state.bounds = None;
}
pub fn record_reparent_operations(&self, operations: u32) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
state.reparent_operations = state.reparent_operations.saturating_add(operations);
}
pub fn record_page_load(&self) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
state.page_loads = state.page_loads.saturating_add(1);
if state.last_stress_cycles > 0 && state.page_loads > state.stress_expected_page_loads {
state.last_stress_passed = Some(false);
}
}
pub fn report_probe(&self, report: ToolProbeReport) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
if let Some(expected) = &state.stress_expected_session_id {
if expected != &report.session_id {
state.last_stress_passed = Some(false);
}
}
state.session_id = Some(report.session_id);
state.counter = report.counter;
state.draft = report.draft;
}
pub fn finish_stress(
&self,
cycles: u32,
expected_page_loads: u32,
expected_session_id: Option<String>,
) {
let mut state = self.inner.lock().expect("tool WebView state poisoned");
let passed = state.page_loads == expected_page_loads
&& state.session_id == expected_session_id
&& expected_session_id.is_some();
state.last_stress_cycles = cycles;
state.last_stress_passed = Some(passed);
state.stress_expected_page_loads = expected_page_loads;
state.stress_expected_session_id = expected_session_id;
}
pub fn snapshot(&self) -> ToolWebviewSnapshot {
let state = self.inner.lock().expect("tool WebView state poisoned");
ToolWebviewSnapshot {
exists: state.exists,
visible: state.visible,
placement: state.placement,
reparent_operations: state.reparent_operations,
page_loads: state.page_loads,
session_id: state.session_id.clone(),
counter: state.counter,
draft: state.draft.clone(),
last_stress_cycles: state.last_stress_cycles,
last_stress_passed: state.last_stress_passed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bounds() -> ToolWebviewBounds {
ToolWebviewBounds {
x: 300.0,
y: 120.0,
width: 800.0,
height: 600.0,
}
}
#[test]
fn tracks_probe_state_across_successful_reparent_stress() {
let manager = ToolWebviewManager::default();
manager.begin_open(bounds());
manager.record_page_load();
manager.report_probe(ToolProbeReport {
session_id: "session-a".into(),
counter: 9,
draft: "preserve me".into(),
});
manager.record_reparent_operations(200);
manager.finish_stress(100, 1, Some("session-a".into()));
let snapshot = manager.snapshot();
assert_eq!(snapshot.reparent_operations, 200);
assert_eq!(snapshot.counter, 9);
assert_eq!(snapshot.draft, "preserve me");
assert_eq!(snapshot.last_stress_passed, Some(true));
}
#[test]
fn marks_stress_failed_when_the_page_loads_again() {
let manager = ToolWebviewManager::default();
manager.begin_open(bounds());
manager.record_page_load();
manager.report_probe(ToolProbeReport {
session_id: "session-a".into(),
counter: 1,
draft: "before".into(),
});
manager.finish_stress(100, 1, Some("session-a".into()));
manager.record_page_load();
manager.report_probe(ToolProbeReport {
session_id: "session-b".into(),
counter: 0,
draft: "after".into(),
});
assert_eq!(manager.snapshot().last_stress_passed, Some(false));
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MooTool Next Tauri",
"version": "0.1.0",
"identifier": "com.rememberber.mootool.next.tauri",
"build": {
"beforeDevCommand": "npm run web:dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run web:build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "MooTool Next Tauri",
"width": 1440,
"height": 900,
"minWidth": 760,
"minHeight": 620,
"center": true,
"hiddenTitle": true,
"titleBarStyle": "Overlay"
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false,
"targets": "all",
"category": "DeveloperTool",
"shortDescription": "Developer utilities powered by Tauri and Rust",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+177
View File
@@ -0,0 +1,177 @@
import {
Boxes,
ChevronDown,
Command,
Languages,
PanelLeftClose,
Search,
Settings
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { CalculatorPage } from '../features/calculator/CalculatorPage'
import { HomePage } from '../features/home/HomePage'
import { WebviewLab } from '../features/webviewLab/WebviewLab'
import { runtimeApi } from '../platform/api/runtimeApi'
import type { RuntimeInfo } from '../platform/contracts/runtime'
import { homeTool, toolCatalog, toolGroups, type ToolId } from './toolCatalog'
export function App() {
const [activeTool, setActiveTool] = useState<ToolId>('home')
const [query, setQuery] = useState('')
const [sidebarCompact, setSidebarCompact] = useState(false)
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo>()
const [notice, setNotice] = useState('')
const [recent, setRecent] = useState<ToolId[]>([])
useEffect(() => {
void runtimeApi.getInfo().then(setRuntimeInfo).catch((error: unknown) => {
setNotice(error instanceof Error ? error.message : '无法读取 Tauri 运行时信息')
})
}, [])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
document.getElementById('tool-search')?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
const visibleGroups = useMemo(() => toolGroups.map((group) => ({
group,
tools: toolCatalog.filter((tool) => tool.group === group && (
!query.trim()
|| `${tool.title} ${tool.keywords.join(' ')}`.toLowerCase().includes(query.trim().toLowerCase())
))
})).filter(({ tools }) => tools.length > 0), [query])
function openTool(toolId: ToolId): void {
const tool = toolCatalog.find((item) => item.id === toolId)
if (tool && !tool.ready) {
setNotice(`${tool.title} 已进入独立产品路线图,当前 P0 先验证工作台与 Calculator 垂直切片。`)
return
}
setActiveTool(toolId)
setNotice('')
if (toolId !== 'home') {
setRecent((items) => [toolId, ...items.filter((item) => item !== toolId)].slice(0, 5))
}
}
return (
<main className={`app-shell ${sidebarCompact ? 'app-shell--compact' : ''}`}>
<div className="window-drag-region" data-tauri-drag-region />
<aside className="sidebar">
<div className="sidebar-toolbar">
<button
className="icon-button"
type="button"
aria-label={sidebarCompact ? '展开导航' : '收起导航'}
onClick={() => setSidebarCompact((value) => !value)}
>
<PanelLeftClose />
</button>
<label className="search-control">
<Search />
<input
id="tool-search"
value={query}
placeholder="搜索工具"
onChange={(event) => setQuery(event.target.value)}
/>
<kbd><Command />K</kbd>
</label>
</div>
<nav className="tool-nav" aria-label="工具导航">
<NavButton
icon={homeTool.icon}
label={homeTool.title}
active={activeTool === 'home'}
compact={sidebarCompact}
onClick={() => openTool('home')}
/>
{visibleGroups.map(({ group, tools }) => (
<section className="nav-group" key={group}>
<h2>{group}</h2>
{tools.map((tool) => (
<NavButton
key={tool.id}
icon={tool.icon}
label={tool.title}
active={activeTool === tool.id}
compact={sidebarCompact}
planned={!tool.ready}
onClick={() => openTool(tool.id)}
/>
))}
</section>
))}
{!query && recent.length > 0 && (
<section className="recent-group">
<h2> <ChevronDown /></h2>
{recent.map((toolId) => {
const tool = toolCatalog.find((item) => item.id === toolId)
return tool && <button type="button" key={toolId} onClick={() => openTool(toolId)}>{tool.title}</button>
})}
</section>
)}
</nav>
<footer className="sidebar-footer">
<div className="brand-lockup">
<span className="brand-symbol"><Boxes /></span>
<span>MooTool <small>Tauri</small></span>
</div>
<div className="footer-actions">
<button className="icon-button" type="button" aria-label="语言"><Languages /></button>
<button className="icon-button" type="button" aria-label="设置"><Settings /></button>
</div>
</footer>
</aside>
<section className="workspace">
<div className={activeTool === 'home' ? 'view-layer' : 'view-layer view-layer--hidden'}>
<HomePage runtimeInfo={runtimeInfo} onOpenCalculator={() => openTool('calculator')} />
</div>
<div className={activeTool === 'calculator' ? 'view-layer' : 'view-layer view-layer--hidden'}>
<CalculatorPage />
</div>
<div className={activeTool === 'webview-lab' ? 'view-layer' : 'view-layer view-layer--hidden'}>
<WebviewLab active={activeTool === 'webview-lab'} />
</div>
{notice && (
<button className="notice-toast" type="button" onClick={() => setNotice('')}>
{notice}
</button>
)}
</section>
</main>
)
}
function NavButton({ icon: Icon, label, active, compact, planned = false, onClick }: {
icon: typeof Boxes
label: string
active: boolean
compact: boolean
planned?: boolean
onClick(): void
}) {
return (
<button
className={`nav-button ${active ? 'nav-button--active' : ''}`}
type="button"
title={compact ? label : undefined}
onClick={onClick}
>
<Icon />
<span>{label}</span>
{planned && <i aria-label="计划中" />}
</button>
)
}
+120
View File
@@ -0,0 +1,120 @@
import {
Binary,
Braces,
Calculator,
Clock3,
Code2,
Diff,
FileCode2,
FileImage,
FileText,
Globe2,
HardDrive,
Image,
KeyRound,
Languages,
MessageSquareText,
Network,
NotebookPen,
Palette,
PanelsTopLeft,
QrCode,
Regex,
Rocket,
ScanSearch,
ServerCog,
ShieldCheck,
Variable,
type LucideIcon
} from 'lucide-react'
export type ToolId =
| 'home'
| 'quick-note'
| 'text-diff'
| 'reformat'
| 'json'
| 'config'
| 'runtime'
| 'protobuf'
| 'variables'
| 'http'
| 'host'
| 'network'
| 'ua'
| 'encode'
| 'crypto'
| 'regex'
| 'cron'
| 'qrcode'
| 'timestamp'
| 'message-board'
| 'translation'
| 'calculator'
| 'color'
| 'image'
| 'pdf'
| 'system'
| 'webview-lab'
export interface ToolDefinition {
id: ToolId
title: string
group: '文本与配置' | '开发工具' | '网络工具' | '编码工具' | '实用工具' | '系统工具'
icon: LucideIcon
ready: boolean
keywords: string[]
}
export const toolCatalog: ToolDefinition[] = [
tool('quick-note', '随手记', '文本与配置', NotebookPen, ['note', 'markdown']),
tool('text-diff', '文本对比', '文本与配置', Diff, ['diff', 'compare']),
tool('reformat', '格式化', '文本与配置', FileCode2, ['format', 'sql', 'xml']),
tool('json', 'JSON', '文本与配置', Braces, ['json', 'path']),
tool('config', 'YAML / Properties', '文本与配置', FileText, ['yaml', 'properties']),
tool('runtime', '代码运行', '开发工具', Code2, ['code', 'run']),
tool('protobuf', 'Protobuf', '开发工具', Binary, ['proto']),
tool('variables', '环境变量', '开发工具', Variable, ['env']),
tool('http', 'HTTP', '网络工具', Globe2, ['request', 'api']),
tool('host', 'Host', '网络工具', ServerCog, ['hosts', 'dns']),
tool('network', '网络 / IP', '网络工具', Network, ['ip', 'port']),
tool('ua', 'UA 分析', '网络工具', ScanSearch, ['user agent', 'browser']),
tool('encode', '编码解码', '编码工具', Rocket, ['base64', 'unicode']),
tool('crypto', '加解密 / 随机', '编码工具', KeyRound, ['hash', 'aes', 'uuid']),
tool('regex', 'Regex', '编码工具', Regex, ['regexp', '正则']),
tool('cron', 'Cron', '编码工具', Clock3, ['schedule']),
tool('qrcode', '二维码', '编码工具', QrCode, ['qr']),
tool('timestamp', '时间转换', '实用工具', Clock3, ['timestamp', 'date']),
tool('message-board', '留言板', '实用工具', MessageSquareText, ['message']),
tool('translation', '翻译', '实用工具', Languages, ['translate']),
{
...tool('calculator', '计算器', '实用工具', Calculator, ['calc', 'math', '进制']),
ready: true
},
tool('color', '调色板', '实用工具', Palette, ['color', 'picker']),
tool('image', '图片工具', '实用工具', Image, ['capture', 'resize']),
tool('pdf', 'PDF', '实用工具', FileImage, ['pdf', 'merge']),
tool('system', '硬件与系统', '系统工具', HardDrive, ['hardware', 'system']),
{
...tool('webview-lab', 'WebView 实验台', '系统工具', PanelsTopLeft, ['webview', 'reparent', 'p0']),
ready: true
}
]
export const toolGroups = [...new Set(toolCatalog.map((item) => item.group))]
export const homeTool = {
id: 'home' as const,
title: 'MooTool',
icon: ShieldCheck
}
function tool(
id: Exclude<ToolId, 'home'>,
title: string,
group: ToolDefinition['group'],
icon: LucideIcon,
keywords: string[]
): ToolDefinition {
return { id, title, group, icon, keywords, ready: false }
}
@@ -0,0 +1,191 @@
import { ArrowDown, ArrowUp, Equal, History, RotateCcw } from 'lucide-react'
import { useState } from 'react'
import {
combination,
convertBase,
evaluateExpression,
greatestCommonDivisor,
leastCommonMultiple,
permutation
} from './calculator'
export function CalculatorPage() {
const [expression, setExpression] = useState('2 * (3 + 4)')
const [result, setResult] = useState('14')
const [decimal, setDecimal] = useState('255')
const [hex, setHex] = useState('ff')
const [binary, setBinary] = useState('11111111')
const [gcdValues, setGcdValues] = useState(['54', '24'])
const [lcmValues, setLcmValues] = useState(['54', '24'])
const [permutationValues, setPermutationValues] = useState(['5', '2'])
const [combinationValues, setCombinationValues] = useState(['5', '2'])
const [history, setHistory] = useState(['表达式: 2 * (3 + 4) = 14'])
const [error, setError] = useState('')
function run(label: string, input: string, operation: () => string): void {
try {
const output = operation()
setResult(output)
setError('')
setHistory((current) => [`${label}: ${input} = ${output}`, ...current].slice(0, 12))
} catch (cause) {
setError(cause instanceof Error ? cause.message : '计算失败')
}
}
function evaluate(): void {
run('表达式', expression, () => evaluateExpression(expression))
}
return (
<section className="tool-page">
<header className="tool-header">
<div>
<span className="eyebrow">DAILY TOOL</span>
<h1></h1>
</div>
<button className="secondary-button" type="button" onClick={() => setHistory([])}>
<RotateCcw />
</button>
</header>
<div className="calculator-layout">
<div className="calculator-controls">
<section className="tool-card calculator-expression">
<h2></h2>
<div className="expression-row">
<label className="sr-only" htmlFor="calculator-expression"></label>
<input
id="calculator-expression"
value={expression}
onChange={(event) => setExpression(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') evaluate() }}
/>
<button className="primary-button" type="button" onClick={evaluate}>
<Equal />
</button>
</div>
{error && <p className="error-message" role="alert">{error}</p>}
</section>
<section className="tool-card">
<h2></h2>
<div className="base-grid">
<BaseInput label="十六进制" value={hex} onChange={setHex} />
<div className="conversion-actions">
<button type="button" onClick={() => run('HEX → DEC', hex, () => {
const value = convertBase(hex, 16, 10)
setDecimal(value)
return value
})}><ArrowDown />HEX DEC</button>
<button type="button" onClick={() => run('DEC → HEX', decimal, () => {
const value = convertBase(decimal, 10, 16)
setHex(value)
return value
})}><ArrowUp />DEC HEX</button>
</div>
<BaseInput label="十进制" value={decimal} onChange={setDecimal} />
<div className="conversion-actions">
<button type="button" onClick={() => run('DEC → BIN', decimal, () => {
const value = convertBase(decimal, 10, 2)
setBinary(value)
return value
})}><ArrowDown />DEC BIN</button>
<button type="button" onClick={() => run('BIN → DEC', binary, () => {
const value = convertBase(binary, 2, 10)
setDecimal(value)
return value
})}><ArrowUp />BIN DEC</button>
</div>
<BaseInput label="二进制" value={binary} onChange={setBinary} />
</div>
</section>
<IntegerOperation
title="最大公约数"
values={gcdValues}
onChange={setGcdValues}
action="计算 GCD"
onRun={() => run('GCD', gcdValues.join(', '), () => greatestCommonDivisor(gcdValues[0], gcdValues[1]))}
/>
<IntegerOperation
title="最小公倍数"
values={lcmValues}
onChange={setLcmValues}
action="计算 LCM"
onRun={() => run('LCM', lcmValues.join(', '), () => leastCommonMultiple(lcmValues[0], lcmValues[1]))}
/>
<IntegerOperation
title="排列 A(n,m)"
labels={['n', 'm']}
values={permutationValues}
onChange={setPermutationValues}
action="A(n,m)"
onRun={() => run('A(n,m)', permutationValues.join(', '), () => permutation(permutationValues[0], permutationValues[1]))}
/>
<IntegerOperation
title="组合 C(n,m)"
labels={['n', 'm']}
values={combinationValues}
onChange={setCombinationValues}
action="C(n,m)"
onRun={() => run('C(n,m)', combinationValues.join(', '), () => combination(combinationValues[0], combinationValues[1]))}
/>
</div>
<aside className="calculator-output">
<div className="result-panel">
<span></span>
<output>{result}</output>
</div>
<div className="history-panel">
<h2><History /></h2>
{history.length === 0
? <p className="empty-state"></p>
: history.map((item, index) => <p key={`${index}-${item}`}>{item}</p>)}
</div>
</aside>
</div>
</section>
)
}
function BaseInput({ label, value, onChange }: {
label: string
value: string
onChange(value: string): void
}) {
return (
<label className="base-input">
<span>{label}</span>
<input value={value} onChange={(event) => onChange(event.target.value)} />
</label>
)
}
function IntegerOperation({ title, labels = ['数值 1', '数值 2'], values, onChange, action, onRun }: {
title: string
labels?: [string, string]
values: string[]
onChange(values: string[]): void
action: string
onRun(): void
}) {
return (
<section className="tool-card operation-card">
<h2>{title}</h2>
<div className="operation-row">
{values.map((value, index) => (
<label key={labels[index]}>
<span>{labels[index]}</span>
<input
value={value}
onChange={(event) => onChange(values.map((current, itemIndex) => itemIndex === index ? event.target.value : current))}
/>
</label>
))}
<button className="secondary-button" type="button" onClick={onRun}>{action}</button>
</div>
</section>
)
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
combination,
convertBase,
evaluateExpression,
greatestCommonDivisor,
leastCommonMultiple,
permutation
} from './calculator'
describe('independent calculator domain', () => {
it('evaluates arithmetic without executing arbitrary code', () => {
expect(evaluateExpression('2 * (3 + 4)=')).toBe('14')
expect(evaluateExpression('-3 + 10 / 2')).toBe('2')
expect(evaluateExpression('.5 * 8')).toBe('4')
expect(() => evaluateExpression('globalThis')).toThrow()
expect(() => evaluateExpression('1 / 0')).toThrow()
expect(() => evaluateExpression('(1 + 2')).toThrow()
})
it('converts integer bases exactly', () => {
expect(convertBase('255', 10, 16)).toBe('ff')
expect(convertBase('11111111', 2, 10)).toBe('255')
expect(convertBase('-ff', 16, 10)).toBe('-255')
})
it('performs exact integer operations', () => {
expect(greatestCommonDivisor('54', '24')).toBe('6')
expect(leastCommonMultiple('6', '8')).toBe('24')
expect(permutation('5', '2')).toBe('20')
expect(combination('5', '2')).toBe('10')
})
})
@@ -0,0 +1,136 @@
export function evaluateExpression(expression: string): string {
const source = expression.trim().replace(/=$/, '')
if (!source || source.length > 500 || !/^[\d+\-*/().\s]+$/.test(source)) {
throw new Error('请输入有效的四则运算表达式')
}
const result = new ArithmeticParser(source).parse()
if (!Number.isFinite(result)) throw new Error('计算结果不是有限数值')
return Number.parseFloat(result.toPrecision(14)).toString()
}
export function convertBase(value: string, from: 2 | 10 | 16, to: 2 | 10 | 16): string {
const normalized = value.trim()
if (!normalized) throw new Error('请输入需要转换的数值')
const negative = normalized.startsWith('-')
const unsigned = normalized.replace(/^[+-]/, '')
const valid = from === 2 ? /^[01]+$/ : from === 10 ? /^\d+$/ : /^[\da-f]+$/i
if (!valid.test(unsigned)) throw new Error('输入值与当前进制不匹配')
const parsed = from === 10
? BigInt(unsigned)
: BigInt(`${from === 2 ? '0b' : '0x'}${unsigned}`)
return `${negative ? '-' : ''}${parsed.toString(to)}`
}
export function greatestCommonDivisor(left: string, right: string): string {
let a = absolute(parseInteger(left))
let b = absolute(parseInteger(right))
while (b !== 0n) [a, b] = [b, a % b]
return String(a)
}
export function leastCommonMultiple(left: string, right: string): string {
const a = parseInteger(left)
const b = parseInteger(right)
if (a === 0n || b === 0n) return '0'
return String(absolute((a / BigInt(greatestCommonDivisor(left, right))) * b))
}
export function permutation(nValue: string, mValue: string): string {
const [n, m] = parseCountPair(nValue, mValue)
let result = 1n
for (let value = n - m + 1; value <= n; value += 1) result *= BigInt(value)
return String(result)
}
export function combination(nValue: string, mValue: string): string {
const [n, requested] = parseCountPair(nValue, mValue)
const m = Math.min(requested, n - requested)
let result = 1n
for (let index = 1; index <= m; index += 1) {
result = (result * BigInt(n - m + index)) / BigInt(index)
}
return String(result)
}
function parseInteger(value: string): bigint {
if (!/^[+-]?\d+$/.test(value.trim())) throw new Error('请输入整数')
return BigInt(value.trim())
}
function parseCountPair(nValue: string, mValue: string): [number, number] {
const n = Number(nValue)
const m = Number(mValue)
if (!Number.isSafeInteger(n) || !Number.isSafeInteger(m) || n < 0 || m < 0 || m > n || n > 5000) {
throw new Error('需要满足 0 ≤ m ≤ n ≤ 5000')
}
return [n, m]
}
function absolute(value: bigint): bigint {
return value < 0n ? -value : value
}
class ArithmeticParser {
private position = 0
constructor(private readonly source: string) {}
parse(): number {
const result = this.parseAddition()
this.skipWhitespace()
if (this.position !== this.source.length) throw new Error('表达式格式错误')
return result
}
private parseAddition(): number {
let result = this.parseMultiplication()
while (true) {
if (this.consume('+')) result += this.parseMultiplication()
else if (this.consume('-')) result -= this.parseMultiplication()
else return result
}
}
private parseMultiplication(): number {
let result = this.parseUnary()
while (true) {
if (this.consume('*')) result *= this.parseUnary()
else if (this.consume('/')) result /= this.parseUnary()
else return result
}
}
private parseUnary(): number {
if (this.consume('+')) return this.parseUnary()
if (this.consume('-')) return -this.parseUnary()
return this.parsePrimary()
}
private parsePrimary(): number {
if (this.consume('(')) {
const result = this.parseAddition()
if (!this.consume(')')) throw new Error('表达式括号不匹配')
return result
}
this.skipWhitespace()
const match = /^(?:\d+(?:\.\d*)?|\.\d+)/.exec(this.source.slice(this.position))
if (!match) throw new Error('表达式格式错误')
this.position += match[0].length
return Number(match[0])
}
private consume(token: string): boolean {
this.skipWhitespace()
if (this.source[this.position] !== token) return false
this.position += 1
return true
}
private skipWhitespace(): void {
while (/\s/.test(this.source[this.position] ?? '')) this.position += 1
}
}
+74
View File
@@ -0,0 +1,74 @@
import { Boxes, CheckCircle2, Cpu, Database, GitBranch, PackageCheck } from 'lucide-react'
import type { RuntimeInfo } from '../../platform/contracts/runtime'
interface HomePageProps {
runtimeInfo?: RuntimeInfo
onOpenCalculator(): void
}
export function HomePage({ runtimeInfo, onOpenCalculator }: HomePageProps) {
return (
<section className="home-page">
<div className="home-hero">
<div className="brand-symbol brand-symbol--large"><Boxes aria-hidden="true" /></div>
<div>
<span className="eyebrow">INDEPENDENT DESKTOP PRODUCT</span>
<div className="home-title-row">
<h1>MooTool Next Tauri</h1>
<span className="version-pill">v{runtimeInfo?.version ?? '…'}</span>
</div>
<p> TauriRust WebView </p>
<button className="primary-button" type="button" onClick={onOpenCalculator}>
</button>
</div>
</div>
<div className="home-grid">
<article className="home-card home-card--accent">
<span className="card-icon"><CheckCircle2 /></span>
<div>
<h2>P0 线</h2>
<p>Rust CoreTauri-owned API Calculator </p>
</div>
</article>
<article className="home-card">
<span className="card-icon"><GitBranch /></span>
<div>
<h2>线</h2>
<p> Electron </p>
</div>
</article>
<article className="home-card">
<span className="card-icon"><Database /></span>
<div>
<h2></h2>
<p></p>
</div>
</article>
<article className="home-card">
<span className="card-icon"><PackageCheck /></span>
<div>
<h2></h2>
<p> ID Release Notes Tauri </p>
</div>
</article>
</div>
<section className="runtime-panel">
<div className="runtime-title">
<Cpu />
<div>
<h2></h2>
<p> Rust Command </p>
</div>
</div>
<dl>
<div><dt> ID</dt><dd>{runtimeInfo?.productId ?? '读取中'}</dd></div>
<div><dt></dt><dd>{runtimeInfo?.runtime ?? '读取中'}</dd></div>
<div><dt></dt><dd>{runtimeInfo ? `${runtimeInfo.platform} · ${runtimeInfo.architecture}` : '读取中'}</dd></div>
</dl>
</section>
</section>
)
}
@@ -0,0 +1,67 @@
import { Minus, Plus, RotateCcw, ShieldCheck } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { toolWebviewApi } from '../../platform/api/toolWebviewApi'
export function ToolProbePage() {
const sessionId = useRef(crypto.randomUUID())
const [counter, setCounter] = useState(0)
const [draft, setDraft] = useState('state survives reparent')
const [reportError, setReportError] = useState('')
useEffect(() => {
void toolWebviewApi.report({
sessionId: sessionId.current,
counter,
draft
}).then(() => setReportError('')).catch((error: unknown) => {
setReportError(error instanceof Error ? error.message : String(error))
})
}, [counter, draft])
return (
<main className="tool-probe">
<header className="tool-probe__header">
<div>
<span className="eyebrow">OWNED CHILD WEBVIEW</span>
<h1> WebView </h1>
</div>
<span className="probe-status"><ShieldCheck /> Rust Manager</span>
</header>
<section className="probe-grid">
<article className="probe-card">
<span> ID</span>
<strong data-testid="probe-session">{sessionId.current}</strong>
<p> ID </p>
</article>
<article className="probe-card probe-card--counter">
<span></span>
<output data-testid="probe-counter">{counter}</output>
<div>
<button type="button" aria-label="减少计数" onClick={() => setCounter((value) => value - 1)}>
<Minus />
</button>
<button type="button" aria-label="重置计数" onClick={() => setCounter(0)}>
<RotateCcw />
</button>
<button type="button" aria-label="增加计数" onClick={() => setCounter((value) => value + 1)}>
<Plus />
</button>
</div>
</article>
</section>
<label className="probe-draft">
<span>稿</span>
<input
value={draft}
data-testid="probe-draft"
onChange={(event) => setDraft(event.target.value)}
/>
<small> 100 </small>
</label>
{reportError && <p className="probe-error" role="alert">{reportError}</p>}
</main>
)
}
@@ -0,0 +1,246 @@
import {
CircleCheck,
CircleX,
ExternalLink,
PanelTop,
Play,
Power,
RefreshCw
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toolWebviewApi } from '../../platform/api/toolWebviewApi'
import type {
ToolWebviewBounds,
ToolWebviewSnapshot
} from '../../platform/contracts/toolWebview'
const initialSnapshot: ToolWebviewSnapshot = {
exists: false,
visible: false,
placement: 'closed',
reparentOperations: 0,
pageLoads: 0,
sessionId: null,
counter: 0,
draft: '',
lastStressCycles: 0,
lastStressPassed: null
}
export function WebviewLab({ active }: { active: boolean }) {
const slotRef = useRef<HTMLDivElement>(null)
const [snapshot, setSnapshot] = useState(initialSnapshot)
const [busy, setBusy] = useState('')
const [error, setError] = useState('')
const nativeRuntime = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__)
const readBounds = useCallback((): ToolWebviewBounds => {
const slot = slotRef.current
if (!slot) {
throw new Error('工具 WebView 容器尚未就绪')
}
const bounds = slot.getBoundingClientRect()
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height)
}
}, [])
const run = useCallback(async (
label: string,
operation: () => Promise<ToolWebviewSnapshot>
): Promise<void> => {
setBusy(label)
setError('')
try {
setSnapshot(await operation())
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy('')
}
}, [])
useEffect(() => {
let cancelled = false
void toolWebviewApi.getSnapshot().then(async (current) => {
if (cancelled) return
let next = current
if (current.exists && current.placement === 'docked') {
if (active) {
next = await toolWebviewApi.updateBounds(readBounds())
next = await toolWebviewApi.setVisible(true)
} else {
next = await toolWebviewApi.setVisible(false)
}
}
if (!cancelled) setSnapshot(next)
}).catch((cause: unknown) => {
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause))
})
return () => {
cancelled = true
}
}, [active, readBounds])
useEffect(() => {
if (!active) return
const timer = window.setInterval(() => {
void toolWebviewApi.getSnapshot().then(setSnapshot).catch(() => undefined)
}, 750)
return () => window.clearInterval(timer)
}, [active])
useEffect(() => {
if (!active || !slotRef.current) return
let frame = 0
const update = () => {
window.cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(() => {
void toolWebviewApi.getSnapshot().then((current) => {
if (current.exists && current.placement === 'docked') {
return toolWebviewApi.updateBounds(readBounds()).then(setSnapshot)
}
return undefined
}).catch(() => undefined)
})
}
const observer = new ResizeObserver(update)
observer.observe(slotRef.current)
window.addEventListener('resize', update)
return () => {
window.cancelAnimationFrame(frame)
observer.disconnect()
window.removeEventListener('resize', update)
}
}, [active, readBounds])
return (
<section className="webview-lab">
<header className="tool-header webview-lab__header">
<div>
<span className="eyebrow">P0 ARCHITECTURE PROBE</span>
<h1>WebView </h1>
</div>
<div className="webview-lab__actions">
<button
className="primary-button"
type="button"
disabled={busy !== '' || snapshot.exists}
onClick={() => void run('正在创建', () => toolWebviewApi.open(readBounds()))}
>
<Power />
</button>
<button
className="secondary-button"
type="button"
disabled={busy !== '' || !snapshot.exists || snapshot.placement === 'detached'}
onClick={() => void run('正在分离', () => toolWebviewApi.detach())}
>
<ExternalLink />
</button>
<button
className="secondary-button"
type="button"
disabled={busy !== '' || snapshot.placement !== 'detached'}
onClick={() => void run('正在收回', () => toolWebviewApi.dock(readBounds()))}
>
<PanelTop />
</button>
<button
className="secondary-button"
type="button"
disabled={busy !== '' || !snapshot.exists}
onClick={() => void run('100 次验证中', () => toolWebviewApi.stress(readBounds(), 100))}
>
<Play />100
</button>
<button
className="icon-button webview-lab__close"
type="button"
aria-label="关闭工具 WebView"
disabled={busy !== '' || !snapshot.exists}
onClick={() => void run('正在关闭', () => toolWebviewApi.close())}
>
<CircleX />
</button>
</div>
</header>
<section className="webview-metrics" aria-label="WebView 运行状态">
<Metric label="位置" value={placementLabel(snapshot.placement)} />
<Metric label="页面加载" value={`${snapshot.pageLoads}`} />
<Metric label="重挂载操作" value={`${snapshot.reparentOperations}`} />
<Metric label="探针计数" value={String(snapshot.counter)} />
<Metric
label="压力结论"
value={stressLabel(snapshot)}
passed={snapshot.lastStressPassed}
/>
</section>
<div className="tool-webview-frame">
<div ref={slotRef} className="tool-webview-slot" aria-label="原生工具 WebView 停靠区域">
{!snapshot.exists && (
<div className="tool-webview-placeholder">
<RefreshCw />
<strong> WebView</strong>
<span>
{nativeRuntime
? '创建后,这个区域由 Rust 管理的原生子 WebView 覆盖。'
: '浏览器预览只展示控制层;原生重挂载需在 Tauri 桌面运行。'}
</span>
</div>
)}
{snapshot.placement === 'detached' && (
<div className="tool-webview-placeholder">
<ExternalLink />
<strong> WebView </strong>
<span></span>
</div>
)}
</div>
<footer>
<span>
<code>{snapshot.sessionId ?? '等待探针上报'}</code>
</span>
<span>
稿<code>{snapshot.draft || '—'}</code>
</span>
{busy && <strong>{busy}</strong>}
{error && <strong className="webview-lab__error">{error}</strong>}
</footer>
</div>
</section>
)
}
function Metric({ label, value, passed }: {
label: string
value: string
passed?: boolean | null
}) {
return (
<div className={passed == null ? '' : passed ? 'metric--passed' : 'metric--failed'}>
<span>{label}</span>
<strong>
{passed === true && <CircleCheck />}
{passed === false && <CircleX />}
{value}
</strong>
</div>
)
}
function placementLabel(placement: ToolWebviewSnapshot['placement']): string {
return { closed: '未创建', docked: '已停靠', detached: '独立窗口' }[placement]
}
function stressLabel(snapshot: ToolWebviewSnapshot): string {
if (snapshot.lastStressPassed === null) return '未执行'
return snapshot.lastStressPassed
? `${snapshot.lastStressCycles} 次通过`
: `${snapshot.lastStressCycles} 次失败`
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './app/App'
import { ToolProbePage } from './features/webviewLab/ToolProbePage'
import './styles.css'
const surface = new URLSearchParams(window.location.search).get('surface')
createRoot(document.getElementById('root')!).render(
<StrictMode>
{surface === 'tool-probe' ? <ToolProbePage /> : <App />}
</StrictMode>
)
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { createRuntimeApi } from './runtimeApi'
describe('runtime API adapter', () => {
it('maps the domain call to the single owned Tauri command', async () => {
const runtimeInfo = {
productId: 'next-tauri' as const,
productName: 'MooTool Next Tauri',
version: '0.1.0',
platform: 'macos',
architecture: 'x86_64',
runtime: 'tauri' as const
}
const invoke = vi.fn().mockResolvedValue(runtimeInfo)
await expect(createRuntimeApi(invoke).getInfo()).resolves.toEqual(runtimeInfo)
expect(invoke).toHaveBeenCalledOnce()
expect(invoke).toHaveBeenCalledWith('get_runtime_info')
})
})
+23
View File
@@ -0,0 +1,23 @@
import { invoke } from '@tauri-apps/api/core'
import type { RuntimeApi, RuntimeInfo } from '../contracts/runtime'
type Invoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>
export function createRuntimeApi(invokeCommand: Invoke = invoke): RuntimeApi {
return {
getInfo: () => invokeCommand<RuntimeInfo>('get_runtime_info')
}
}
const browserPreviewInfo: RuntimeInfo = {
productId: 'next-tauri',
productName: 'MooTool Next Tauri',
version: 'web-preview',
platform: typeof navigator === 'undefined' ? 'browser' : navigator.platform || 'browser',
architecture: 'browser',
runtime: 'tauri'
}
export const runtimeApi: RuntimeApi = typeof window !== 'undefined' && window.__TAURI_INTERNALS__
? createRuntimeApi()
: { getInfo: async () => browserPreviewInfo }
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { createToolWebviewApi } from './toolWebviewApi'
describe('tool WebView API adapter', () => {
it('maps lifecycle operations to Tauri-owned domain commands', async () => {
const snapshot = {
exists: true,
visible: true,
placement: 'docked' as const,
reparentOperations: 200,
pageLoads: 1,
sessionId: 'session-a',
counter: 9,
draft: 'preserve me',
lastStressCycles: 100,
lastStressPassed: true
}
const invoke = vi.fn().mockResolvedValue(snapshot)
const api = createToolWebviewApi(invoke)
const bounds = { x: 280, y: 120, width: 800, height: 560 }
await api.open(bounds)
await api.updateBounds(bounds)
await api.setVisible(false)
await api.detach()
await api.dock(bounds)
await api.stress(bounds, 100)
await api.close()
await api.report({ sessionId: 'session-a', counter: 9, draft: 'preserve me' })
expect(invoke.mock.calls).toEqual([
['open_tool_webview', { bounds }],
['update_tool_webview_bounds', { bounds }],
['set_tool_webview_visible', { visible: false }],
['detach_tool_webview'],
['dock_tool_webview', { bounds }],
['stress_tool_webview_reparent', { bounds, cycles: 100 }],
['close_tool_webview'],
['report_tool_webview_probe', {
report: { sessionId: 'session-a', counter: 9, draft: 'preserve me' }
}]
])
})
})
@@ -0,0 +1,98 @@
import { invoke } from '@tauri-apps/api/core'
import type {
ToolProbeReport,
ToolWebviewApi,
ToolWebviewBounds,
ToolWebviewSnapshot
} from '../contracts/toolWebview'
type Invoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>
const closedSnapshot: ToolWebviewSnapshot = {
exists: false,
visible: false,
placement: 'closed',
reparentOperations: 0,
pageLoads: 0,
sessionId: null,
counter: 0,
draft: '',
lastStressCycles: 0,
lastStressPassed: null
}
export function createToolWebviewApi(invokeCommand: Invoke = invoke): ToolWebviewApi {
return {
getSnapshot: () => invokeCommand<ToolWebviewSnapshot>('get_tool_webview_snapshot'),
open: (bounds) => invokeCommand<ToolWebviewSnapshot>('open_tool_webview', { bounds }),
updateBounds: (bounds) => invokeCommand<ToolWebviewSnapshot>('update_tool_webview_bounds', { bounds }),
setVisible: (visible) => invokeCommand<ToolWebviewSnapshot>('set_tool_webview_visible', { visible }),
detach: () => invokeCommand<ToolWebviewSnapshot>('detach_tool_webview'),
dock: (bounds) => invokeCommand<ToolWebviewSnapshot>('dock_tool_webview', { bounds }),
stress: (bounds, cycles) => invokeCommand<ToolWebviewSnapshot>(
'stress_tool_webview_reparent',
{ bounds, cycles }
),
close: () => invokeCommand<ToolWebviewSnapshot>('close_tool_webview'),
report: (report) => invokeCommand<ToolWebviewSnapshot>('report_tool_webview_probe', { report })
}
}
function createBrowserPreviewApi(): ToolWebviewApi {
let snapshot = { ...closedSnapshot }
const withBounds = async (_bounds: ToolWebviewBounds): Promise<ToolWebviewSnapshot> => snapshot
return {
getSnapshot: async () => snapshot,
open: async () => {
snapshot = { ...snapshot, exists: true, visible: true, placement: 'docked', pageLoads: 1 }
return snapshot
},
updateBounds: withBounds,
setVisible: async (visible) => {
snapshot = { ...snapshot, visible }
return snapshot
},
detach: async () => {
snapshot = {
...snapshot,
placement: 'detached',
visible: true,
reparentOperations: snapshot.reparentOperations + 1
}
return snapshot
},
dock: async () => {
snapshot = {
...snapshot,
placement: 'docked',
visible: true,
reparentOperations: snapshot.reparentOperations + 1
}
return snapshot
},
stress: async (_bounds, cycles) => {
snapshot = {
...snapshot,
placement: 'docked',
reparentOperations: snapshot.reparentOperations + cycles * 2,
lastStressCycles: cycles,
lastStressPassed: snapshot.sessionId !== null
}
return snapshot
},
close: async () => {
snapshot = { ...closedSnapshot }
return snapshot
},
report: async (report: ToolProbeReport) => {
snapshot = { ...snapshot, ...report }
return snapshot
}
}
}
export const toolWebviewApi: ToolWebviewApi =
typeof window !== 'undefined' && window.__TAURI_INTERNALS__
? createToolWebviewApi()
: createBrowserPreviewApi()
@@ -0,0 +1,12 @@
export interface RuntimeInfo {
productId: 'next-tauri'
productName: string
version: string
platform: string
architecture: string
runtime: 'tauri'
}
export interface RuntimeApi {
getInfo(): Promise<RuntimeInfo>
}
@@ -0,0 +1,39 @@
export interface ToolWebviewBounds {
x: number
y: number
width: number
height: number
}
export type ToolWebviewPlacement = 'closed' | 'docked' | 'detached'
export interface ToolProbeReport {
sessionId: string
counter: number
draft: string
}
export interface ToolWebviewSnapshot {
exists: boolean
visible: boolean
placement: ToolWebviewPlacement
reparentOperations: number
pageLoads: number
sessionId: string | null
counter: number
draft: string
lastStressCycles: number
lastStressPassed: boolean | null
}
export interface ToolWebviewApi {
getSnapshot(): Promise<ToolWebviewSnapshot>
open(bounds: ToolWebviewBounds): Promise<ToolWebviewSnapshot>
updateBounds(bounds: ToolWebviewBounds): Promise<ToolWebviewSnapshot>
setVisible(visible: boolean): Promise<ToolWebviewSnapshot>
detach(): Promise<ToolWebviewSnapshot>
dock(bounds: ToolWebviewBounds): Promise<ToolWebviewSnapshot>
stress(bounds: ToolWebviewBounds, cycles: number): Promise<ToolWebviewSnapshot>
close(): Promise<ToolWebviewSnapshot>
report(report: ToolProbeReport): Promise<ToolWebviewSnapshot>
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface Window {
__TAURI_INTERNALS__?: unknown
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client", "vitest/globals", "node"]
},
"include": ["src", "vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
host: '127.0.0.1',
port: 1420,
strictPort: true,
watch: {
ignored: ['**/src-tauri/target/**']
}
},
build: {
target: ['es2022', 'chrome105', 'safari13']
}
})