[next-tauri]plan p0
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
@@ -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()` 在任一首发平台无法达到稳定性门槛,必须在以下方案中作出明确产品决策:
|
||||
|
||||
- 推迟“工具分离”功能,不阻塞其他工具。
|
||||
|
||||
@@ -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` 分配独立 Capability;Rust 校验调用方 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 后,原生内容消失且生命周期按钮回到未创建状态。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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":[]}}
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 741 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -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>,
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
mootool_next_tauri_lib::run();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>给开发者准备的桌面小工具,以 Tauri、Rust 和系统 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 Core、Tauri-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} 次失败`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
__TAURI_INTERNALS__?: unknown
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
})
|
||||