diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6897a9d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# 内容寻址的 blob 文件名就是内容的 sha256,任何行尾转换都会让校验失败 +# (core.autocrlf=true 的环境下,不加这两行就可能把包内 blob 改坏: +# 提交时 CRLF→LF、检出时 LF→CRLF 都会改变字节)。 +svfs-plugins-root/** -text +*.svfs -text diff --git a/.gitignore b/.gitignore index 2496aa6..c8f023b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,16 @@ build/ dist/ wheels/ *.egg-info +*.pyc # Virtual environments .venv resources +# 插件模板的 npm 依赖(examples/vue-plugin-template 里 npm install 自己装, +# 四百多个文件、且与仓库代码无关,不该进版本库) +node_modules/ + # output files output.md diff --git a/examples/plugin_server.py b/examples/plugin_server.py new file mode 100644 index 0000000..e0ad83d --- /dev/null +++ b/examples/plugin_server.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""SVFS 插件服务端的最小示例(FastAPI)。 + +一个进程同时承担客户端的两件事: + +1. **插件下载**:把每个插件的 publish 产物(``manifest.json`` + ``blobs/``) + 放在 ``PLUGINS_DIR/<插件id>/`` 下并由静态文件服务吐出——客户端 + 「从服务器安装」填插件 id 即可(对象逐个 sha256 校验); +2. **接口服务**:``/api/*`` 是普通 HTTP API,插件经 + ``svfs.net.request({path: "/api/ping"})`` 调用。宿主代发请求, + 不经过浏览器的 CORS,服务端不需要任何跨域配置。 + ``/api/*`` 需要登录:客户端登录界面调 ``POST /api/login`` 换 token, + 之后宿主自动给发往本服务器的请求附上 ``Authorization: Bearer``。 + +运行: + +.. code-block:: bash + + uv sync --extra fastapi # fastapi + starlette(本仓库的 extra) + uv pip install uvicorn # 服务器(示例直接用 uvicorn 起进程) + uv run python examples/plugin_server.py + # → http://127.0.0.1:8000 (客户端「服务器」卡片填这个地址) + +发布一个插件(也可以用下面的 POST /plugins/ 接口上传): + +.. code-block:: bash + + svfs pack ./my-plugin dist.svfs --compress + svfs publish dist.svfs svfs-plugins-root/com.example.my-plugin + # 客户端「从服务器安装」填:com.example.my-plugin + +目录布局(客户端约定,勿改): + +.. code-block:: text + + PLUGINS_DIR/ + └── <插件id>/ + ├── manifest.json + └── blobs// +""" + +import os +import secrets +import sqlite3 +import tempfile +from datetime import datetime +from pathlib import Path + +from fastapi import Depends, FastAPI, File, Header, HTTPException, Request, UploadFile +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.publish import export_objects +from svfs_client.manifest import ManifestError, read_from_vfs + +#: 插件包根:每个 <插件id>/ 一个子目录,里面是 svfs publish 的产物 +PLUGINS_DIR = Path(os.environ.get("SVFS_PLUGINS_DIR", "./svfs-plugins-root")).resolve() +PLUGINS_DIR.mkdir(parents=True, exist_ok=True) + +app = FastAPI(title="SVFS 插件服务", version="0.1.0") + + +# ---------------------------------------------------------------------- +# 登录认证:客户端启动时经登录界面换取 token,之后访问 /api/* 都带上它 +# (会话在内存里,重启服务端后客户端会收到 401 并被要求重新登录) +# ---------------------------------------------------------------------- + +#: 演示账号:未设置 ``SVFS_USERS`` 环境变量时的兜底 +DEMO_USERS = {"admin": "admin123"} + + +def _load_users(): + """从 ``SVFS_USERS``(``user:pass,user2:pass2`` 格式)读账号表,每次请求现读, + 改环境变量即时生效;未设置(或置空)时用内置演示账号 admin / admin123。""" + raw = os.environ.get("SVFS_USERS", "") + users = {} + for item in raw.split(","): + item = item.strip() + if not item: + continue + name, _, password = item.partition(":") + name, password = name.strip(), password.strip() + if name and password: + users[name] = password + return users or DEMO_USERS + + +#: token -> 会话(username / login_time;进程内存会话,重启即失效) +SESSIONS = {} + + +def require_auth(authorization: str = Header(default="")): + """FastAPI 依赖:校验 ``Authorization: Bearer ``,通过返回会话 dict。""" + token = authorization[7:].strip() if authorization.startswith("Bearer ") else "" + session = SESSIONS.get(token) + if not session: + raise HTTPException(status_code=401, detail="未登录或登录已过期") + return session + + +class LoginBody(BaseModel): + username: str + password: str + + +@app.post("/api/login") +def login(body: LoginBody): + """客户端登录界面调用:账号密码换 token。""" + if _load_users().get(body.username) != body.password or not body.password: + raise HTTPException(status_code=401, detail="用户名或密码不正确") + token = secrets.token_hex(32) + login_time = datetime.now().isoformat(timespec="seconds") + SESSIONS[token] = {"username": body.username, "login_time": login_time} + return {"ok": True, "token": token, "username": body.username, + "login_time": login_time} + + +@app.get("/api/me") +def me(session: dict = Depends(require_auth)): + """客户端启动时校验记住的 token 是否仍然有效。""" + return {"ok": True, **session} + + +@app.post("/api/logout") +def logout(authorization: str = Header(default="")): + """退出登录:作废当前 token。""" + token = authorization[7:].strip() if authorization.startswith("Bearer ") else "" + SESSIONS.pop(token, None) + return {"ok": True} + + +# ---------------------------------------------------------------------- +# 接口服务:插件经 svfs.net.request 调用(随你自己的业务随意增删) +# ---------------------------------------------------------------------- + + +@app.get("/api/ping") +def ping(session: dict = Depends(require_auth)): + """演示插件的「请求 /api/ping」按钮打的就是这里。 + + 需要登录(Bearer token):客户端桥会给发往本服务器的 net 请求 + 自动带上登录 token,插件代码不用关心认证。 + """ + return {"pong": True, "user": session["username"], + "time": datetime.now().isoformat(timespec="seconds")} + + +@app.post("/api/echo") +async def echo(request: Request, session: dict = Depends(require_auth)): + """演示 POST + JSON body:插件把 body 作为字符串放进 net 请求。""" + body = await request.json() + return {"received": body, "ok": True, "user": session["username"]} + + +# ---------------------------------------------------------------------- +# 插件上传:收一个 .svfs,服务端完成 publish(对象 + 清单) +# ---------------------------------------------------------------------- + + +def _check_upload_token(request_token): + """可选的上传令牌:设置环境变量 ``SVFS_UPLOAD_TOKEN`` 后, + 上传必须带 ``X-SVFS-Token`` 头(每次请求现读,改环境变量即时生效)。""" + expected = os.environ.get("SVFS_UPLOAD_TOKEN", "") + if expected and request_token != expected: + raise HTTPException(status_code=403, detail="上传令牌不正确") + + +@app.post("/plugins/{plugin_id}") +async def upload_plugin(plugin_id: str, file: UploadFile = File(...), + x_svfs_token: str = Header(default="")): + """上传一个插件包并就地发布。 + + 服务端做三件事:读 plugin.json 校验清单(与客户端同一套规则)、 + 确认清单 id 与 URL 一致、用 ``export_objects`` 导出成 + ``manifest.json + blobs/``(内容寻址,重复上传自动去重)。 + """ + _check_upload_token(x_svfs_token) + data = await file.read() + + handle_fd, tmp_path = tempfile.mkstemp(suffix=".svfs") + try: + with os.fdopen(handle_fd, "wb") as handle: + handle.write(data) + try: + vfs = SQLiteVFS(tmp_path, readonly=True) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"不是有效的 .svfs 包: {exc}") + try: + try: + manifest = read_from_vfs(vfs) + except ManifestError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + report = vfs.verify() + if not report["ok"]: + raise HTTPException(status_code=400, + detail="包完整性校验未通过: " + ";".join(report["errors"][:3])) + finally: + vfs.close() + + if manifest["id"] != plugin_id: + raise HTTPException( + status_code=400, + detail=f"清单 id ({manifest['id']}) 与 URL 里的插件 id ({plugin_id}) 不一致", + ) + + stats = export_objects(tmp_path, str(PLUGINS_DIR / plugin_id), overwrite=True) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + return { + "ok": True, + "id": manifest["id"], + "version": manifest["version"], + "files": stats["files"], + "digest": stats["digest"], + } + + +# ---------------------------------------------------------------------- +# 客户端自更新信息(可选) +# ---------------------------------------------------------------------- + + +@app.get("/client/manifest.json") +def client_manifest(): + """客户端启动时查一次"有没有推荐的新版客户端"。 + + 生产上这个文件通常是静态托管的(与插件同一套 ``/…`` 布局, + 内容 ``{"version", "url", "notes"}``);示例服务端用环境变量现场生成, + 方便演示提示链路: + + .. code-block:: bash + + SVFS_CLIENT_VERSION=9.9.9 SVFS_CLIENT_URL=https://example.com/dl \\ + uv run python examples/plugin_server.py + + 没设置时返回 404 = 客户端"没有更新信息"(静默跳过,不当错误)。 + """ + version = os.environ.get("SVFS_CLIENT_VERSION", "").strip() + if not version: + raise HTTPException(status_code=404, detail="未配置 SVFS_CLIENT_VERSION") + return {"version": version, + "url": os.environ.get("SVFS_CLIENT_URL", "").strip(), + "notes": os.environ.get("SVFS_CLIENT_NOTES", "").strip()} + + +# ---------------------------------------------------------------------- +# 插件下载:静态吐出 PLUGINS_DIR(务必注册在所有 API 路由之后) +# ---------------------------------------------------------------------- + + +@app.get("/") +def index(): + plugins = sorted( + entry.name for entry in PLUGINS_DIR.iterdir() + if (entry / "manifest.json").is_file() + ) + return {"service": "svfs-plugin-server", "plugins": plugins} + + +app.mount("/", StaticFiles(directory=str(PLUGINS_DIR)), name="plugins") + + +if __name__ == "__main__": + import uvicorn + + demo = os.environ.get("SVFS_USERS", "").strip() + if demo: + print(f"登录账号来自 SVFS_USERS 环境变量({len(demo.split(','))} 个)") + else: + print("登录账号:admin / admin123(可用 SVFS_USERS=\"user:pass,user2:pass2\" 自定义)") + uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("SVFS_PORT", "8000"))) diff --git a/examples/vue-plugin-template/README.md b/examples/vue-plugin-template/README.md new file mode 100644 index 0000000..c54f1d7 --- /dev/null +++ b/examples/vue-plugin-template/README.md @@ -0,0 +1,53 @@ +# SVFS 插件开发模板(Vue 3 + Vite) + +用 Vue 3 开发 SVFS 插件的起点模板:构建产物打进 `.svfs` 包、在客户端的 +WebView 里以 `svfs://<插件id>/` 独立 origin 运行。附赠浏览器 mock,让 +`npm run dev` 不进客户端也能迭代 UI。 + +## 目录结构 + +```text +public/plugin.json 插件清单(vite 会原样拷进 dist 根目录——清单必须在包根) +src/main.js 入口;dev 模式自动装 window.svfs 模拟器 +src/svfs.js window.svfs 的薄封装(Promise + JSON 便捷层),组件统一从这里用 +src/mock-svfs.js 浏览器版宿主模拟器(storage→localStorage,net→fetch,对话框→confirm/alert) +src/App.vue 分页签的组织方式(主机信息 / 存储 / 网络 / 界面·对话框) +src/components/*.vue 每类能力一个面板组件,照着抄就行 +``` + +## 开发流程 + +```bash +npm install + +# 1) 浏览器里迭代 UI(mock 桥;net 相对路径打到 vite dev server) +npm run dev + +# 2) 构建 + 打包(plugin.json 在 public/ 下,构建后位于 dist 根,满足"清单在包根") +npm run build +svfs pack ./dist plugin.svfs --compress + +# 3a) 安装到客户端:客户端「设置 → 插件管理 → 安装本地插件…」选 plugin.svfs +# 3b) 或发布到示例服务端,客户端「从服务器安装」填 com.example.vue-demo: +svfs publish plugin.svfs svfs-plugins-root/com.example.vue-demo +``` + +## 桥能力速查(src/svfs.js) + +| 能力 | 需要权限 | 说明 | +|---|---|---| +| `getHostInfo()` | 无 | `host.info`;含登录用户 `authUser`(v3+) | +| `createStorage(prefix)` | storage | 按插件隔离的键值存储 | +| `netRequest/netJson` | net | 宿主代发 HTTP,绕开 CORS,同源请求自动带登录 token | +| `notify / setTitle` | notify / title | 状态栏通知 / 改窗口标题 | +| `confirmDialog / messageBox` | dialog | 宿主**原生对话框**(v4+),Promise 返回用户选择 | + +## 注意事项 + +* **`vite.config` 里 `base: "./"` 不能丢**:包内是独立 origin,绝对路径资源会 404。 +* manifest 的 `api_version` 是你要求的最低宿主版本:用了 `dialog` 就写 4, + 没用新能力可以写 2(兼容面更大)。改 id 记得同步 `public/plugin.json` + 与上传路径——两处必须一致。 +* 静态资源放 `src/assets/` 里 `import` 引用(会被 vite 重写为相对路径); + 放 `public/` 的文件按根相对路径引用。 +* 真实桥的行为以客户端「设置 → 桥调试台」插件为准;mock 只覆盖日常迭代。 diff --git a/examples/vue-plugin-template/index.html b/examples/vue-plugin-template/index.html new file mode 100644 index 0000000..2cc764d --- /dev/null +++ b/examples/vue-plugin-template/index.html @@ -0,0 +1,13 @@ + + + + + + Vue 模板插件 + + + +
+ + + diff --git a/examples/vue-plugin-template/package-lock.json b/examples/vue-plugin-template/package-lock.json new file mode 100644 index 0000000..9fdce66 --- /dev/null +++ b/examples/vue-plugin-template/package-lock.json @@ -0,0 +1,1265 @@ +{ + "name": "svfs-vue-plugin-template", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "svfs-vue-plugin-template", + "version": "0.1.0", + "dependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/examples/vue-plugin-template/package.json b/examples/vue-plugin-template/package.json new file mode 100644 index 0000000..dc87de3 --- /dev/null +++ b/examples/vue-plugin-template/package.json @@ -0,0 +1,18 @@ +{ + "name": "svfs-vue-plugin-template", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/examples/vue-plugin-template/public/plugin.json b/examples/vue-plugin-template/public/plugin.json new file mode 100644 index 0000000..161f6ed --- /dev/null +++ b/examples/vue-plugin-template/public/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "com.example.vue-demo", + "name": "Vue 模板插件", + "version": "0.1.0", + "api_version": 4, + "entry": "index.html", + "permissions": ["storage", "notify", "title", "net", "dialog"], + "description": "用 Vue 3 + Vite 开发的 SVFS 插件模板,演示全部桥能力" +} diff --git a/examples/vue-plugin-template/requirements.txt b/examples/vue-plugin-template/requirements.txt new file mode 100644 index 0000000..18ca5f5 Binary files /dev/null and b/examples/vue-plugin-template/requirements.txt differ diff --git a/examples/vue-plugin-template/src/App.vue b/examples/vue-plugin-template/src/App.vue new file mode 100644 index 0000000..3731aff --- /dev/null +++ b/examples/vue-plugin-template/src/App.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/examples/vue-plugin-template/src/components/HostPanel.vue b/examples/vue-plugin-template/src/components/HostPanel.vue new file mode 100644 index 0000000..7031ae2 --- /dev/null +++ b/examples/vue-plugin-template/src/components/HostPanel.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/examples/vue-plugin-template/src/components/NetPanel.vue b/examples/vue-plugin-template/src/components/NetPanel.vue new file mode 100644 index 0000000..56c04dd --- /dev/null +++ b/examples/vue-plugin-template/src/components/NetPanel.vue @@ -0,0 +1,47 @@ + + + diff --git a/examples/vue-plugin-template/src/components/StoragePanel.vue b/examples/vue-plugin-template/src/components/StoragePanel.vue new file mode 100644 index 0000000..9f43f62 --- /dev/null +++ b/examples/vue-plugin-template/src/components/StoragePanel.vue @@ -0,0 +1,61 @@ + + + diff --git a/examples/vue-plugin-template/src/components/UiPanel.vue b/examples/vue-plugin-template/src/components/UiPanel.vue new file mode 100644 index 0000000..a3de8a9 --- /dev/null +++ b/examples/vue-plugin-template/src/components/UiPanel.vue @@ -0,0 +1,44 @@ + + + diff --git a/examples/vue-plugin-template/src/main.js b/examples/vue-plugin-template/src/main.js new file mode 100644 index 0000000..05e24b9 --- /dev/null +++ b/examples/vue-plugin-template/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import { installMockSvfs } from "./mock-svfs"; + +// 浏览器里 npm run dev 时没有宿主桥:装上模拟器,保证组件代码两态一致 +if (import.meta.env.DEV) { + installMockSvfs(); +} + +createApp(App).mount("#app"); diff --git a/examples/vue-plugin-template/src/mock-svfs.js b/examples/vue-plugin-template/src/mock-svfs.js new file mode 100644 index 0000000..fa9a3f8 --- /dev/null +++ b/examples/vue-plugin-template/src/mock-svfs.js @@ -0,0 +1,82 @@ +// 开发模式的 window.svfs 模拟器:让 `npm run dev` 在普通浏览器里也能跑。 +// +// * storage → localStorage(真实客户端里是按插件隔离的 JSON 文件) +// * net → fetch(相对 path 打到 vite dev server;要连示例服务端可以 +// 在 vite.config 里加 proxy,或请求时直接给完整 url) +// * 对话框 → window.confirm / alert(真实客户端里是原生 QMessageBox) +// +// 客户端内的最终效果以「设置 → 桥调试台」插件为准;mock 只覆盖开发迭代。 +export function installMockSvfs() { + if (window.svfsReady) return; // 已在宿主里,别覆盖 + + let resolveReady; + window.svfsReady = new Promise((resolve) => (resolveReady = resolve)); + + const KEY_PREFIX = "mock-svfs:"; + const storageKey = (key) => KEY_PREFIX + key; + + window.svfs = { + host: { + info: async () => ({ + pluginId: "com.example.vue-demo", + appName: "Browser Dev", + appVersion: "0.0.0", + apiVersion: 4, + platform: "browser", + baseUrl: location.origin, + authUser: "", + }), + }, + storage: { + get: async (key) => localStorage.getItem(storageKey(key)), + set: async (key, value) => { + localStorage.setItem(storageKey(key), JSON.stringify(value)); + return true; + }, + remove: async (key) => { + localStorage.removeItem(storageKey(key)); + return true; + }, + keys: async () => + Object.keys(localStorage) + .filter((key) => key.startsWith(KEY_PREFIX)) + .map((key) => key.slice(KEY_PREFIX.length)), + }, + net: { + request: async (options) => { + const response = await fetch(options.url || options.path || "/", { + method: options.method || "GET", + headers: options.headers, + body: options.body, + }); + const bodyText = await response.text(); + return { + ok: response.ok, + status: response.status, + headers: {}, + bodyText, + bodyBase64: "", + }; + }, + }, + ui: { + notify: async (message) => { + console.log("[svfs notify]", message); + return true; + }, + setTitle: async (title) => { + document.title = title; + return true; + }, + confirm: async (options) => + window.confirm(`${options.title || "确认"}\n\n${options.text || ""}`), + message: async (options) => { + window.alert(`[${options.kind || "info"}] ${options.title || ""}\n\n${options.text || ""}`); + return true; + }, + }, + }; + + console.info("[mock-svfs] 已安装浏览器版 window.svfs 模拟器(真实效果以客户端为准)"); + resolveReady(window.svfs); +} diff --git a/examples/vue-plugin-template/src/svfs.js b/examples/vue-plugin-template/src/svfs.js new file mode 100644 index 0000000..7a172ab --- /dev/null +++ b/examples/vue-plugin-template/src/svfs.js @@ -0,0 +1,87 @@ +// window.svfs 的薄封装:统一等待桥就绪(window.svfsReady 是宿主注入的 +// Promise),把每类能力包成小函数,组件里直接 import 使用,不用碰全局。 +// +// 在浏览器里 npm run dev 时没有宿主桥——main.js 会先装上 mock-svfs, +// 所以这些函数在两种环境下行为一致。 + +export function onSvfsReady() { + if (window.svfsReady) return window.svfsReady; + return Promise.reject( + new Error("未检测到 svfs 桥:请在 SVFS Client 中运行本插件") + ); +} + +/** 宿主信息:{pluginId, appName, appVersion, apiVersion, platform, baseUrl, authUser} */ +export async function getHostInfo() { + const svfs = await onSvfsReady(); + return svfs.host.info(); +} + +/** 带命名空间前缀的键值存储(按插件隔离) */ +export function createStorage(prefix = "") { + return { + async get(key) { + const svfs = await onSvfsReady(); + const raw = await svfs.storage.get(prefix + key); + return raw === undefined || raw === null ? null : raw; + }, + async set(key, value) { + const svfs = await onSvfsReady(); + return svfs.storage.set(prefix + key, value); + }, + async remove(key) { + const svfs = await onSvfsReady(); + return svfs.storage.remove(prefix + key); + }, + async keys() { + const svfs = await onSvfsReady(); + return svfs.storage.keys(); + }, + }; +} + +/** 原始 net 请求 → {ok, status, headers, bodyText, bodyBase64};4xx/5xx 也是正常返回 */ +export async function netRequest(options) { + const svfs = await onSvfsReady(); + if (!svfs.net) throw new Error("宿主没有 net 能力(manifest 需要声明 net 权限)"); + return svfs.net.request(options); +} + +/** net + JSON 便捷封装:path 相对服务器地址;非 2xx 抛错(带 response) */ +export async function netJson(options) { + const res = await netRequest(options); + let data = null; + try { + data = res.bodyText ? JSON.parse(res.bodyText) : null; + } catch { + data = res.bodyText; + } + if (res.status >= 400) { + const err = new Error(`HTTP ${res.status}`); + err.response = { status: res.status, data }; + throw err; + } + return { status: res.status, data }; +} + +export async function notify(message) { + const svfs = await onSvfsReady(); + return svfs.ui.notify(message); +} + +export async function setTitle(title) { + const svfs = await onSvfsReady(); + return svfs.ui.setTitle(title); +} + +/** 宿主原生确认框(dialog 权限):Promise,用户点"确定"返回 true */ +export async function confirmDialog(text, title = "确认") { + const svfs = await onSvfsReady(); + return svfs.ui.confirm({ text, title }); +} + +/** 宿主原生消息框:kind = "info" | "warn" | "error" */ +export async function messageBox(text, { title = "提示", kind = "info" } = {}) { + const svfs = await onSvfsReady(); + return svfs.ui.message({ text, title, kind }); +} diff --git a/examples/vue-plugin-template/vite.config.mjs b/examples/vue-plugin-template/vite.config.mjs new file mode 100644 index 0000000..119651c --- /dev/null +++ b/examples/vue-plugin-template/vite.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +// 插件最终从 svfs://<插件id>/ 包内加载:必须用相对路径(base: "./"), +// 否则构建产物里的 /assets/... 绝对路径在包内会 404。 +export default defineConfig({ + plugins: [vue()], + base: "./", + build: { + outDir: "dist", + // 客户端内嵌 Chromium 较新,无需过度降级 + target: "chrome110", + }, +}); diff --git a/src/svfs_client/__init__.py b/src/svfs_client/__init__.py new file mode 100644 index 0000000..7bda103 --- /dev/null +++ b/src/svfs_client/__init__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""SVFS Client —— 以 .svfs 单文件分发的插件化桌面客户端(PySide6 + WebEngine)。 + +概念模型: + +* 一个插件 = 一个**完整的 .svfs 包**(完整前端 + 根目录一份 ``plugin.json`` 清单)。 + 插件之间互不可见:每个插件一个包文件、一个独立 origin(``svfs://<插件id>/``)、 + 一份独立的键值存储。 +* 版本模型是"快照即版本":一个插件一个 .svfs 文件,每次升级往里打一个新快照, + 没变的文件共享 blob,回滚 = 把注册表的当前快照指回旧的那一版。 + +分层(与 svfs_studio 相同的约定): + +* 顶层与 ``manifest`` / ``paths`` / ``registry`` / ``installer`` / ``sample`` + 是**纯标准库**,不 import Qt,可以在没有 PySide6 的环境里单独测试; +* ``scheme`` / ``bridge`` / ``webview`` / ``worker`` / ``shell`` / ``app`` + 属于 Qt 层,只在 GUI 进程里被导入。 +""" + +#: 客户端版本(与 sqlite-vfs 的版本号分开走) +__version__ = "0.1.0" + +#: 窗口标题 +APP_NAME = "SVFS Client" + +#: 宿主实现的桥 API 版本。plugin.json 的 ``api_version`` 是插件**要求的 +#: 最低宿主版本**:宿主接受 api_version ≤ 自身的插件(桥能力只增不破, +#: 旧插件跑在新宿主上没问题;要求更高版本的插件会得到"请升级客户端"提示)。 +#: 只有桥的能力集合(permissions)变化时才升这个号: +#: 1 —— 首版:storage / notify / title +#: 2 —— 新增 net(经宿主请求服务端接口)与 host.info 的 baseUrl 字段 +#: 3 —— host.info 新增 authUser;宿主给发往同源服务器的 net 请求 +#: 自动附加 ``Authorization: Bearer <登录token>``(纯增量,v2 插件 +#: 不受影响,在 v3 宿主上照常安装运行) +#: 4 —— 新增 dialog 权限与 ui.confirm / ui.message(宿主原生对话框) +PLUGIN_API_VERSION = 4 diff --git a/src/svfs_client/__main__.py b/src/svfs_client/__main__.py new file mode 100644 index 0000000..80aeec3 --- /dev/null +++ b/src/svfs_client/__main__.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""``python -m svfs_client`` / ``svfs-client`` 的入口。 + +缺 PySide6(或 WebEngine)时给一句人话提示而不是一段栈——GUI 依赖是可选 +extra,没装是正常状态。注意插件客户端**必须**用完整版 PySide6: +PySide6-Essentials 不含 WebEngine。 +""" + +import sys + + +def main(): + try: + from .app import main as run + except ImportError as exc: # 最常见的就是没装 PySide6 / WebEngine + missing = getattr(exc, "name", "") or str(exc) + if "PySide6" in str(exc) or "PySide6" in missing: + print( + "SVFS Client 需要 PySide6(完整版,含 WebEngine;核心库本身零第三方依赖)。\n" + "请任选一种方式安装:\n" + ' uv sync --extra gui (在本仓库里)\n' + ' uv pip install "sqlite-vfs[gui]"\n' + ' pip install PySide6\n' + "注意:PySide6-Essentials 不含 WebEngine,运行插件客户端不够用。", + file=sys.stderr, + ) + # 冻结环境里 PySide6 明明在却走到这里 = 打包时裁多了, + # 把原始错误吐出来方便定位缺了哪个模块 + print(f"原始导入错误: {exc!r}", file=sys.stderr) + return 1 + raise + return run() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/svfs_client/app.py b/src/svfs_client/app.py new file mode 100644 index 0000000..3fedc0f --- /dev/null +++ b/src/svfs_client/app.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""应用引导:QApplication、主题、命令行参数。 + +注意顺序:``register_scheme()`` 必须发生在 QApplication 创建**之前** +(Qt 要求自定义 scheme 在 WebEngine 初始化前注册),所以这里在 +create_app() 的第一行做。 +""" + +import sys + +from PySide6.QtCore import QCoreApplication, Qt +from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap +from PySide6.QtWidgets import QApplication + +from svfs_studio import theme + +from . import APP_NAME, credentials, diagnostics, settings, __version__ +from .scheme import register_scheme + +__all__ = ["create_app", "main"] + + +def _excepthook(exc_type, exc, tb): + """未捕获异常进诊断日志,再走默认处理。""" + diagnostics.log(f"未捕获异常: {exc_type.__name__}: {exc}", "crash") + sys.__excepthook__(exc_type, exc, tb) + + +def _logo_pixmap(size=64, alpha=255): + """没有图标资源时现场画一个:圆角方块 + "S"。 + + 启动动画与登录界面(login.py)也用同一支画笔,保证视觉一致; + ``alpha`` 供动画做呼吸效果。 + """ + pixmap = QPixmap(size, size) + pixmap.fill(QColor("#00000000")) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setOpacity(alpha / 255.0) + painter.setBrush(QColor(theme.ACCENT)) + painter.setPen(QColor(theme.ACCENT)) + painter.drawRoundedRect(1, 1, size - 2, size - 2, size * 0.22, size * 0.22) + painter.setPen(QColor(theme.ACCENT_TEXT)) + font = QFont() + font.setPixelSize(int(size * 0.55)) + font.setBold(True) + painter.setFont(font) + painter.drawText(pixmap.rect(), 0x0084, "S") # AlignCenter + painter.end() + return pixmap + + +def _app_icon(): + return QIcon(_logo_pixmap(64)) + + +def create_app(argv=None): + """创建并配置 QApplication(scheme 注册 → Fusion → 暗色主题)。""" + register_scheme() + # WebEngine 的官方要求:在 QApplication 之前共享 OpenGL 上下文。 + # 不设的话 Chromium 会自建 GL 上下文,首次打开插件时容易出现整窗闪烁。 + QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) + app = QApplication(argv or [sys.argv[0]]) + app.setApplicationName(APP_NAME) + app.setApplicationVersion(__version__) + app.setOrganizationName("svfs-client") + theme.apply_theme(app) + # 客户端特有的少量补充样式:插件列表的行更"胖",页面栈背景与窗口一致 + app.setStyleSheet(app.styleSheet() + f""" +QListWidget#PluginList {{ background: transparent; border: none; padding: 0; }} +QListWidget#PluginList::item {{ padding: 6px 8px; border-radius: {theme.RADIUS}px; }} +QListWidget#PluginRail {{ background: transparent; border: none; padding: 0; }} +QListWidget#PluginRail::item {{ border-radius: {theme.RADIUS}px; }} +QWebEngineView {{ background: {theme.BG}; }} +""") + app.setWindowIcon(_app_icon()) + app.setQuitOnLastWindowClosed(True) + sys.excepthook = _excepthook + return app + + +def main(argv=None): + from . import paths + from .shell import MainWindow + + argv = list(sys.argv if argv is None else argv) + base_override = None + open_plugin = None + args = argv[1:] + if "--help" in args or "-h" in args: + print( + f"SVFS Client {__version__} —— 以 .svfs 单文件分发的插件化桌面客户端\n" + "\n" + "用法: svfs-client [--data 目录] [--open 插件id]\n" + "\n" + " --data 目录 指定数据目录(默认 %LOCALAPPDATA%/svfs-client)\n" + " --open 插件id 启动后直接打开某个已安装的插件\n" + " -h, --help 显示本说明\n" + "\n" + "界面里:启动后先登录服务器(示例服务端默认账号 admin / admin123,\n" + "勾选「记住登录」可跳过后续登录);侧栏点插件即打开,插件管理在\n" + "「设置」里(安装本地 .svfs、从服务器安装、切换版本/回滚、校验与\n" + "卸载),侧栏底部是用户中心与设置入口。" + ) + return 0 + if "--data" in args: + index = args.index("--data") + if index + 1 < len(args): + base_override = args[index + 1] + del args[index:index + 2] + if "--open" in args: + index = args.index("--open") + if index + 1 < len(args): + open_plugin = args[index + 1] + del args[index:index + 2] + + app = create_app(argv[:1]) + # 生效数据目录:--data 参数 > 指针文件(设置里可改) > 默认目录 + base = paths.ensure_layout(paths.resolve_data_dir(base_override)) + # 日志落盘 + 代理/CA:登录发生在主窗口之前(启动动画里的静默校验就会 + # 发 HTTPS 请求),所以这两件事必须在这一步就位 + error = settings.apply_runtime(base) + if error: + print(f"提示: {error}", file=sys.stderr) + diagnostics.log( + f"启动 v{__version__}(数据目录 {base};" + f"代理 {settings.proxy_config(base)[0] or '系统默认'};" + f"凭据后端 {credentials.backend(base)})", "startup") + + # 强制登录:启动动画(顺带静默校验记住的 token)→ 登录界面 → 主窗口。 + # 启动链期间关掉"最后一个窗口关闭即退出"——启动动画淡出与登录窗出现 + # 之间的空档会把还没跑起来的应用整个带走,主窗口亮出来后再恢复。 + from .login import StartupFlow + + app.setQuitOnLastWindowClosed(False) + session = StartupFlow(base).run() + if session is None: + return 0 # 登录窗被关闭:强制登录下即退出客户端 + + window = MainWindow(base, session=session) + app.setQuitOnLastWindowClosed(True) + window.show() + # 启动动画/登录这段时间里进程一直不在前台,Windows 的前台锁可能 + # 不让 show() 把新窗口提到最前(结果只在任务栏里,被别的窗口挡住) + window.raise_() + window.activateWindow() + if open_plugin: + # 插件列表是异步刷新的:登记意图,apply_payload 收到列表后打开 + window._pending_open = open_plugin + return app.exec() diff --git a/src/svfs_client/auth.py b/src/svfs_client/auth.py new file mode 100644 index 0000000..dda630a --- /dev/null +++ b/src/svfs_client/auth.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""登录认证(纯标准库,可脱离 Qt 测试)。 + +客户端启动时的登录流程: + +1. 登录界面把用户名/密码交给 :func:`login`,服务端返回 token(内存会话); +2. token 存在 settings.json(勾选「记住登录」时),下次启动先经 + :func:`verify_token` 静默校验,有效就跳过登录界面; +3. 宿主桥给发往服务器的 net 请求自动附加 ``Authorization: Bearer``, + 插件代码不用关心认证(见 bridge.py)。 + +这里只封装 HTTP 细节,不管界面与持久化;所有失败统一抛 +:class:`AuthError`,消息是可直接展示给用户的中文。 +""" + +import json +import urllib.error +import urllib.request + +from . import netutil, settings + +__all__ = ["AuthError", "AUTH_TIMEOUT", "login", "verify_token", "logout"] + +#: 登录/校验请求的超时(秒)。比插件 net 请求短:这是启动路径,卡久了 +# 界面会一直停在「正在登录」。 +AUTH_TIMEOUT = 8 + + +class AuthError(Exception): + """认证失败(网络不通/凭据错误/服务器响应无效),消息可直接展示。 + + ``network=True`` 表示是网络层失败(服务器不可达),而非凭据错误—— + 启动链据此决定是否允许离线宽限进入。 + """ + + def __init__(self, message, network=False): + super().__init__(message) + self.network = network + + +def _request(base_url, path, token=None, data=None, timeout=AUTH_TIMEOUT, + method=None): + """向服务器发一个 JSON 请求,返回解析后的响应 dict。 + + 4xx 时抛带服务端 detail 的 AuthError;网络层失败抛「无法连接」; + 响应不是 JSON 对象时抛「服务器响应无效」。method 缺省按有无 body + 推断(有 = POST,无 = GET)。 + """ + try: + base = settings.normalize_base_url(base_url) + except ValueError as exc: + raise AuthError(str(exc)) from exc + if not base: + raise AuthError("尚未配置服务器地址") + request = urllib.request.Request( + f"{base}/{path.lstrip('/')}", + data=json.dumps(data).encode("utf-8") if data is not None else None, + method=method or ("POST" if data is not None else "GET"), + ) + request.add_header("Accept", "application/json") + if data is not None: + request.add_header("Content-Type", "application/json") + if token: + request.add_header("Authorization", f"Bearer {token}") + try: + # loopback 目标绕过系统代理(同 bridge:代理会黑掉 localhost 请求) + with netutil.urlopen(request, timeout=timeout) as response: + body = response.read(1024 * 1024) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = json.loads(exc.read(64 * 1024)).get("detail", "") + except Exception: + pass + if exc.code == 401: + raise AuthError(detail or "用户名或密码不正确") from exc + if exc.code in (408, 429, 502, 503, 504): + # 服务器临时不可用/过载:也算网络侧失败(允许离线宽限) + raise AuthError(f"服务器暂时不可用(HTTP {exc.code})", + network=True) from exc + raise AuthError(f"服务器返回 HTTP {exc.code}") from exc + except (urllib.error.URLError, OSError) as exc: + raise AuthError(f"无法连接服务器 {base}:{_brief(exc)}", + network=True) from exc + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise AuthError("服务器响应无效(不是 JSON)") from exc + if not isinstance(payload, dict): + raise AuthError("服务器响应无效(不是 JSON 对象)") + return payload + + +def _brief(exc): + """网络异常压成一句话(urllib 的默认文案太啰嗦)。""" + reason = getattr(exc, "reason", None) + return str(reason) if reason is not None else str(exc) + + +def login(base_url, username, password, timeout=AUTH_TIMEOUT): + """账号密码换 token,返回 ``{"token", "username", "base_url"}``。""" + username = str(username or "").strip() + password = str(password or "") + if not username: + raise AuthError("请输入用户名") + if not password: + raise AuthError("请输入密码") + payload = _request(base_url, "/api/login", timeout=timeout, + data={"username": username, "password": password}) + token = str(payload.get("token") or "") + if not token: + raise AuthError("服务器响应无效(缺少 token)") + return { + "token": token, + "username": str(payload.get("username") or username), + "base_url": settings.normalize_base_url(base_url), + # 服务端可选提供(示例服务端有):用户中心展示用 + "login_time": str(payload.get("login_time") or ""), + } + + +def verify_token(base_url, token, timeout=AUTH_TIMEOUT): + """校验记住的 token,有效返回 ``{"username", "base_url", "login_time"}``。""" + if not str(token or "").strip(): + raise AuthError("没有已保存的登录凭据") + payload = _request(base_url, "/api/me", token=token, timeout=timeout) + return { + "username": str(payload.get("username") or ""), + "base_url": settings.normalize_base_url(base_url), + "login_time": str(payload.get("login_time") or ""), + } + + +def logout(base_url, token, timeout=AUTH_TIMEOUT): + """通知服务器作废 token;尽力而为,失败不影响本地退出。""" + if not str(token or "").strip(): + return + try: + _request(base_url, "/api/logout", token=token, timeout=timeout, + method="POST") + except AuthError: + pass diff --git a/src/svfs_client/bridge.py b/src/svfs_client/bridge.py new file mode 100644 index 0000000..d5d98cf --- /dev/null +++ b/src/svfs_client/bridge.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""受限 JS 桥:插件前端与宿主之间唯一通道(Qt 层)。 + +每个插件页面挂一个 :class:`SvfsBridge` 实例(经 QWebChannel 注册为 +``channel.objects.svfs``),注入的 bootstrap 把它包成页内的 +``window.svfs``(Promise API,见 webview.py)。 + +能力清单与 manifest 的 ``permissions`` 一一对应,**没声明的能力一律拒绝**: + +=================== ========================================================= +权限 暴露的 API +=================== ========================================================= +storage storage.get / set / remove / keys(按插件隔离的 JSON 文件) +notify ui.notify(系统托盘通知;没有托盘时退回状态栏提示) +title ui.setTitle(改窗口标题) +net net.request(宿主代发 HTTP 请求,url 或相对服务器地址的 path) +dialog ui.confirm / ui.message(宿主原生对话框,见 uiDialog) +=================== ========================================================= + +host.info 不需要权限——它只包含插件自己的元数据、宿主版本号与配置的服务器 +地址,不含用户数据。拒绝不抛异常(QWebChannel 的 slot 异常对 JS 不可见), +而是返回 null/False,前端按"能力不存在"降级即可。 + +net 的特殊之处:HTTP 请求可能耗时数秒,而 QWebChannel 的 slot 跑在主线程, +直接在 slot 里发请求会卡住整个界面。所以 :meth:`netRequest` 只把请求投递给 +后台线程并立刻返回请求 id,结果经 :attr:`netResult` 信号送回页面——bootstrap +把这一来一回包成普通的 Promise(见 webview.py)。 + +超时与重试是**可配的**(``settings.net_timeout`` / ``net_retries``,见 +:meth:`_net_options`):内网接口慢或过网关抖动时不用改代码。幂等方法才会 +重试(见 netutil)。 +""" + +import base64 +import json +import os +import tempfile +import threading +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +from PySide6.QtCore import QObject, Signal, Slot + +from . import APP_NAME, PLUGIN_API_VERSION, __version__ +from . import diagnostics, paths, settings as settings_mod +from . import manifest as manifest_mod +from . import netutil + +__all__ = ["SvfsBridge"] + +#: 单个 net 响应的大小上限 +NET_MAX_BYTES = 20 * 1024 * 1024 +#: net 请求超时(秒)的默认值;实际取值来自设置(settings.net_options) +NET_TIMEOUT = 30 + + +class SvfsBridge(QObject): + """一个插件的桥实例。构造参数决定了它能碰到的所有数据。""" + + #: 用户可见的通知(宿主接信号显示为系统托盘通知,无托盘时退回状态栏) + notifyRequested = Signal(str) + #: 插件请求改窗口标题 + titleRequested = Signal(str) + #: net 请求的应答(JSON:{reqId, ...结果或 error}),从后台线程发出 + netResult = Signal(str) + #: 宿主注入的登录 token 被服务器拒绝(401)——壳层据此引导重新登录 + sessionRejected = Signal(str) + + def __init__(self, plugin_id, permissions, storage_file, + base_url_provider=None, session_provider=None, + net_options_provider=None, allowed_hosts=None, parent=None): + super().__init__(parent) + self._plugin_id = plugin_id + self._permissions = set(permissions or []) + #: 插件清单里的 net 目标白名单(空 = 不限制,老插件行为不变) + self._allowed_hosts = list(allowed_hosts or []) + self._storage_file = storage_file + self._storage_lock = threading.Lock() + self._storage = None # 惰性加载 + #: 返回当前配置的服务器地址的 callable(未配置时返回 "" / None)。 + #: 用 callable 而不是快照值:设置改动后立刻生效,不用重建插件页面。 + self._base_url_provider = base_url_provider + #: 返回当前登录会话的 callable(``{"token","username"}`` 或 None)。 + #: net 请求发给同源服务器时宿主自动附上 Bearer token(插件无感知)。 + self._session_provider = session_provider + #: 返回 ``(timeout, retries)`` 的 callable(缺省用模块默认值)。 + #: 同上是现读:改了设置下一次请求就生效。 + self._net_options_provider = net_options_provider + self._net_pool = ThreadPoolExecutor( + max_workers=2, thread_name_prefix=f"svfs-net-{plugin_id}" + ) + self._net_seq = 0 + self._net_seq_lock = threading.Lock() + + def shutdown(self): + """页面销毁时调用:不再接受新的 net 请求,正在跑的等它超时自然结束。""" + self._net_pool.shutdown(wait=False) + + def _base_url(self): + if self._base_url_provider is None: + return "" + try: + return settings_mod.normalize_base_url(self._base_url_provider() or "") + except ValueError: + return "" + + def _session(self): + if self._session_provider is None: + return None + session = self._session_provider() + return session if isinstance(session, dict) else None + + def _net_options(self): + """当前生效的 ``(timeout, retries)``;provider 缺失或抛错都用默认值。""" + if self._net_options_provider is None: + return NET_TIMEOUT, 0 + try: + timeout, retries = self._net_options_provider() + return max(1, int(timeout)), max(0, int(retries)) + except Exception: + return NET_TIMEOUT, 0 + + # ---- host(无需权限) ------------------------------------------ + + @Slot(result="QVariantMap") + def hostInfo(self): + return { + "pluginId": self._plugin_id, + "appName": APP_NAME, + "appVersion": __version__, + "apiVersion": PLUGIN_API_VERSION, + "platform": _platform(), + "baseUrl": self._base_url(), + "authUser": (self._session() or {}).get("username") or "", + } + + # ---- storage(需要 "storage" 权限) ---------------------------- + + @Slot(str, result="QVariant") + def storageGet(self, key): + if not self._allowed("storage") or not self._valid_key(key): + return None + data = self._load_storage() + value = data.get(key) + return None if value is None else json.dumps(value, ensure_ascii=False) + + @Slot(str, str, result=bool) + def storageSet(self, key, value_json): + if not self._allowed("storage") or not self._valid_key(key): + return False + try: + value = json.loads(value_json) + except (TypeError, ValueError): + return False + data = self._load_storage() + data[key] = value + return self._save_storage(data) + + @Slot(str, result=bool) + def storageRemove(self, key): + if not self._allowed("storage") or not self._valid_key(key): + return False + data = self._load_storage() + data.pop(key, None) + return self._save_storage(data) + + @Slot(result="QVariantList") + def storageKeys(self): + if not self._allowed("storage"): + return [] + return sorted(self._load_storage().keys()) + + # ---- ui(notify / title 各自需要权限) -------------------------- + + @Slot(str, result=bool) + def notify(self, message): + if not self._allowed("notify"): + return False + text = str(message).strip() + if not text: + return False + self.notifyRequested.emit(text[:500]) # 防刷屏:截断超长消息 + return True + + @Slot(str, result=bool) + def setTitle(self, title): + if not self._allowed("title"): + return False + text = str(title).strip() + if not text: + return False + self.titleRequested.emit(text[:200]) + return True + + # ---- dialog(需要 "dialog" 权限) ------------------------------- + # + # 原生对话框是少数允许"阻塞"的 slot:QMessageBox.exec 在嵌套事件 + # 循环里等用户点击,主线程界面保持可交互,slot 返回后 JS 的 Promise + # 才落定——所以这里可以安全地同步拿到用户的选择。 + + @Slot(str, result=str) + def uiDialog(self, payload_json): + """弹宿主原生对话框,返回 JSON ``{"ok": bool, "accepted": bool}``。 + + payload: ``{kind: "confirm"|"info"|"warn"|"error", title, text, detail}`` + (kind 缺省 confirm;title/text/detail 各有长度上限,超长截断。) + """ + if not self._allowed("dialog"): + return json.dumps({"ok": False, "error": "没有 dialog 权限"}) + try: + payload = json.loads(payload_json or "{}") + if not isinstance(payload, dict): + raise ValueError("payload 必须是 JSON 对象") + except (TypeError, ValueError) as exc: + return json.dumps({"ok": False, "error": str(exc)}) + + kind = str(payload.get("kind") or "confirm").lower() + title = str(payload.get("title") or "")[:200] + text = str(payload.get("text") or "")[:2000] + detail = str(payload.get("detail") or "")[:2000] + + from PySide6.QtWidgets import QMessageBox + + box = QMessageBox(self.parent()) + if kind == "confirm": + box.setIcon(QMessageBox.Icon.Question) + box.setWindowTitle(title or "确认") + box.setStandardButtons( + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel + ) + else: + box.setIcon({ + "warn": QMessageBox.Icon.Warning, + "error": QMessageBox.Icon.Critical, + }.get(kind, QMessageBox.Icon.Information)) + box.setWindowTitle(title or "提示") + box.setStandardButtons(QMessageBox.StandardButton.Ok) + box.setText(text or "(空)") + if detail: + box.setInformativeText(detail) + accepted = box.exec() == QMessageBox.StandardButton.Ok + return json.dumps({"ok": True, "accepted": accepted}) + + # ---- net(需要 "net" 权限) ------------------------------------- + # + # slot 里绝不做网络 IO(会卡主线程):netRequest 只投递并返回请求 id, + # 结果经 netResult 信号送回。两步都是 JSON 字符串进出。 + + @Slot(str, result=str) + def netRequest(self, payload_json): + """投递一个 HTTP 请求,立刻返回请求 id(解析失败返回空串)。""" + if not self._allowed("net"): + return "" + try: + payload = json.loads(payload_json) + if not isinstance(payload, dict): + raise ValueError("payload 必须是 JSON 对象") + except (TypeError, ValueError): + return "" + with self._net_seq_lock: + self._net_seq += 1 + req_id = str(self._net_seq) + self._net_pool.submit(self._run_net, req_id, payload) + return req_id + + def _run_net(self, req_id, payload): + """在工作线程里执行真正的 HTTP 请求;无论如何都会回一个 netResult。""" + injected = False + status = None + try: + url = settings_mod.resolve_net_url( + self._base_url(), + payload.get("url"), payload.get("path"), + ) + self._check_host(url) + method = str(payload.get("method") or "GET").upper() + if method not in ("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"): + raise ValueError(f"不支持的 HTTP 方法: {method}") + headers = payload.get("headers") or {} + if not isinstance(headers, dict): + raise ValueError("headers 必须是对象") + body = payload.get("body") + data = body.encode("utf-8") if isinstance(body, str) else None + if body is not None and not isinstance(body, str): + raise ValueError("body 必须是字符串") + + request = urllib.request.Request(url, data=data, method=method) + for key, value in headers.items(): + request.add_header(str(key), str(value)) + injected = self._inject_auth(request, url, headers) + timeout, retries = self._net_options() + # loopback 目标绕过系统代理;幂等方法(GET/HEAD)遇 5xx/连接错误 + # 按退避重试——策略集中在 netutil,这里只管把配置喂进去 + with netutil.open_with_retry(request, timeout=timeout, + retries=retries) as response: + raw = response.read(NET_MAX_BYTES + 1) + if len(raw) > NET_MAX_BYTES: + raise ValueError( + f"响应超过大小上限({NET_MAX_BYTES // (1024 * 1024)} MB)" + ) + status = int(response.status) + result = { + "status": status, + "ok": True, + "headers": { + key: value for key, value in response.getheaders() + }, + } + except urllib.error.HTTPError as exc: + # HTTP 4xx/5xx 也算"请求成功送达",把状态码交给插件自己处理 + status = int(exc.code) + raw = exc.read(NET_MAX_BYTES + 1) or b"" + if len(raw) > NET_MAX_BYTES: + result = {"status": status, "ok": False, + "error": "响应超过大小上限"} + raw = b"" + else: + result = {"status": status, "ok": False, + "headers": dict(exc.headers or {})} + except Exception as exc: # 超时/DNS 失败/协议错误/校验失败…… + diagnostics.log(f"net 请求失败: {exc}", "net") + self.netResult.emit(json.dumps( + {"reqId": req_id, "ok": False, "error": str(exc)}, + ensure_ascii=False)) + return + + self._report_auth_rejection(status, injected) + result["bodyBase64"] = base64.b64encode(raw).decode("ascii") + try: + result["bodyText"] = raw.decode("utf-8") + except UnicodeDecodeError: + result["bodyText"] = "" # 二进制内容走 bodyBase64 + self.netResult.emit(json.dumps({"reqId": req_id, **result}, + ensure_ascii=False)) + + def _check_host(self, url): + """插件清单声明了 net_hosts 时,只放行白名单里的主机。 + + 不声明 = 不限制;声明了就一律拒绝(不抛给插件一个"能不能通"的 + 模糊结果,而是明确的错误信息,插件作者一眼能看出是清单配错了)。 + """ + if not self._allowed_hosts: + return + host = urllib.parse.urlsplit(url).hostname or "" + if manifest_mod.host_allowed(host, self._allowed_hosts): + return + raise ValueError( + f"目标主机不在插件的 net_hosts 白名单里: {host}" + f"(允许: {', '.join(self._allowed_hosts)})" + ) + + def _report_auth_rejection(self, status, injected): + """宿主注入的 token 被服务器拒绝时通知壳层(引导重新登录)。 + + 只认"这次请求确实带了宿主注入的 Authorization,且应答 401": + 插件用自己那套认证 scheme 收到 401 与宿主会话无关,不该惊动用户。 + 信号从工作线程发出,Qt 会自动排队到主线程的接收者。 + """ + if not injected or status != 401: + return + diagnostics.log("服务器拒绝了宿主注入的登录凭据(HTTP 401)", "auth") + self.sessionRejected.emit("服务器返回 401:登录状态已失效") + + def _inject_auth(self, request, url, plugin_headers): + """给发往同源服务器的请求附加登录 token(插件无感知),返回是否注入。 + + 只在登录会话存在、目标与 base_url 同源(netloc 相等)、且插件 + 没有自带 Authorization 头时注入——不把凭据发给第三方地址, + 也不覆盖插件自己声明的认证。 + """ + if any(str(key).lower() == "authorization" for key in plugin_headers): + return False + token = str((self._session() or {}).get("token") or "") + base = self._base_url() + if not token or not base: + return False + try: + same_origin = ( + urllib.parse.urlsplit(url).netloc == urllib.parse.urlsplit(base).netloc + ) + except ValueError: + return False + if same_origin: + request.add_header("Authorization", f"Bearer {token}") + return True + return False + + # ---- 内部 ------------------------------------------------------ + + def _allowed(self, permission): + return permission in self._permissions + + @staticmethod + def _valid_key(key): + return isinstance(key, str) and 0 < len(key) <= 200 + + def _load_storage(self): + with self._storage_lock: + if self._storage is None: + try: + with open(self._storage_file, "r", encoding="utf-8") as f: + self._storage = json.load(f) + if not isinstance(self._storage, dict): + self._storage = {} + except (OSError, ValueError): + self._storage = {} + return self._storage + + def _save_storage(self, data): + with self._storage_lock: + self._storage = data + os.makedirs(os.path.dirname(self._storage_file), exist_ok=True) + fd, tmp = tempfile.mkstemp( + dir=os.path.dirname(self._storage_file), + prefix=f".{os.path.basename(self._storage_file)}-", + suffix=".tmp", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + os.replace(tmp, self._storage_file) + return True + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + return False + + +def _platform(): + import platform + + return f"{platform.system()} {platform.release()}" + + +def make_storage_file(base, plugin_id): + """桥 storage 的文件路径(paths 的便捷转发,测试与 shell 共用)。""" + return paths.storage_file(base, plugin_id) diff --git a/src/svfs_client/credentials.py b/src/svfs_client/credentials.py new file mode 100644 index 0000000..50f17a3 --- /dev/null +++ b/src/svfs_client/credentials.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""登录令牌的安全存储(纯标准库)。 + +问题:登录 token 是能直接冒充用户的东西。它原先明文躺在 ``settings.json`` +里——任何读得到数据目录的进程(备份/同步盘/误传的目录快照/另一个本地账号) +都能拿走它,而数据目录恰恰是一个"会被整体拷走"的东西。 + +后端按可用性择优,**从不明文写 token 到 settings.json**: + +=========== ========================================================== +backend 说明 +=========== ========================================================== +``dpapi`` Windows 数据保护 API(CryptProtectData)。密文只有**当前用户 + 在本机**能解开(换机器/换用户都解不开),不需要第三方依赖 +``keyring`` 其它平台上的系统凭据库(macOS Keychain / Linux Secret + Service),经可选依赖 ``keyring`` 访问;没装就跳过 +``plain`` 兜底:明文写 ``credentials.dat``。只在上面两者都不可用时才 + 走到这里,且会如实上报(:func:`is_secure` 为 False, + 设置界面据此提示用户) +=========== ========================================================== + +凭据文件 ``credentials.dat`` 不是秘密本身(dpapi/keyring 时里面只有密文或 +一句"去系统凭据库取"),但它是**为什么**能取到秘密的说明: + +.. code-block:: json + + {"version": 1, "backend": "dpapi", "base_url": "http://10.0.0.5:8000", + "username": "admin", "token": ""} + +``base_url`` 参与匹配:令牌只发给**签发它的那个服务器**。用户在设置里换了 +服务器地址后,旧令牌不会被当成新服务器的凭据发出去(这是原先单值 +``auth_token`` 的一个隐患)。 + +老版本的凭据在 settings.json 的 ``auth_token`` 字段里,:func:`migrate` +负责搬过来并清掉原字段(启动链调用一次)。 +""" + +import base64 +import json +import os +import tempfile + +from . import diagnostics, paths + +__all__ = [ + "BACKENDS", + "backend", + "is_secure", + "describe", + "save", + "load", + "clear", + "migrate", +] + +RECORD_VERSION = 1 + +#: DPAPI 密文里带的备注(CryptProtectData 的 szDataDescr) +_DPAPI_DESCRIPTION = "svfs-client login token" +#: 不弹任何 UI 提示的 DPAPI 标志(客户端是无人值守的后台调用) +_CRYPTPROTECT_UI_FORBIDDEN = 0x01 +#: keyring 里的 service 名 +_KEYRING_SERVICE = "svfs-client" + +BACKEND_DPAPI = "dpapi" +BACKEND_KEYRING = "keyring" +BACKEND_PLAIN = "plain" + +BACKENDS = (BACKEND_DPAPI, BACKEND_KEYRING, BACKEND_PLAIN) + + +# ---------------------------------------------------------------------- +# 后端探测 +# ---------------------------------------------------------------------- + + +def _keyring(): + """可选依赖 ``keyring``(没装返回 None)。测试通过替换它注入假后端。""" + try: + import keyring + except Exception: # ImportError / 依赖损坏 / 后端初始化失败 + return None + return keyring + + +def _keyring_usable(): + """keyring 装了不代表能用(Linux 上没 Secret Service 就会抛)。 + + 真去写一次才知道——这里用"能取出后端名"当可用性判据,失败当不可用。 + """ + module = _keyring() + if module is None: + return False + try: + module.get_keyring() + return True + except Exception: + return False + + +def _dpapi_usable(): + return os.name == "nt" and hasattr(__import__("ctypes"), "WinDLL") + + +def backend(base=""): + """当前生效的存储后端名(探测,不落盘)。 + + ``base`` 只在已有凭据文件时有用:文件里记着当初用的是哪个后端, + 继续沿用它(避免"上次能用 DPAPI,这次探测失败"把凭据弄丢)。 + """ + stored = _read_record(base).get("backend") if base else "" + if stored in BACKENDS and _usable(stored): + return stored + if _dpapi_usable(): + return BACKEND_DPAPI + if _keyring_usable(): + return BACKEND_KEYRING + return BACKEND_PLAIN + + +def _usable(name): + if name == BACKEND_DPAPI: + return _dpapi_usable() + if name == BACKEND_KEYRING: + return _keyring_usable() + return True + + +def is_secure(base=""): + """凭据是否受保护(plain 后端 = 明文,返回 False)。""" + return backend(base) != BACKEND_PLAIN + + +def describe(base=""): + """给界面用的一句话(说清存在哪、能不能防住"文件被拷走")。""" + return { + BACKEND_DPAPI: "Windows DPAPI 加密(仅当前用户在本机可解)", + BACKEND_KEYRING: "系统凭据库(keyring)", + BACKEND_PLAIN: "明文保存(本机没有可用的加密后端,任何能读数据目录的" + "进程都能取走它)", + }.get(backend(base), "未知") + + +# ---------------------------------------------------------------------- +# DPAPI +# ---------------------------------------------------------------------- + + +class _Blob: + """ctypes 的 DATA_BLOB(延迟构造成真正的 Structure,模块导入不碰 DLL)。""" + + @staticmethod + def define(): + import ctypes + + class DATA_BLOB(ctypes.Structure): + _fields_ = [("cbData", ctypes.c_ulong), + ("pbData", ctypes.POINTER(ctypes.c_char))] + + return DATA_BLOB + + +def _dpapi_call(data, protect): + """调一次 CryptProtectData / CryptUnprotectData,返回结果字节。""" + import ctypes + from ctypes import wintypes + + blob_type = _Blob.define() + crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + payload = bytes(data) + buffer = ctypes.create_string_buffer(payload, len(payload)) + blob_in = blob_type(len(payload), + ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char))) + blob_out = blob_type() + + function = crypt32.CryptProtectData if protect else crypt32.CryptUnprotectData + function.argtypes = [ + ctypes.POINTER(blob_type), + ctypes.c_wchar_p if protect else ctypes.POINTER(ctypes.c_wchar_p), + ctypes.POINTER(blob_type), ctypes.c_void_p, ctypes.c_void_p, + wintypes.DWORD, ctypes.POINTER(blob_type), + ] + function.restype = wintypes.BOOL + + ok = function(ctypes.byref(blob_in), + _DPAPI_DESCRIPTION if protect else None, + None, None, None, _CRYPTPROTECT_UI_FORBIDDEN, + ctypes.byref(blob_out)) + if not ok: + raise OSError(ctypes.WinError(ctypes.get_last_error())) + try: + return ctypes.string_at(blob_out.pbData, blob_out.cbData) + finally: + kernel32.LocalFree.argtypes = [wintypes.HLOCAL] + kernel32.LocalFree.restype = wintypes.HLOCAL + kernel32.LocalFree(blob_out.pbData) + + +# ---------------------------------------------------------------------- +# 凭据文件读写 +# ---------------------------------------------------------------------- + + +def _read_record(base): + """读凭据记录;缺文件/坏 JSON/格式不对都返回 {}(凭据坏了等于没登录)。""" + if not base: + return {} + try: + with open(paths.credentials_file(base), "r", encoding="utf-8") as handle: + record = json.load(handle) + except (OSError, ValueError): + return {} + return record if isinstance(record, dict) else {} + + +def _write_record(base, record): + """原子写凭据文件;POSIX 下顺带收权限(Windows 靠用户配置目录的 ACL)。""" + file = paths.credentials_file(base) + os.makedirs(os.path.dirname(file), exist_ok=True) + handle_fd, tmp = tempfile.mkstemp(dir=os.path.dirname(file), + prefix=".credentials-", suffix=".tmp") + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + json.dump(record, handle, ensure_ascii=False, indent=2) + if os.name != "nt": + os.chmod(tmp, 0o600) + os.replace(tmp, file) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def _delete_record(base): + try: + os.unlink(paths.credentials_file(base)) + except OSError: + pass + + +# ---------------------------------------------------------------------- +# 对外 API +# ---------------------------------------------------------------------- + + +def save(base, token, *, base_url="", username=""): + """存令牌(空 token = 清除)。返回生效的后端名。 + + 令牌只在 :func:`load` 传入**同一个 base_url** 时才交还——见模块文档。 + """ + token = str(token or "") + if not token: + clear(base) + return backend(base) + + base_url = str(base_url or "") + name = backend(base) + record = { + "version": RECORD_VERSION, + "backend": name, + "base_url": base_url, + "username": str(username or ""), + "token": "", + } + try: + if name == BACKEND_DPAPI: + record["token"] = base64.b64encode( + _dpapi_call(token.encode("utf-8"), True)).decode("ascii") + elif name == BACKEND_KEYRING: + _keyring().set_password(_KEYRING_SERVICE, base_url or "default", token) + else: + record["token"] = token # plain:如实存原文(后端已在界面提示) + except Exception as exc: + # 加密后端当场失败(DPAPI 被策略禁用、keyring 后端挂了):降级到 + # plain 而不是把令牌丢掉,同时如实汇报(界面会提示"明文保存") + diagnostics.log(f"凭据加密失败({name}),降级为明文保存: {exc}", + "credentials") + name = BACKEND_PLAIN + record["backend"] = name + record["token"] = token + + # 换了后端就把旧后端的残留清掉(keyring 里的旧值 / 旧密文) + _clear_other_backends(base, keep=name, base_url=base_url, record=record) + _write_record(base, record) + diagnostics.log(f"登录凭据已保存(后端: {name})", "credentials") + return name + + +def load(base, base_url=""): + """取令牌;没有、坏了、或 base_url 对不上都返回 ""。""" + record = _read_record(base) + if not record: + return "" + stored_url = str(record.get("base_url") or "") + if stored_url and base_url and stored_url != str(base_url): + # 换了服务器:绝不把旧服务器的令牌发到新服务器去 + diagnostics.log( + f"凭据属于 {stored_url},与当前服务器 {base_url} 不一致,忽略", + "credentials") + return "" + name = str(record.get("backend") or "") + if name == BACKEND_KEYRING: + stored = _keyring_load(stored_url) + elif name == BACKEND_DPAPI: + try: + raw = base64.b64decode(str(record.get("token") or "")) + stored = _dpapi_call(raw, False).decode("utf-8") + except Exception as exc: + diagnostics.log(f"凭据解密失败({exc})——需要重新登录", "credentials") + return "" + else: + stored = str(record.get("token") or "") + return stored + + +def _keyring_load(base_url): + module = _keyring() + if module is None: + return "" + try: + return str(module.get_password(_KEYRING_SERVICE, + base_url or "default") or "") + except Exception as exc: + diagnostics.log(f"从系统凭据库读取失败: {exc}", "credentials") + return "" + + +def clear(base): + """清除凭据(文件 + 系统凭据库里的那一份)。""" + record = _read_record(base) + _delete_record(base) + base_url = str(record.get("base_url") or "") + if record.get("backend") == BACKEND_KEYRING or _keyring_usable(): + module = _keyring() + if module is not None: + try: + module.delete_password(_KEYRING_SERVICE, base_url or "default") + except Exception: + pass # 本来就没有/后端不认,都无所谓:目的是"以后取不到" + + +def _clear_other_backends(base, *, keep, base_url, record): + """清理不再使用的后端里的残留值(换后端后不留孤儿凭据)。""" + if keep != BACKEND_KEYRING and _keyring_usable(): + module = _keyring() + if module is not None: + try: + module.delete_password(_KEYRING_SERVICE, base_url or "default") + except Exception: + pass + + +def migrate(base): + """把老版本的 settings.json 里的 ``auth_token`` 搬到凭据存储。 + + 返回迁移后的后端名(没有可迁移的凭据返回 "")。启动链调用一次即可: + 迁移后 settings.json 里不再留 token。 + """ + from . import settings as settings_mod + + try: + data = settings_mod.load(base) + except Exception: + return "" + token = str(data.get("auth_token") or "") + if not token: + return "" + name = save(base, token, base_url=str(data.get("base_url") or ""), + username=str(data.get("auth_user") or "")) + data["auth_token"] = "" + settings_mod.save(base, data) + diagnostics.log(f"settings.json 里的登录令牌已迁移到凭据存储({name})", + "credentials") + return name diff --git a/src/svfs_client/diagnostics.py b/src/svfs_client/diagnostics.py new file mode 100644 index 0000000..0275863 --- /dev/null +++ b/src/svfs_client/diagnostics.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""诊断日志(纯标准库):内存环形缓冲 + 落盘滚动文件 + 诊断报告导出。 + +客户端出问题时(桥调用失败、页面报错、请求卡住)没有任何现场可查是 +排障的最大障碍——这里统一收集: + +* :func:`log` —— 各处(工作线程、桥、页面控制台、未捕获异常)把关键 + 事件写进内存环形缓冲(线程安全,只保留最近 N 条),**同时追加到滚动 + 日志文件**(:func:`configure` 配置后生效)。内存那份给"导出诊断报告"用 + (贴给开发者),文件那份给"客户端崩了/重启之后还能回看"用——崩溃时 + 内存里的东西正好一起没了,而崩溃恰恰是最需要日志的时候; +* :func:`configure` —— 指定日志目录、单文件上限与保留份数(默认 1 MB × + 3 份,即最多约 4 MB)。设置里的 ``log_to_file`` 可以整体关掉落盘; +* :func:`report` —— 汇总环境信息 + 环形日志,生成可交给开发者的 + 诊断报告文本(设置 → 导出诊断报告)。 + +注意只收集元信息与消息文本,不收集插件业务数据;插件页面的 console 输出 +会原样进来(那是插件作者自己打的调试信息),介意这一点的部署可以把 +``log_to_file`` 关掉。 +""" + +import os +import platform +import threading +from datetime import datetime + +__all__ = ["LOG_LIMIT", "configure", "log_path", "log", "recent", "clear", + "report"] + +#: 环形缓冲上限(行) +LOG_LIMIT = 600 + +#: 日志文件名与滚动参数(单文件上限 / 保留份数) +LOG_FILE_NAME = "client.log" +LOG_FILE_MAX_BYTES = 1024 * 1024 +LOG_FILE_BACKUPS = 3 + +_lock = threading.Lock() +_ring = [] +#: 落盘目标:{"path": …, "max_bytes": …, "backups": …};None = 只留内存 +_target = None + + +def configure(log_dir=None, *, max_bytes=LOG_FILE_MAX_BYTES, + backups=LOG_FILE_BACKUPS): + """启用/关闭日志落盘。 + + ``log_dir`` 为 None/空串 = 关闭(只保留内存环形缓冲)。配置失败 + (目录建不出来、没权限)静默降级为"只留内存"——日志是排障辅助, + 不能因为它自己把客户端搞挂。 + """ + global _target + if not log_dir: + _target = None + return + try: + os.makedirs(log_dir, exist_ok=True) + except OSError as exc: + _target = None + log(f"日志目录不可用({log_dir}: {exc}),只保留内存日志", "diagnostics") + return + _target = { + "path": os.path.join(log_dir, LOG_FILE_NAME), + "max_bytes": max(1024, int(max_bytes)), + "backups": max(0, int(backups)), + } + + +def log_path(): + """当前日志文件路径(未落盘时为空串)。""" + return _target["path"] if _target else "" + + +def log(message, source="client"): + """记一条诊断日志(线程安全)。source 标记来源:worker/console:<插件id>/net… + + 日志本身绝不抛出:它服务于排障,不能反过来把被测代码搞挂。 + """ + try: + line = f"{datetime.now():%H:%M:%S} [{source}] {message}" + with _lock: + _ring.append(line) + del _ring[:-LOG_LIMIT] + if _target: + _append(line, _target) + except Exception: + pass + + +def _append(line, target): + """追加一行并按需轮转(调用方已持有 _lock)。""" + path = target["path"] + try: + if os.path.exists(path) and os.path.getsize(path) >= target["max_bytes"]: + _rotate(path, target["backups"]) + with open(path, "a", encoding="utf-8") as handle: + handle.write(f"{datetime.now():%Y-%m-%d} {line}\n") + except OSError: + pass # 磁盘满/被占用/权限变了:丢几行日志可以接受,不能影响业务 + + +def _rotate(path, backups): + """client.log → client.log.1 → … ;超出份数的删掉。""" + if backups <= 0: + os.unlink(path) + return + oldest = f"{path}.{backups}" + if os.path.exists(oldest): + os.unlink(oldest) + for index in range(backups - 1, 0, -1): + source = f"{path}.{index}" + if os.path.exists(source): + os.replace(source, f"{path}.{index + 1}") + os.replace(path, f"{path}.1") + + +def recent(limit=None): + """最近的日志行(旧→新)。""" + with _lock: + lines = list(_ring) + return lines[-limit:] if limit else lines + + +def clear(): + with _lock: + _ring.clear() + + +def report(info): + """生成诊断报告文本。 + + ``info`` 是调用方收集的环境信息 dict(应用/Qt 版本、数据目录、插件 + 清单、服务器地址……),报告 = 环境信息 + 最近日志。 + """ + lines = ["SVFS Client 诊断报告", + f"生成时间: {datetime.now():%Y-%m-%d %H:%M:%S}", + f"Python: {platform.python_version()} {platform.system()} " + f"{platform.release()}", + f"日志文件: {log_path() or '(未落盘)'}", + ""] + for key, value in (info or {}).items(): + lines.append(f"{key}: {value}") + lines += ["", "---- 最近日志 ----"] + lines += recent() or ["(空)"] + return "\n".join(lines) diff --git a/src/svfs_client/dialogs.py b/src/svfs_client/dialogs.py new file mode 100644 index 0000000..a89b8dd --- /dev/null +++ b/src/svfs_client/dialogs.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python3 +"""主窗口的二级对话框:设置、用户中心(Qt 层)。 + +两个对话框都是**标准边框**的 QDialog(暗色背景由全局 QSS 的 +``QDialog { background: … }`` 提供): + +* :class:`SettingsDialog` —— 服务器地址 / 启动插件经 MainWindow 的回调 + 修改(校验+落盘+界面同步);数据目录走 paths 的指针文件(重启生效); + 缓存路径直接写 settings(安装时现读,即时生效); +* :class:`UserCenterDialog` —— 登录状态经 ``session_provider`` 现读, + 退出/重新登录是 MainWindow 传进来的回调,动作后 :meth:`refresh` 重取。 +""" + +import os + +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QColor, QDesktopServices, QFont, QPainter, QPixmap +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QFileDialog, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, +) + +from svfs_studio import format as fmt +from svfs_studio import theme +from svfs_studio.widgets.cards import Badge, ElidedLabel, hbox, vbox + +from . import APP_NAME, __version__, credentials, diagnostics, paths, settings + + +def _qt_version(): + from PySide6.QtCore import qVersion + + return qVersion() + +__all__ = ["avatar_pixmap", "SettingsDialog", "UserCenterDialog"] + + +def avatar_pixmap(name, size=28): + """用户头像:圆形色块 + 用户名首字母(无资源文件,现场画)。""" + letter = (str(name or "").strip()[:1] or "?").upper() + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(QColor(theme.ACCENT)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(0, 0, size - 1, size - 1) + painter.setPen(QColor(theme.ACCENT_TEXT)) + font = QFont() + font.setPixelSize(max(8, int(size * 0.46))) + font.setBold(True) + painter.setFont(font) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, letter) + painter.end() + return pixmap + + +def _caption(text): + label = QLabel(text) + theme.role(label, "muted") + label.setFont(theme.font_ui(theme.SIZE_SMALL)) + return label + + +class SettingsDialog(QDialog): + """设置:插件管理入口、启动直显、服务器地址、数据目录、对象缓存、关于。 + + 回调一览(对话框自己不碰跨模块状态): + + * ``apply_base_url(raw)`` —— MainWindow.set_base_url,返回错误或 None; + * ``on_open_manager`` —— 切到插件管理页; + * ``plugins_provider()`` —— 已安装插件的 ``[(id, 显示名)]``; + * ``startup_provider()`` —— 当前配置的启动插件 id(空 = 默认规则); + * ``apply_startup(raw)`` —— MainWindow.set_startup_plugin,返回错误或 None; + * ``on_net_settings_changed`` —— 网络项(代理/超时/CA)保存成功后的回调, + 壳层用它重探一次服务器状态灯。 + """ + + def __init__(self, base, apply_base_url, on_open_manager=None, + plugins_provider=None, startup_provider=None, + apply_startup=None, on_install_debug=None, + on_net_settings_changed=None, parent=None): + super().__init__(parent) + self.base = base + self._apply_base_url = apply_base_url # MainWindow.set_base_url + self._on_open_manager = on_open_manager + self._plugins_provider = plugins_provider + self._apply_startup = apply_startup or (lambda plugin_id: None) + self._on_install_debug = on_install_debug + self._on_net_settings_changed = on_net_settings_changed + self.setWindowTitle("设置") + self.setMinimumWidth(500) + + cfg = settings.load(base) + column = vbox(self, 16, 10) + + # ---- 插件管理(导航入口)---- + manager_row = hbox(spacing=8) + manager_text = vbox(spacing=2) + manager_text.addWidget(_caption("插件管理")) + manager_desc = QLabel("安装、升级、回滚与卸载插件") + theme.role(manager_desc, "faint") + manager_text.addWidget(manager_desc) + manager_row.addLayout(manager_text, 1) + open_manager_button = QPushButton("打开") + open_manager_button.setObjectName("Primary") + open_manager_button.clicked.connect(self._open_manager) + manager_row.addWidget(open_manager_button) + column.addLayout(manager_row) + + # ---- 桥调试台(开发辅助)---- + debug_row = hbox(spacing=8) + debug_text = vbox(spacing=2) + debug_text.addWidget(_caption("桥调试台")) + debug_desc = QLabel("安装一个可逐项验证 window.svfs 能力的调试插件") + theme.role(debug_desc, "faint") + debug_text.addWidget(debug_desc) + debug_row.addLayout(debug_text, 1) + debug_button = QPushButton("安装调试插件") + debug_button.clicked.connect(self._install_debug) + debug_row.addWidget(debug_button) + column.addLayout(debug_row) + column.addSpacing(6) + + # ---- 启动时打开 ---- + column.addWidget(_caption("启动时打开(默认规则:优先上次打开的插件," + "没有则用第一个启用的插件)")) + startup_row = hbox(spacing=8) + self.startup_combo = QComboBox() + self.startup_combo.addItem("默认规则", "") + for plugin_id, name in (self._plugins_provider or (lambda: []))(): + self.startup_combo.addItem(f"{name}({plugin_id})", plugin_id) + current_startup = (startup_provider or (lambda: ""))() + startup_index = self.startup_combo.findData(current_startup) + self.startup_combo.setCurrentIndex(max(0, startup_index)) + startup_row.addWidget(self.startup_combo, 1) + startup_save = QPushButton("保存") + startup_save.clicked.connect(self._apply_startup_clicked) + startup_row.addWidget(startup_save) + column.addLayout(startup_row) + self.startup_status = QLabel("") + self.startup_status.hide() + column.addWidget(self.startup_status) + column.addSpacing(6) + + # ---- 服务器地址 ---- + column.addWidget(_caption("服务器地址(承载插件下载与接口服务)")) + self.server_edit = QLineEdit(cfg.get("base_url") or "") + self.server_edit.setFont(theme.font_mono(theme.SIZE_BASE)) + self.server_edit.setPlaceholderText("http://127.0.0.1:8000") + column.addWidget(self.server_edit) + row = hbox(spacing=8) + self.server_error = QLabel("") + theme.role(self.server_error, "err") + self.server_error.hide() + row.addWidget(self.server_error, 1) + self.server_status = QLabel("") + theme.role(self.server_status, "ok") + row.addWidget(self.server_status) + save_button = QPushButton("保存") + save_button.setObjectName("Primary") + save_button.clicked.connect(self._apply_base_url_clicked) + row.addWidget(save_button) + column.addLayout(row) + column.addSpacing(6) + + # ---- 登录凭据(存在哪、受不受保护)---- + column.addWidget(_caption("登录凭据")) + self.credentials_label = QLabel("") + self.credentials_label.setWordWrap(True) + theme.role(self.credentials_label, "faint") + column.addWidget(self.credentials_label) + column.addSpacing(6) + + # ---- 数据目录(插件路径)---- + column.addWidget(_caption("数据目录(注册表 / 设置 / 插件包;更改后重启生效)")) + dir_row = hbox(spacing=8) + data_label = ElidedLabel(base) + theme.role(data_label, "mono") + dir_row.addWidget(data_label, 1) + open_button = QPushButton("打开目录") + open_button.setObjectName("Ghost") + open_button.clicked.connect( + lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(base)) + ) + dir_row.addWidget(open_button) + change_button = QPushButton("更改…") + change_button.clicked.connect(self._change_data_dir) + dir_row.addWidget(change_button) + column.addLayout(dir_row) + self.data_status = QLabel("") + theme.role(self.data_status, "ok") + self.data_status.hide() + column.addWidget(self.data_status) + column.addSpacing(6) + + # ---- 对象缓存(路径可配置,安装时即时生效)---- + column.addWidget(_caption("对象缓存路径(留空 = 系统默认)")) + cache_edit_row = hbox(spacing=8) + self.cache_edit = QLineEdit(str(cfg.get("cache_dir") or "")) + self.cache_edit.setFont(theme.font_mono(theme.SIZE_BASE)) + self.cache_edit.setPlaceholderText(paths.object_cache_dir()) + cache_edit_row.addWidget(self.cache_edit, 1) + cache_save = QPushButton("保存") + cache_save.clicked.connect(self._save_cache_dir) + cache_edit_row.addWidget(cache_save) + cache_reset = QPushButton("恢复默认") + cache_reset.setObjectName("Ghost") + cache_reset.clicked.connect(self._reset_cache_dir) + cache_edit_row.addWidget(cache_reset) + column.addLayout(cache_edit_row) + self.cache_status = QLabel("") + self.cache_status.hide() + column.addWidget(self.cache_status) + + cache_row = hbox(spacing=8) + self.cache_label = ElidedLabel(settings.resolve_cache_dir(base)) + theme.role(self.cache_label, "mono") + cache_row.addWidget(self.cache_label, 1) + self.cache_size_label = QLabel("") + theme.role(self.cache_size_label, "muted") + cache_row.addWidget(self.cache_size_label) + clear_button = QPushButton("清理缓存") + clear_button.setObjectName("Ghost") + clear_button.clicked.connect(self._clear_cache) + cache_row.addWidget(clear_button) + column.addLayout(cache_row) + column.addSpacing(6) + + # ---- 网络(超时/重试/代理/CA)---- + column.addWidget(_caption("网络(插件 net 请求与服务器连接)")) + net_row = hbox(spacing=6) + net_row.addWidget(QLabel("超时")) + self.timeout_spin = QSpinBox() + self.timeout_spin.setRange(1, 600) + self.timeout_spin.setSuffix(" 秒") + self.timeout_spin.setValue(settings.net_options(base)[0]) + self.timeout_spin.setToolTip("单次插件 net 请求的超时(默认 30 秒)") + net_row.addWidget(self.timeout_spin) + net_row.addSpacing(4) + net_row.addWidget(QLabel("重试")) + self.retries_spin = QSpinBox() + self.retries_spin.setRange(0, 5) + self.retries_spin.setSuffix(" 次") + self.retries_spin.setValue(settings.net_options(base)[1]) + self.retries_spin.setToolTip( + "只对幂等方法(GET/HEAD)生效:遇 502/503/504 或连接错误按指数退避重试" + ) + net_row.addWidget(self.retries_spin) + net_row.addStretch(1) + self.net_status = QLabel("") + theme.role(self.net_status, "ok") + net_row.addWidget(self.net_status) + net_save = QPushButton("保存网络设置") + net_save.clicked.connect(self._save_net_options) + net_row.addWidget(net_save) + column.addLayout(net_row) + + proxy_row = hbox(spacing=8) + proxy_row.addWidget(QLabel("代理")) + self.proxy_edit = QLineEdit(str(cfg.get("proxy") or "")) + self.proxy_edit.setFont(theme.font_mono(theme.SIZE_BASE)) + self.proxy_edit.setPlaceholderText("留空 = 跟随系统代理(loopback 永远直连)") + proxy_row.addWidget(self.proxy_edit, 1) + column.addLayout(proxy_row) + + ca_row = hbox(spacing=8) + ca_row.addWidget(QLabel("CA 证书")) + self.ca_edit = QLineEdit(str(cfg.get("ca_bundle") or "")) + self.ca_edit.setFont(theme.font_mono(theme.SIZE_BASE)) + self.ca_edit.setPlaceholderText("留空 = 系统信任库(自签/内网 HTTPS 用)") + ca_row.addWidget(self.ca_edit, 1) + ca_browse = QPushButton("浏览…") + ca_browse.setObjectName("Ghost") + ca_browse.clicked.connect(self._pick_ca_bundle) + ca_row.addWidget(ca_browse) + column.addLayout(ca_row) + self.net_error = QLabel("") + theme.role(self.net_error, "err") + self.net_error.setWordWrap(True) + self.net_error.hide() + column.addWidget(self.net_error) + column.addSpacing(6) + + # ---- 关于 / 诊断 ---- + about_row = hbox(spacing=8) + about = QLabel(f"{APP_NAME} v{__version__}") + theme.role(about, "faint") + about_row.addWidget(about, 1) + self.log_check = QCheckBox("诊断日志落盘") + self.log_check.setChecked(settings.log_to_file(base)) + self.log_check.setToolTip( + f"滚动写入 {paths.logs_dir(base)}(1 MB × 4 份);" + "崩溃/重启后仍可回看,关掉则只保留内存里最近 600 条" + ) + self.log_check.toggled.connect(self._toggle_log_to_file) + about_row.addWidget(self.log_check) + doctor_button = QPushButton("导出诊断报告…") + doctor_button.setObjectName("Ghost") + doctor_button.setToolTip("导出环境信息与最近日志,便于排查问题") + doctor_button.clicked.connect(self._export_diagnostics) + about_row.addWidget(doctor_button) + column.addLayout(about_row) + + close_row = hbox() + close_row.addStretch(1) + close_button = QPushButton("关闭") + close_button.clicked.connect(self.accept) + close_row.addWidget(close_button) + column.addLayout(close_row) + + self._refresh_cache_size() + self._refresh_credentials() + + # ---- 行为 ------------------------------------------------------- + + def _refresh_credentials(self): + """登录凭据存哪、受不受保护(明文兜底要看得见,否则没人会发现)。""" + base = self.base + name = credentials.backend(base) + where = credentials.describe(base) + if credentials.is_secure(base): + has_token = bool(credentials.load(base)) + state = "已保存登录令牌" if has_token else "尚未保存登录令牌" + theme.role(self.credentials_label, "faint") + self.credentials_label.setText(f"{state} · 存储方式:{where}") + else: + theme.role(self.credentials_label, "warn") + self.credentials_label.setText( + f"⚠ 存储方式:{where}(后端: {name})" + ) + + def _open_manager(self): + self.accept() + if self._on_open_manager: + self._on_open_manager() + + def _install_debug(self): + # 关掉对话框再装:主窗口里能看到安装进度与结果,装完自动打开调试台 + self.accept() + if self._on_install_debug: + self._on_install_debug() + + def _pick_ca_bundle(self): + path, _ = QFileDialog.getOpenFileName( + self, "选择 CA 证书", self.ca_edit.text() or self.base, + "证书文件 (*.pem *.crt *.cer *.p7b);;所有文件 (*)", + ) + if path: + self.ca_edit.setText(path) + self._save_net_options() + + def _save_net_options(self): + """保存网络项并**当场应用**:CA 路径写错要立刻报,别等请求失败。""" + data = settings.load(self.base) + data["net_timeout"] = int(self.timeout_spin.value()) + data["net_retries"] = int(self.retries_spin.value()) + data["proxy"] = self.proxy_edit.text().strip() + data["ca_bundle"] = self.ca_edit.text().strip() + settings.save(self.base, data) + error = settings.apply_runtime(self.base) + self.net_error.hide() + if error: + theme.role(self.net_status, "err") + self.net_status.setText("未生效") + self.net_error.setText(error) + self.net_error.show() + return + theme.role(self.net_status, "ok") + self.net_status.setText("已应用") + if self._on_net_settings_changed: + self._on_net_settings_changed() + + def _toggle_log_to_file(self, checked): + data = settings.load(self.base) + data["log_to_file"] = bool(checked) + settings.save(self.base, data) + settings.apply_runtime(self.base) + + def _apply_startup_clicked(self): + self.startup_status.hide() + error = self._apply_startup(self.startup_combo.currentData() or "") + if error: + theme.role(self.startup_status, "err") + self.startup_status.setText(error) + else: + theme.role(self.startup_status, "ok") + self.startup_status.setText("已保存,下次启动生效") + self.startup_status.show() + + def _apply_base_url_clicked(self): + self.server_error.hide() + self.server_status.hide() + error = self._apply_base_url(self.server_edit.text()) + if error: + self.server_error.setText(error) + self.server_error.show() + return + self.server_status.setText("已保存") + self.server_status.show() + + def _change_data_dir(self): + chosen = QFileDialog.getExistingDirectory( + self, "选择数据目录", self.base + ) + if not chosen: + return + if os.path.abspath(chosen) == os.path.abspath(self.base): + return + answer = QMessageBox.question( + self, "更改数据目录", + f"把插件数据目录切换到:\n{chosen}\n\n" + "需要重启客户端后生效,当前数据不受影响。现在保存吗?", + ) + if answer != QMessageBox.StandardButton.Yes: + return + error = paths.set_data_dir(chosen) + if error: + QMessageBox.warning(self, "无法更改", error) + return + self.data_status.setText("已保存——重启客户端后生效") + self.data_status.show() + + def _save_cache_dir(self): + data = settings.load(self.base) + data["cache_dir"] = self.cache_edit.text().strip() + settings.save(self.base, data) + self._refresh_cache_size() + theme.role(self.cache_status, "ok") + self.cache_status.setText("已保存,安装插件时即时生效") + self.cache_status.show() + + def _reset_cache_dir(self): + self.cache_edit.setText("") + self._save_cache_dir() + + def _export_diagnostics(self): + """把环境信息 + 内存日志导出成文本文件(排障用)。""" + info = { + "应用版本": f"{APP_NAME} v{__version__}", + "数据目录": self.base, + "Qt 版本": _qt_version(), + "凭据存储": f"{credentials.backend(self.base)}(" + f"{credentials.describe(self.base)})", + } + target, _ = QFileDialog.getSaveFileName( + self, "导出诊断报告", + os.path.join(self.base, "svfs-diagnostics.txt"), + "文本文件 (*.txt);;所有文件 (*)", + ) + if not target: + return + try: + text = diagnostics.report(info) + with open(target, "w", encoding="utf-8") as handle: + handle.write(text) + except OSError as exc: + QMessageBox.warning(self, "导出失败", str(exc)) + return + theme.role(self.data_status, "ok") + self.data_status.setText(f"诊断报告已导出: {target}") + self.data_status.show() + + def _refresh_cache_size(self): + cache_dir = settings.resolve_cache_dir(self.base) + self.cache_label.set_full_text(cache_dir) + size = paths.dir_size(cache_dir) + self.cache_size_label.setText( + "空" if size == 0 else fmt.human_size(size) + ) + + def _clear_cache(self): + cache_dir = settings.resolve_cache_dir(self.base) + size = paths.dir_size(cache_dir) + if size == 0: + self.cache_status.hide() + self.cache_size_label.setText("空") + return + answer = QMessageBox.question( + self, "清理对象缓存", + f"删除缓存目录里的全部内容({fmt.human_size(size)})?\n" + "不影响已安装的插件;下次从服务器安装会重新下载。", + ) + if answer != QMessageBox.StandardButton.Yes: + return + paths.clear_dir_contents(cache_dir) + self._refresh_cache_size() + + +class UserCenterDialog(QDialog): + """用户中心:当前登录会话 + 退出/重新登录。""" + + def __init__(self, base, session_provider, on_relogin=None, on_logout=None, + parent=None): + super().__init__(parent) + self.base = base + self._session_provider = session_provider + self._on_relogin = on_relogin + self._on_logout = on_logout + self.setWindowTitle("用户中心") + self.setMinimumWidth(400) + + column = vbox(self, 16, 8) + + head = hbox(spacing=12) + self.avatar_label = QLabel() + self.avatar_label.setFixedSize(56, 56) + head.addWidget(self.avatar_label) + name_box = vbox(spacing=4) + self.user_label = QLabel("未登录") + theme.role(self.user_label, "h1") + name_box.addWidget(self.user_label) + self.state_badge = Badge("未登录") + name_box.addWidget(self.state_badge) + head.addLayout(name_box, 1) + column.addLayout(head) + column.addSpacing(8) + + column.addWidget(_caption("服务器地址")) + self.server_label = ElidedLabel("未配置") + theme.role(self.server_label, "mono") + column.addWidget(self.server_label) + + column.addWidget(_caption("本次登录时间")) + self.login_time_label = ElidedLabel("—") + theme.role(self.login_time_label, "mono") + column.addWidget(self.login_time_label) + column.addSpacing(8) + + button_row = hbox(spacing=8) + self.relogin_button = QPushButton("登录…") + self.relogin_button.setObjectName("Primary") + self.relogin_button.clicked.connect(self._do_relogin) + button_row.addWidget(self.relogin_button) + self.logout_button = QPushButton("退出登录") + self.logout_button.setObjectName("Danger") + self.logout_button.clicked.connect(self._do_logout) + button_row.addWidget(self.logout_button) + column.addLayout(button_row) + + close_row = hbox() + close_row.addStretch(1) + close_button = QPushButton("关闭") + close_button.clicked.connect(self.accept) + close_row.addWidget(close_button) + column.addLayout(close_row) + + self.refresh() + + # ---- 刷新与动作 --------------------------------------------------- + + def refresh(self): + """按 session_provider 的现值重画(登录/退出动作后调用)。""" + session = self._session_provider() or {} + username = str(session.get("username") or "") + invalid = bool(session.get("invalid")) + self.avatar_label.setPixmap(avatar_pixmap(username, 56)) + self.user_label.setText(username or "未登录") + if username and invalid: + self.state_badge.set_state("warn", "登录已失效,请重新登录") + self.relogin_button.setObjectName("Primary") + self.relogin_button.setText("重新登录…") + self.logout_button.setEnabled(True) + elif username: + self.state_badge.set_state("ok", "已登录") + self.relogin_button.setObjectName("Ghost") + self.relogin_button.setText("重新登录…") + self.logout_button.setEnabled(True) + else: + self.state_badge.set_state("muted", "未登录") + self.relogin_button.setObjectName("Primary") + self.relogin_button.setText("登录…") + self.logout_button.setEnabled(False) + theme.repolish(self.relogin_button) + self.server_label.set_full_text(str(session.get("base_url") or "未配置")) + self.login_time_label.set_full_text(str(session.get("login_time") or "—")) + + def _do_relogin(self): + if self._on_relogin: + self._on_relogin() + self.refresh() + + def _do_logout(self): + if self._on_logout: + self._on_logout() + self.refresh() diff --git a/src/svfs_client/installer.py b/src/svfs_client/installer.py new file mode 100644 index 0000000..eb27f5e --- /dev/null +++ b/src/svfs_client/installer.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +"""安装器:本地 .svfs / 远程 manifest → ``plugins//plugin.svfs``(纯标准库)。 + +版本模型是"快照即版本",所有流程都围绕它展开: + +* **首次安装本地包**:整包复制——源包的历史快照原样保留,当前快照取源包最新一版; +* **首次安装远程包**:按清单取回对象(逐个 sha256 校验)到临时目录,打包成 + plugin.svfs,快照名 = 版本号; +* **升级**(对已安装的插件再次安装):把源包当前快照导出到临时目录,在已安装包上 + 派生新快照(``create_snapshot(parent_id=当前, copy_entries=True)``)再增量打包。 + 没变的文件共享 blob,一次升级通常只增加变化部分的字节;旧快照全部保留; +* **回滚**(:func:`use_version`):把注册表的 current_snapshot_id 指回旧快照。 + 包文件一个字节都不用动,协议层(scheme)按注册表指的快照开包。 + +写包之前先关掉正在使用包文件的只读句柄是调用方(GUI 壳)的责任——Windows 上 +``os.replace`` 不能覆盖被占用的文件。安装失败/中断时会删除半成品快照, +不会留下"装了一半"的版本。 +""" + +import os +import shutil +import tempfile +from datetime import datetime + +from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.folder_packer import FolderPacker +from sqlite_vfs.publish import ObjectFetcher, load_manifest + +from . import manifest as manifest_mod +from . import paths, registry + +__all__ = [ + "InstallError", + "is_newer_version", + "client_update_status", + "inspect_package", + "install_from_file", + "install_from_url", + "set_enabled", + "use_version", + "uninstall", + "list_installed", +] + +#: 侧栏/详情里允许读取的图标上限;超限就不读(图标不该很大) +ICON_MAX_BYTES = 256 * 1024 + + +class InstallError(Exception): + """安装/管理操作失败,信息可直接展示给用户。""" + + +def is_newer_version(candidate, current): + """候选版本号是否比当前新(按 '.' 分段数字比较)。 + + 用于更新检查:服务器快照名(如 "2.0.0")与本地当前版本(可能是 + "2.0.0-2" 这类追加快照名)比较。分段更多/数字更大的算更新; + 无法解析时保守返回 False。 + """ + def parts(value): + out = [] + for piece in str(value or "").split("."): + digits = "".join(ch for ch in piece if ch.isdigit()) + out.append(int(digits) if digits else 0) + return out + + try: + return parts(candidate) > parts(current) + except Exception: + return False + + +def client_update_status(payload, current_version): + """把 ``client/manifest.json`` 的内容解析成客户端更新检查结果。 + + 服务器上的文件长这样(纯静态,与插件清单同一套托管方式):: + + {"version": "0.2.0", "url": "https://…/SVFS-Client-0.2.0.zip", + "notes": "修了 X"} + + 返回 ``{current, latest, url, notes, update}``。服务器没配这个文件、内容 + 不是对象、或者没写 version,都算"没有更新信息"(update=False)——这是 + 可选的增量能力,缺了不该报错打扰用户。 + """ + current = str(current_version or "") + result = {"current": current, "latest": "", "url": "", "notes": "", + "update": False} + if not isinstance(payload, dict): + return result + latest = str(payload.get("version") or "") + result["latest"] = latest + result["url"] = str(payload.get("url") or "") + result["notes"] = str(payload.get("notes") or "") + result["update"] = bool(latest) and is_newer_version(latest, current) + return result + + +# ---------------------------------------------------------------------- +# 只读查看 +# ---------------------------------------------------------------------- + + +def inspect_package(db_path): + """打开一个插件包看清单与状态(安装预览、详情页共用)。 + + 返回 dict:manifest / snapshot_id(最新快照)/ snapshots / verify。 + 不是插件包(缺清单)、校验不过、格式不对,都抛 :class:`InstallError`。 + """ + try: + vfs = SQLiteVFS(db_path, readonly=True) + except Exception as exc: # VFSFormatError / sqlite3.Error / 文件不存在 + raise InstallError(f"无法打开插件包 {db_path}: {exc}") from exc + try: + try: + data = manifest_mod.read_from_vfs(vfs) + except manifest_mod.ManifestError as exc: + raise InstallError(str(exc)) from exc + except Exception as exc: # 包内容损坏时读清单本身就会失败(VFSIntegrityError 等) + raise InstallError(f"无法读取 plugin.json(包可能已损坏): {exc}") from exc + report = vfs.verify() + if not report["ok"]: + raise InstallError( + f"插件包完整性校验未通过({db_path}): " + + ";".join(report["errors"][:3]) + ) + return { + "manifest": data, + "snapshot_id": report["snapshot_id"], + "snapshots": _snapshot_rows(vfs), + "verify": report, + } + finally: + vfs.close() + + +def _snapshot_rows(vfs): + return [ + { + "id": s["id"], + "name": s["name"], + "created_time": str(s.get("created_time") or ""), + "file_count": s.get("file_count"), + "total_size": s.get("total_size"), + "digest": s.get("digest"), + } + for s in vfs.list_snapshots() + ] + + +def read_icon(package_path, icon_path): + """读插件图标字节(worker 给侧栏用);没声明、不存在或超限都返回 None。""" + if not icon_path: + return None + try: + vfs = SQLiteVFS(package_path, readonly=True) + except Exception: + return None + try: + info = vfs.get_file_info(icon_path) + if info is None or info["is_directory"] or info["file_size"] > ICON_MAX_BYTES: + return None + return vfs.read_file(icon_path) + except Exception: + return None + finally: + vfs.close() + + +# ---------------------------------------------------------------------- +# 安装 +# ---------------------------------------------------------------------- + + +def install_from_file(src_db, *, base, check_content=False, progress=None, + source=None): + """从本地 .svfs 安装/升级,返回更新后的注册项。 + + progress: ``progress(done, total, label)``,只在升级打包阶段有值。 + check_content: 传给 verify 的权威校验开关(逐文件重算 sha256)。 + source: 注册表里记录的来源,默认记为本地路径(远程安装会传入 URL)。 + """ + info = inspect_package(src_db) + data = info["manifest"] + if check_content: + report = _verify_content(src_db) + if not report["ok"]: + raise InstallError("逐文件内容校验未通过: " + ";".join(report["errors"][:3])) + + paths.ensure_layout(base) + registry_data = registry.load(base) + target = paths.plugin_package(base, data["id"]) + + if os.path.exists(target): + entry = _upgrade(target, src_db, data, info["snapshot_id"], base, + progress=progress) + else: + entry = _install_fresh_copy(src_db, data, info, target) + return _finish_entry(registry_data, base, entry, + source=source or ("local", str(src_db))) + + +def install_from_url(manifest_source, *, base, base_url=None, local_root=None, + cache_dir=None, check_content=False, progress=None): + """从远程/本地 manifest 安装:按清单取回对象 → 打包 → 安装。 + + manifest_source 可以是 http(s) URL 或本地 manifest.json 路径。 + base_url / local_root 缺省时按 CLI 的规则解析:URL 清单从同级目录取对象, + 本地清单从清单所在目录取对象。 + """ + is_url = str(manifest_source).startswith(("http://", "https://")) + try: + manifest = load_manifest(manifest_source) + except Exception as exc: + raise InstallError(f"无法读取清单 {manifest_source}: {exc}") from exc + if base_url is None and is_url: + base_url = str(manifest_source).rsplit("/", 1)[0] or None + if local_root is None and not is_url: + local_root = os.path.dirname(os.path.abspath(manifest_source)) + + workdir = tempfile.mkdtemp(prefix="svfs-install-") + try: + fetcher = ObjectFetcher( + manifest, + base_url=base_url, + local_root=local_root, + cache_dir=cache_dir, + progress=_fetch_progress(progress), + ) + fetcher.fetch(workdir) # 返回值只是统计;清单本身在 manifest 里 + + # 取回来的树先落成临时包,走与本地安装完全相同的校验/安装路径 + staging = os.path.join(workdir, "plugin.svfs") + _pack_tree(staging, workdir, manifest.get("snapshot", {}).get("name")) + return install_from_file( + staging, base=base, check_content=check_content, progress=progress, + source=("url", str(manifest_source)), + ) + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +def _fetch_progress(progress): + if progress is None: + return None + + def adapt(done, total): + progress(done, total, f"取回对象 {done}/{total}") + + return adapt + + +def _verify_content(db_path): + """只读打开并做权威校验(逐文件重算 sha256)。""" + vfs = SQLiteVFS(db_path, readonly=True) + try: + return vfs.verify(check_content=True) + finally: + vfs.close() + + +def _install_fresh_copy(src_db, data, info, target): + """首次安装本地包:整包原子复制,历史快照原样保留。""" + os.makedirs(os.path.dirname(target), exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(target), prefix=".install-", suffix=".tmp") + os.close(fd) + try: + shutil.copyfile(src_db, tmp) + if os.path.getsize(tmp) != os.path.getsize(src_db): + raise InstallError("复制插件包时大小不一致,已中止") + os.replace(tmp, target) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + return _entry_from_manifest(data, info["snapshot_id"]) + + +def _upgrade(target, src_db, data, src_snapshot_id, base, progress=None): + """升级:源包当前快照 → 临时目录 → 已安装包上的新快照(增量)。""" + workdir = tempfile.mkdtemp(prefix="svfs-upgrade-") + try: + src = SQLiteVFS(src_db, readonly=True, snapshot_id=src_snapshot_id) + try: + src.export_filesystem(workdir, ignore_timestamps=False) + finally: + src.close() + + # compress=True:新写入的 blob 与首次安装保持一致(库里 compress_algo 按 blob 记录, + # 混着存也能读,但没必要故意混) + vfs = SQLiteVFS(target, compress=True) + new_id = None + try: + entry = registry.load(base)["plugins"].get(data["id"], {}) + parent_id = entry.get("current_snapshot_id") + if parent_id is not None: + try: + vfs.use_snapshot(parent_id) + except FileNotFoundError: + parent_id = None # 注册表指向的快照没了,退回最新 + if parent_id is None: + parent_id = vfs.get_stats()["snapshot_id"] + + name = _unique_snapshot_name(vfs, data["version"]) + try: + new_id = vfs.create_snapshot(name, parent_id=parent_id, copy_entries=True) + vfs.add_vfs_metadata(f"svfs-plugin:{data['id']}") + packer = FolderPacker(vfs) + packer.pack_folder(workdir, incremental=True, rehash=True, + progress=_pack_progress(progress)) + vfs.recount() + report = vfs.verify() + if not report["ok"]: + raise InstallError( + "升级后校验未通过: " + ";".join(report["errors"][:3]) + ) + except BaseException: + # 升级失败/被中断:删掉半成品快照,包停留在升级前的状态 + if new_id is not None: + try: + vfs.use_snapshot(parent_id) + vfs.delete_snapshot(new_id) + except Exception: + pass + raise + entry = _entry_from_manifest(data, new_id) + entry["previous_snapshot_id"] = parent_id + return entry + finally: + vfs.close() + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +def _pack_progress(progress): + if progress is None: + return None + + def adapt(done, total, rel_path): + progress(done, total, rel_path) + + return adapt + + +def _pack_tree(out_db, source_folder, snapshot_name): + """把一棵取回的目录树打成插件包(远程首次安装的中间产物)。""" + vfs = SQLiteVFS(out_db, compress=True) + try: + vfs.create_snapshot(snapshot_name or "1.0.0") + vfs.add_vfs_metadata("svfs-plugin") + packer = FolderPacker(vfs) + packer.pack_folder(source_folder, incremental=True, rehash=True) + vfs.recount() + finally: + vfs.close() + + +def _unique_snapshot_name(vfs, base_name): + """快照名不强制唯一,但界面要按名字找版本,所以重名的追加 -2、-3…""" + existing = {s["name"] for s in vfs.list_snapshots()} + if base_name not in existing: + return base_name + index = 2 + while f"{base_name}-{index}" in existing: + index += 1 + return f"{base_name}-{index}" + + +# ---------------------------------------------------------------------- +# 注册表收尾 +# ---------------------------------------------------------------------- + + +def _entry_from_manifest(data, snapshot_id): + """按清单造一个注册项骨架(时间戳在 _finish_entry 里补)。""" + return { + "id": data["id"], + "name": data["name"], + "version": data["version"], + "api_version": data["api_version"], + "entry": data["entry"], + "icon": data.get("icon"), + "description": data.get("description", ""), + "permissions": list(data["permissions"]), + # net 白名单进注册表:界面展示与实际约束(桥)都读这一份 + "net_hosts": list(data.get("net_hosts") or []), + "enabled": True, + "current_snapshot_id": snapshot_id, + "installed_time": _now(), + "updated_time": _now(), + } + + +def _finish_entry(registry_data, base, entry, source): + """补来源/统计,写回注册表,返回最终注册项。""" + source_type, source_detail = source + existing = registry_data["plugins"].get(entry["id"]) + if existing: + entry["installed_time"] = existing.get("installed_time", entry["installed_time"]) + entry["source"] = {"type": source_type, "detail": source_detail} + entry["updated_time"] = _now() + + # 打开包刷新一次快照清单(界面展示"有几个版本"用)。 + # 图标字节不进注册表(注册表必须是纯 JSON),由 worker 按需读取。 + target = paths.plugin_package(base, entry["id"]) + try: + vfs = SQLiteVFS(target, readonly=True) + try: + entry["snapshots"] = _snapshot_rows(vfs) + finally: + vfs.close() + except Exception as exc: + raise InstallError(f"安装后无法打开包文件: {exc}") from exc + + registry.put(registry_data, entry) + registry.save(base, registry_data) + return entry + + +def _now(): + return datetime.now().isoformat(timespec="seconds") + + +# ---------------------------------------------------------------------- +# 管理操作(启用 / 回滚 / 卸载 / 列表) +# ---------------------------------------------------------------------- + + +def set_enabled(base, plugin_id, enabled): + """启用/禁用。禁用后协议层直接拒绝服务该插件的所有请求。""" + data = registry.load(base) + try: + entry = registry.get(data, plugin_id) + except KeyError as exc: + raise InstallError(f"插件未安装: {plugin_id}") from exc + entry["enabled"] = bool(enabled) + registry.save(base, data) + return entry + + +def use_version(base, plugin_id, snapshot_id): + """回滚/切换版本:把当前快照指到指定快照。包文件不动。""" + target = paths.plugin_package(base, plugin_id) + data = registry.load(base) + try: + entry = registry.get(data, plugin_id) + except KeyError as exc: + raise InstallError(f"插件未安装: {plugin_id}") from exc + try: + vfs = SQLiteVFS(target, readonly=True) + except Exception as exc: + raise InstallError(f"无法打开插件包: {exc}") from exc + try: + try: + vfs.use_snapshot(snapshot_id) + except FileNotFoundError as exc: + raise InstallError(f"快照不存在: {snapshot_id}") from exc + stats = vfs.get_stats() + finally: + vfs.close() + entry["current_snapshot_id"] = int(snapshot_id) + if stats.get("snapshot_name"): + entry["version"] = stats["snapshot_name"] + entry["updated_time"] = _now() + registry.save(base, data) + return entry + + +def uninstall(base, plugin_id): + """卸载:删包目录与键值存储,清注册表项。不可恢复。""" + data = registry.load(base) + removed = registry.remove(data, plugin_id) + if removed is None: + raise InstallError(f"插件未安装: {plugin_id}") + registry.save(base, data) + + package = paths.plugin_package(base, plugin_id) + plugin_dir = os.path.dirname(package) + if os.path.isdir(plugin_dir): + shutil.rmtree(plugin_dir, ignore_errors=True) + storage = paths.storage_file(base, plugin_id) + if os.path.exists(storage): + os.unlink(storage) + return removed + + +def list_installed(base): + """注册表里的已安装插件(按名字排序)。""" + data = registry.load(base) + entries = list(data["plugins"].values()) + entries.sort(key=lambda item: (item.get("name") or item.get("id") or "").lower()) + return entries diff --git a/src/svfs_client/login.py b/src/svfs_client/login.py new file mode 100644 index 0000000..da40a25 --- /dev/null +++ b/src/svfs_client/login.py @@ -0,0 +1,548 @@ +#!/usr/bin/env python3 +"""启动动画与登录界面(Qt 层)。 + +启动链(:class:`StartupFlow`): + +1. ``SplashWindow`` 播放启动动画(呼吸 logo + 加载点 + 扫过的进度线); +2. 若 settings 里有记住的登录凭据,趁动画期间向服务器静默校验 token + (/api/me),有效则直接进入主窗口,无效则在登录窗里提示原因; +3. 否则弹 ``LoginWindow``(强制登录:关窗即退出客户端),成功后回调。 + +界面全部使用 svfs_studio 的暗色主题(颜色/圆角/字号都从 theme 取), +两个窗口都是无边框圆角卡片:QSS 里 ``QDialog { background: BG }`` 会把 +圆角外糊成实底,所以这里用 ``WA_TranslucentBackground`` + paintEvent 自绘。 +""" + +import math +import time + +from PySide6.QtCore import ( + QEventLoop, + QObject, + QPointF, + QRectF, + Qt, + QThread, + QTimer, + Signal, +) +from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QWidget, +) + +from svfs_studio import theme + +from . import APP_NAME, __version__, credentials, diagnostics, settings +from .app import _logo_pixmap +from .worker import Client + +__all__ = ["SplashWindow", "LoginWindow", "StartupFlow"] + +#: 启动动画最短展示时长(秒)——再快也要让人看清 logo +SPLASH_MIN_SECONDS = 1.4 +#: 动画刷新间隔(毫秒) +SPLASH_TICK_MS = 33 +#: 淡入/淡出时长(毫秒) +FADE_MS = 250 + +SPLASH_WIDTH = 340 +SPLASH_HEIGHT = 200 +LOGIN_WIDTH = 400 + +#: 浮动窗口卡片的圆角。页面内卡片仍用 theme.RADIUS(5px); +#: 无边框悬浮窗放大一号(10px)视觉上更稳,这是唯一的例外。 +CARD_RADIUS = theme.RADIUS * 2 + + +# ---------------------------------------------------------------------- +# SplashWindow:启动动画 +# ---------------------------------------------------------------------- + + +class SplashWindow(QWidget): + """无边框启动动画窗口:呼吸 logo + 状态文字 + 加载点 + 扫过的进度线。""" + + closed = Signal() + + def __init__(self, status="正在启动…", parent=None): + super().__init__(parent) + self.setWindowFlags( + Qt.WindowType.SplashScreen | Qt.WindowType.FramelessWindowHint + ) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setFixedSize(SPLASH_WIDTH, SPLASH_HEIGHT) + self._status = status + self._phase = 0.0 # 动画相位(秒,只增不减) + self._fade = 1.0 # 绘制层渐隐系数(1=完全不透明) + self._closing = False + self._shown_at = 0.0 + self._finishing = False + self._timer = QTimer(self) + self._timer.setInterval(SPLASH_TICK_MS) + self._timer.timeout.connect(self._tick) + + # ---- 生命周期 --------------------------------------------------- + + def showEvent(self, event): + super().showEvent(event) + self._shown_at = time.monotonic() + self._timer.start() + + def set_status(self, text): + """改状态文字(如「正在验证登录…」),动画不中断。""" + self._status = text + self.update() + + def finish(self): + """结束动画:最短展示时间到了才渐隐并关闭;重复调用无副作用。""" + if self._finishing: + return + self._finishing = True + elapsed = time.monotonic() - self._shown_at + delay = max(0.0, SPLASH_MIN_SECONDS - elapsed) + QTimer.singleShot(int(delay * 1000), self._begin_fade_out) + + # 点一下跳过剩余的等待 + def mousePressEvent(self, event): + self.finish() + super().mousePressEvent(event) + + # ---- 动画 ------------------------------------------------------- + + def _tick(self): + self._phase += SPLASH_TICK_MS / 1000.0 + if self._closing: + # 渐隐用画笔 alpha(paintEvent 里乘 _fade),不碰 windowOpacity: + # 半透明无边框窗配窗口级透明度动画在 Windows 上不可靠 + self._fade = max( + 0.0, self._fade - SPLASH_TICK_MS / 1000.0 / (FADE_MS / 1000.0) + ) + if self._fade <= 0.0: + self._timer.stop() + # 先关窗再发信号:接收方要打开下一个窗口,绝不能让这个 + # (已是 Splash 置顶标志的)窗口还挂在屏幕上压住它 + self.close() + self.closed.emit() + return + self.update() + + def _begin_fade_out(self): + self._closing = True + if not self._timer.isActive(): + self._timer.start() + + # ---- 绘制 ------------------------------------------------------- + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + fade = self._fade + painter.setOpacity(fade) + + # 卡片 + rect = QRectF(0.5, 0.5, self.width() - 1.0, self.height() - 1.0) + card = QPainterPath() + card.addRoundedRect(rect, CARD_RADIUS, CARD_RADIUS) + painter.fillPath(card, QColor(theme.PANEL)) + painter.setPen(QColor(theme.BORDER)) + painter.drawPath(card) + + # 呼吸的 logo + breathe = 0.6 + 0.4 * (0.5 + 0.5 * math.sin(self._phase * math.pi)) + size = 56 + painter.drawPixmap( + (self.width() - size) // 2, 26, + _logo_pixmap(size, alpha=int(255 * breathe * fade)), + ) + + # 应用名与状态文字 + painter.setPen(QColor(theme.TEXT)) + painter.setFont(theme.font_ui(theme.SIZE_TITLE, QFont.Weight.DemiBold)) + painter.drawText( + QRectF(0, 92, self.width(), 26), + Qt.AlignmentFlag.AlignCenter, APP_NAME, + ) + painter.setPen(QColor(theme.TEXT_MUTED)) + painter.setFont(theme.font_ui(theme.SIZE_SMALL)) + painter.drawText( + QRectF(0, 120, self.width(), 20), + Qt.AlignmentFlag.AlignCenter, self._status, + ) + + # 三个波浪加载点 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(theme.ACCENT)) + center_y = self.height() - 40 + for i in range(3): + wave = 0.5 + 0.5 * math.sin(self._phase * math.pi * 2 / 0.9 - i * 0.7) + painter.setOpacity((0.2 + 0.8 * wave) * fade) + radius = 2.0 + 1.2 * wave + painter.drawEllipse( + QPointF(self.width() / 2 + (i - 1) * 14, center_y), radius, radius + ) + painter.setOpacity(fade) + + # 底部循环扫过的强调色细线(不定进度) + track_left, track_right = 28, self.width() - 28 + track_w = track_right - track_left + sweep = (self._phase % 1.8) / 1.8 # 0..1 循环 + segment = track_w * 0.4 + x0 = track_left + sweep * (track_w + segment) - segment + visible_left = max(track_left, x0) + visible_right = min(track_right, x0 + segment) + if visible_right > visible_left: + painter.setClipRect(QRectF(track_left, 0, track_w, self.height())) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(theme.ACCENT)) + painter.drawRoundedRect( + QRectF(visible_left, self.height() - 3, + visible_right - visible_left, 2), + 1, 1, + ) + painter.setClipping(False) + + +# ---------------------------------------------------------------------- +# LoginWindow:登录界面 +# ---------------------------------------------------------------------- + + +class LoginWindow(QDialog): + """登录界面(无边框圆角卡片,可拖动;关闭 = 放弃登录)。 + + 成功后 :attr:`session` 是 ``{"token", "username", "base_url"}``; + 勾选「记住登录」时同时把 token 写进 settings.json。 + """ + + def __init__(self, base, parent=None, note=""): + super().__init__(parent) + self.base = base + self.session = None # 登录成功后由 _on_login_done 填 + self._drag_offset = None + self._logging_in = False + + self.setWindowTitle("登录") + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setModal(True) + self.setFixedWidth(LOGIN_WIDTH) + self.setMinimumHeight(470) + + cfg = settings.load(base) + outer = QVBoxLayout(self) + outer.setContentsMargins(1, 1, 1, 1) # 留出自绘描边的位置 + body = QWidget() + column = QVBoxLayout(body) + column.setContentsMargins(32, 28, 32, 22) + column.setSpacing(6) + outer.addWidget(body) + + # ---- 头部:logo + 标题 ---- + logo = QLabel() + logo.setPixmap(_logo_pixmap(52)) + logo.setAlignment(Qt.AlignmentFlag.AlignCenter) + column.addWidget(logo) + column.addSpacing(6) + + title = QLabel("登录") + theme.role(title, "h1") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + column.addWidget(title) + subtitle = QLabel("连接服务器以下载插件与调用接口") + theme.role(subtitle, "muted") + subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + column.addWidget(subtitle) + column.addSpacing(14) + + # ---- 表单 ---- + self._error_label = QLabel("") + theme.role(self._error_label, "err") + self._error_label.setWordWrap(True) + self._error_label.hide() + column.addWidget(self._error_label) + + self.server_edit = self._add_field(column, "服务器地址") + self.server_edit.setFont(theme.font_mono(theme.SIZE_BASE)) + self.server_edit.setPlaceholderText("http://127.0.0.1:8000") + self.server_edit.setText(cfg.get("base_url") or "") + self.user_edit = self._add_field(column, "用户名") + self.user_edit.setPlaceholderText("用户名") + self.user_edit.setText(cfg.get("auth_user") or "") + self.password_edit = self._add_field(column, "密码") + self.password_edit.setPlaceholderText("密码") + self.password_edit.setEchoMode(QLineEdit.EchoMode.Password) + column.addSpacing(4) + + self.remember_check = QCheckBox("记住登录(下次启动自动登录)") + self.remember_check.setChecked(True) + column.addWidget(self.remember_check) + column.addSpacing(8) + + self.login_button = QPushButton("登 录") + self.login_button.setObjectName("Primary") + self.login_button.setFixedHeight(34) + self.login_button.setDefault(True) + self.login_button.clicked.connect(self._submit) + column.addWidget(self.login_button) + + column.addStretch(1) + version = QLabel(f"v{__version__}") + theme.role(version, "faint") + version.setAlignment(Qt.AlignmentFlag.AlignCenter) + column.addWidget(version) + + if note: + self.show_error(note) + + for edit in (self.server_edit, self.user_edit, self.password_edit): + edit.returnPressed.connect(self._submit) + + # 工作线程(与 MainWindow 同款:登录请求不卡界面) + self._thread = QThread(self) + self._thread.setObjectName("svfs-login-worker") + self._client = Client(base, parent=self) + self._client.attach_to(self._thread) + + # ---- 辅助 ------------------------------------------------------- + + def _add_field(self, column, caption): + label = QLabel(caption) + theme.role(label, "muted") + label.setFont(theme.font_ui(theme.SIZE_SMALL)) + column.addWidget(label) + edit = QLineEdit() + edit.setFixedHeight(30) + column.addWidget(edit) + column.addSpacing(6) + return edit + + def show_error(self, text): + self._error_label.setText(text) + self._error_label.show() + + def _set_busy(self, busy): + self._logging_in = busy + self.login_button.setEnabled(not busy) + self.login_button.setText("正在登录…" if busy else "登 录") + for edit in (self.server_edit, self.user_edit, self.password_edit): + edit.setEnabled(not busy) + self.remember_check.setEnabled(not busy) + + # ---- 拖动与淡入 --------------------------------------------------- + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self._drag_offset = ( + event.globalPosition().toPoint() - self.frameGeometry().topLeft() + ) + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._drag_offset is not None and ( + event.buttons() & Qt.MouseButton.LeftButton + ): + self.move(event.globalPosition().toPoint() - self._drag_offset) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + self._drag_offset = None + super().mouseReleaseEvent(event) + + # ---- 登录 ------------------------------------------------------- + + def _submit(self): + if self._logging_in: + return + try: + base_url = settings.normalize_base_url(self.server_edit.text()) + except ValueError as exc: + self.show_error(str(exc)) + return + if not base_url: + self.show_error("请填写服务器地址") + self.server_edit.setFocus() + return + username = self.user_edit.text().strip() + if not username: + self.show_error("请输入用户名") + self.user_edit.setFocus() + return + password = self.password_edit.text() + if not password: + self.show_error("请输入密码") + self.password_edit.setFocus() + return + self._error_label.hide() + self._set_busy(True) + self._client.submit( + "login", + {"base_url": base_url, "username": username, "password": password}, + on_done=self._on_login_done, + on_fail=self._on_login_fail, + ) + + def _on_login_done(self, payload): + session = dict(payload.get("session") or {}) + self._persist(session) + self.session = session + self._set_busy(False) + self.accept() + + def _on_login_fail(self, title, detail): + self._set_busy(False) + self.show_error(detail or title) + + def _persist(self, session): + """把登录结果写回设置:记住时把 token 交给凭据存储,否则清掉旧的。 + + 服务器地址与用户名(不是秘密)仍留在 settings.json;token 走 + credentials(DPAPI/keyring,绝不落到 settings.json)。 + """ + data = settings.load(self.base) + data["base_url"] = session.get("base_url") or data.get("base_url") or "" + data["auth_user"] = session.get("username") or "" + data["auth_token"] = "" # 老字段:迁移后不再使用(见 credentials.migrate) + settings.save(self.base, data) + if self.remember_check.isChecked(): + credentials.save( + self.base, session.get("token") or "", + base_url=session.get("base_url") or data["base_url"], + username=session.get("username") or "", + ) + else: + credentials.clear(self.base) + + # ---- 收尾 ------------------------------------------------------- + + def done(self, result): + # accept/reject 都经过这里:先停工作线程再退出模态循环 + self._thread.quit() + self._thread.wait(3000) + super().done(result) + + # ---- 自绘卡片 ----------------------------------------------------- + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(0.5, 0.5, self.width() - 1.0, self.height() - 1.0) + card = QPainterPath() + card.addRoundedRect(rect, CARD_RADIUS, CARD_RADIUS) + painter.fillPath(card, QColor(theme.PANEL)) + painter.setPen(QColor(theme.BORDER)) + painter.drawPath(card) + + +# ---------------------------------------------------------------------- +# StartupFlow:启动链协调器 +# ---------------------------------------------------------------------- + + +class StartupFlow(QObject): + """启动动画 → 静默校验记住的 token → (必要时)登录界面。 + + :meth:`run` 阻塞到流程结束并返回会话(``{"token", "username", + "base_url"}``);用户关闭登录窗时返回 None(调用方应退出客户端)。 + """ + + def __init__(self, base, parent=None): + super().__init__(parent) + self.base = base + self._session = None + self._login_note = "" + self._splash = None + self._login = None + self._loop = None + self._thread = None + self._client = None + + def run(self): + cfg = settings.load(self.base) + # 老版本的 token 在 settings.json 里:搬到凭据存储(只做一次) + credentials.migrate(self.base) + token = credentials.load(self.base, cfg.get("base_url") or "") + self._splash = SplashWindow() + self._splash.closed.connect(self._on_splash_closed) + self._splash.show() + + if cfg.get("base_url") and token: + # 有记住的凭据:趁动画在播,后台静默校验 + self._splash.set_status("正在验证登录…") + self._thread = QThread(self) + self._thread.setObjectName("svfs-startup-worker") + self._client = Client(self.base) + self._client.attach_to(self._thread) + self._client.submit( + "verify_auth", + {"base_url": cfg["base_url"], "token": token}, + on_done=self._on_verify_done, + on_fail=self._on_verify_fail, + ) + else: + self._splash.finish() + + loop = QEventLoop() + self._loop = loop + loop.exec() + self._loop = None + return self._session + + # ---- 校验结果 --------------------------------------------------- + + def _on_verify_done(self, payload): + session = payload.get("session") + if session is None and payload.get("offline"): + # 服务器不可达(凭据本身没被拒绝):离线宽限进入——本地插件 + # 可用,接口类功能等恢复连接后自然可用 + cfg = settings.load(self.base) + self._session = { + "token": credentials.load(self.base, cfg.get("base_url") or ""), + "username": cfg.get("auth_user") or "", + "base_url": cfg.get("base_url") or "", + "offline": True, + } + diagnostics.log("服务器不可达,离线宽限进入", "startup") + elif session: + self._session = dict(session) + self._splash.finish() + + def _on_verify_fail(self, title, detail): + # 凭据失效(401 等):进登录窗,把原因亮出来 + self._login_note = detail or title + self._splash.finish() + + def _on_splash_closed(self): + self._stop_worker() + if self._session: + self._finish() + return + self._login = LoginWindow(self.base, note=self._login_note) + # 先亮出来再进模态循环,并主动要一次前台:启动动画期间进程不在 + # 前台,Windows 可能不把 exec() 的新窗口提到最前 + self._login.show() + self._login.raise_() + self._login.activateWindow() + self._login.exec() + self._session = self._login.session + self._login = None + self._finish() + + def _finish(self): + if self._loop is not None: + self._loop.quit() + + # ---- 资源 ------------------------------------------------------- + + def _stop_worker(self): + if self._thread is not None: + self._thread.quit() + self._thread.wait(3000) + self._thread = None + self._client = None diff --git a/src/svfs_client/manifest.py b/src/svfs_client/manifest.py new file mode 100644 index 0000000..08ddea4 --- /dev/null +++ b/src/svfs_client/manifest.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""``plugin.json`` —— 插件清单的读取与校验(纯标准库)。 + +一个插件就是一个完整的 .svfs 包,包根(当前快照根)必须有一份 plugin.json: + +.. code-block:: json + + { + "id": "com.example.clock", // 反域名,全局唯一,同时是 svfs:// 的 host + "name": "时钟", + "version": "1.2.0", // 用作快照名 + "api_version": 1, // 要求的最低宿主桥版本(≤ 宿主版本即可安装) + "entry": "index.html", // 前端入口(SPA) + "icon": "icon.png", // 可选,侧栏图标 + "permissions": ["storage", "notify"], + "net_hosts": ["api.example.com", "*.corp.example"], // 可选,见下 + "description": "…" + } + +``net_hosts``(可选):**net 目标白名单**。声明后,该插件经桥发的请求只能指向 +列表里的主机;不声明 = 不限制(老插件行为不变)。有 net 权限的插件本质上能 +把拿到的任何数据发到任何地方,所以给企业内网插件的清单加上这一项是划算的 +(``*.`` 前缀表示后缀匹配)。 + +所有字段校验都在这里做,错误信息是可直接展示给人看的中文。 +""" + +import json +import re + +from . import PLUGIN_API_VERSION + +__all__ = ["ManifestError", "PERMISSIONS", "validate", "from_json", + "read_from_vfs", "host_allowed"] + + +class ManifestError(ValueError): + """清单缺失或校验失败。""" + + +#: 插件 id:反域名形式,小写字母开头,段内小写字母/数字/连字符。 +#: 它同时要充当 svfs:// 的 host(Chromium 会把 host 统一成小写)。 +ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$") +VERSION_PATTERN = re.compile(r"^\d+(\.\d+){0,3}([-._][0-9A-Za-z.-]+)?$") +ENTRY_PATTERN = re.compile(r"^[A-Za-z0-9._][A-Za-z0-9._/-]*$") + +#: 桥 API 的能力集合。manifest 里声明了才能用对应能力,安装时向用户展示: +#: storage —— 按插件隔离的键值存储 +#: notify —— 调宿主发一条通知 +#: title —— 改窗口标题 +#: net —— 经宿主请求服务端接口(绕开 CORS;url/path 见 svfs.net.request) +#: dialog —— 宿主原生对话框(svfs.ui.confirm / svfs.ui.message) +PERMISSIONS = ("storage", "notify", "title", "net", "dialog") + +_REQUIRED = ("id", "name", "version", "entry", "api_version") + + +def validate(data): + """校验并规范化清单,返回浅拷贝;失败抛 :class:`ManifestError`。""" + if not isinstance(data, dict): + raise ManifestError("plugin.json 必须是一个 JSON 对象") + missing = [field for field in _REQUIRED if field not in data] + if missing: + raise ManifestError("plugin.json 缺少字段: " + ", ".join(missing)) + + plugin_id = data["id"] + if not isinstance(plugin_id, str) or not ID_PATTERN.match(plugin_id): + raise ManifestError( + f"插件 id 不合法: {plugin_id!r}(需要反域名形式,如 com.example.clock," + "小写字母/数字/连字符)" + ) + if len(plugin_id) > 63: + # URL host 的常见长度上限;超长会让 origin/缓存键的处理变得不可靠 + raise ManifestError(f"插件 id 过长(超过 63 字符): {plugin_id}") + + version = data["version"] + if not isinstance(version, str) or not VERSION_PATTERN.match(version): + raise ManifestError(f"版本号不合法: {version!r}(如 1.2.0)") + + entry = data["entry"] + if not isinstance(entry, str) or not ENTRY_PATTERN.match(entry) or entry.endswith("/"): + raise ManifestError(f"入口文件不合法: {entry!r}(包内相对路径,如 index.html)") + if entry.startswith("/"): + raise ManifestError("入口文件必须是包内相对路径(不以 / 开头)") + + api_version = data["api_version"] + if not isinstance(api_version, int) or isinstance(api_version, bool): + raise ManifestError("api_version 必须是整数") + if api_version < 1: + raise ManifestError(f"api_version 必须 ≥ 1(当前是 {api_version})") + if api_version > PLUGIN_API_VERSION: + # 旧宿主跑不了依赖新桥能力的插件;反过来,旧插件跑在新宿主上 + # 没问题——桥能力只增不破,所以是下限匹配而不是相等匹配 + raise ManifestError( + f"插件要求 api_version={api_version},宿主只支持 {PLUGIN_API_VERSION};" + "请升级客户端后再安装这个插件" + ) + + permissions = data.get("permissions", []) + if not isinstance(permissions, list) or any( + not isinstance(p, str) or p not in PERMISSIONS for p in permissions + ): + raise ManifestError("permissions 必须是列表,取值只能是: " + ", ".join(PERMISSIONS)) + + net_hosts = _validate_net_hosts(data.get("net_hosts"), permissions) + + manifest = dict(data) + manifest["permissions"] = sorted(set(permissions)) + manifest["net_hosts"] = net_hosts + manifest.setdefault("description", "") + manifest.setdefault("icon", None) + return manifest + + +def _validate_net_hosts(raw, permissions): + """校验可选的白名单字段,返回规范化后的列表(未声明 = 空列表 = 不限制)。""" + if raw in (None, ""): + return [] + if not isinstance(raw, list) or not raw: + raise ManifestError( + 'net_hosts 必须是主机名列表(如 ["api.example.com"]);' + "不想要限制就别写这个字段" + ) + if "net" not in permissions: + # 声明了 net 的限制却没要 net 权限:几乎肯定是写错了,早点报出来 + raise ManifestError("net_hosts 需要同时声明 net 权限") + hosts = [] + for item in raw: + if not isinstance(item, str) or not item.strip(): + raise ManifestError(f"net_hosts 里的条目必须是主机名: {item!r}") + entry = item.strip().lower().rstrip(".") + wildcard = entry.startswith("*.") + if wildcard: + entry = entry[2:] + entry = entry.strip(".") + if not entry or "/" in entry or ":" in entry: + raise ManifestError(f"net_hosts 只写主机名(不含协议/端口/路径): {item!r}") + hosts.append(f"*.{entry}" if wildcard else entry) + return sorted(set(hosts)) + + +def host_allowed(host, patterns): + """主机是否被白名单放行;``patterns`` 为空 = 不限制(全部放行)。 + + 匹配规则:精确相等,或 ``*.example.com`` 这种后缀匹配(含子域本身, + 即 ``example.com`` 也在 ``*.example.com`` 里)。 + """ + if not patterns: + return True + host = str(host or "").lower().strip(".") + for pattern in patterns: + pattern = str(pattern or "").lower().strip(".") + if not pattern: + continue + if pattern.startswith("*."): + suffix = pattern[2:] + if host == suffix or host.endswith("." + suffix): + return True + elif host == pattern: + return True + return False + + +def from_json(text): + """从 JSON 文本(或字节,容忍 UTF-8 BOM)解析并校验清单。""" + if isinstance(text, (bytes, bytearray)): + text = bytes(text).decode("utf-8-sig") + elif text.startswith("\ufeff"): + text = text.lstrip("\ufeff") + try: + data = json.loads(text) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ManifestError(f"plugin.json 不是合法的 JSON: {exc}") from exc + return validate(data) + + +def read_from_vfs(vfs, path="plugin.json"): + """从已打开的 :class:`SQLiteVFS`(当前快照)读清单。 + + 缺文件、坏 JSON、校验不过都抛 :class:`ManifestError`——调用方只管接一种异常。 + """ + try: + raw = vfs.read_file(path) + except FileNotFoundError as exc: + raise ManifestError("包根目录缺少 plugin.json——这不是一个插件包") from exc + return from_json(raw.decode("utf-8-sig")) diff --git a/src/svfs_client/netutil.py b/src/svfs_client/netutil.py new file mode 100644 index 0000000..781a631 --- /dev/null +++ b/src/svfs_client/netutil.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""网络小工具(纯标准库):代理策略、额外 CA、幂等重试与退避。 + +三件事都在这里集中处理,因为它们是"所有对外请求的共同策略": + +1. **loopback 强制直连**:urllib 默认跟随系统代理(Windows 上常被 Clash / + v2ray 等接管)。代理几乎从不正确处理 loopback 目标——指向本机服务器的 + 请求会被代理黑掉,表现为插件接口"一直在请求中…"(而浏览器/curl 对 + localhost 默认直连,所以服务器本身看起来是好的)。所以目标主机是 + loopback(localhost / 127.0.0.1 / ::1)时**永远直连**,哪怕用户配了代理。 + +2. **代理与自签证书可配置**:``configure(proxy, ca_bundle)`` 由壳层按 + ``settings.json`` 调用。内网部署的现实是"要过公司代理"和"服务器用的是 + 自签证书",这两件事不给出配置手段就等于不能用。 + +3. **幂等请求重试**:GET/HEAD 撞上 502/503/504 或连接层错误时按指数退避 + 重试(默认 1 次,见 settings.net_retries)。**只重试幂等方法**——自动重发 + 一次 POST 可能把"下单"变成两单,这种"帮倒忙"的重试比不重试更糟。 +""" + +import os +import ssl +import time +import urllib.error +import urllib.parse +import urllib.request + +from . import diagnostics + +__all__ = ["is_loopback", "configure", "apply_settings", "current", "urlopen", + "open_with_retry"] + +#: 触发重试的服务器状态码(临时不可用/网关抖动;其它 4xx/5xx 重试没意义) +RETRY_STATUS = (502, 503, 504) +#: 首次重试前的等待与退避上限(秒) +RETRY_BACKOFF = 0.3 +RETRY_BACKOFF_MAX = 3.0 +#: 幂等方法(自动重发安全) +IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS") + +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + +#: 当前网络策略(configure 改写;空 = 跟随系统) +_proxy = "" +_ca_bundle = "" +#: opener 缓存:key = (loopback, proxy, ca_bundle) +_openers = {} + + +def is_loopback(url): + """目标是否指向本机(host 为 localhost/127.0.0.1/::1 或 *.localhost)。""" + try: + host = (urllib.parse.urlsplit(url).hostname or "").lower() + except ValueError: + return False + return host in _LOOPBACK_HOSTS or host.endswith(".localhost") + + +def configure(proxy="", ca_bundle=""): + """设置代理与额外 CA 证书;返回错误信息(None = 成功)。 + + ``proxy`` 为空 = 跟随系统代理;``ca_bundle`` 为空 = 系统信任库。 + CA 文件不存在时**不生效并返回错误**(宁可不加密连接,也不要假装配上了)。 + """ + global _proxy, _ca_bundle + error = None + ca_bundle = str(ca_bundle or "").strip() + if ca_bundle and not os.path.isfile(ca_bundle): + error = f"CA 证书文件不存在: {ca_bundle}" + ca_bundle = "" + _proxy = str(proxy or "").strip() + _ca_bundle = ca_bundle + _openers.clear() + return error + + +def current(): + """当前策略(界面/诊断报告展示用):``(proxy, ca_bundle)``。""" + return _proxy, _ca_bundle + + +def apply_settings(base): + """按 ``settings.json`` 应用代理与 CA(启动时、设置保存后各调一次)。 + + 返回错误信息(None = 成功)——CA 路径写错要当场告诉用户,而不是等到 + 某个请求失败时才发现。settings 是延迟导入的(它不依赖本模块,但 + 本模块被 auth 等在很早的阶段导入,避免导入期互相牵扯)。 + """ + from . import settings as settings_mod + + proxy, ca_bundle = settings_mod.proxy_config(base) + return configure(proxy, ca_bundle) + + +def _opener(loopback): + """按目标类型取 opener(缓存)。 + + loopback → 无代理 opener;否则显式代理(配了才加,否则用环境代理)。 + 额外的 CA 证书两种情况都生效。 + + 注意这里的"直连"是靠 ``ProxyHandler({})`` 实现的:空代理表让它一个 + ``*_open`` 方法都不生成,``build_opener`` 于是把它整个丢掉,opener 里 + 干脆没有代理能力(实测行为,别改成"传个空 dict 就当禁代理"的其他写法)。 + """ + key = (loopback, _proxy, _ca_bundle) + opener = _openers.get(key) + if opener is not None: + return opener + + handlers = [] + if loopback: + handlers.append(urllib.request.ProxyHandler({})) # 不走任何代理 + elif _proxy: + handlers.append(urllib.request.ProxyHandler( + {"http": _proxy, "https": _proxy})) + if _ca_bundle: + try: + context = ssl.create_default_context(cafile=_ca_bundle) + except (OSError, ssl.SSLError) as exc: + diagnostics.log(f"CA 证书无法加载({_ca_bundle}): {exc}", "net") + context = None + if context is not None: + handlers.append(urllib.request.HTTPSHandler(context=context)) + opener = urllib.request.build_opener(*handlers) + _openers[key] = opener + return opener + + +def urlopen(request, *, timeout): + """按目标主机选择代理策略后发请求(loopback 强制直连,单次)。""" + return _opener(is_loopback(request.full_url)).open(request, timeout=timeout) + + +def open_with_retry(request, *, timeout, retries=0, sleep=time.sleep): + """``urlopen`` + 幂等重试;失败时抛出最后一次的异常。 + + 只对幂等方法(GET/HEAD/OPTIONS)重试,见模块文档。``sleep`` 可注入, + 测试不必真的等退避时间。 + """ + method = str(request.get_method() or "GET").upper() + attempts = max(1, int(retries) + 1) if method in IDEMPOTENT_METHODS else 1 + last_error = None + for attempt in range(attempts): + if attempt: + delay = min(RETRY_BACKOFF_MAX, RETRY_BACKOFF * (2 ** (attempt - 1))) + diagnostics.log( + f"{method} {request.full_url} 第 {attempt} 次重试" + f"(等待 {delay:.1f}s,上次: {last_error})", "net") + sleep(delay) + try: + return urlopen(request, timeout=timeout) + except urllib.error.HTTPError as exc: + # HTTPError 也是 URLError 的子类,必须先接住 + last_error = exc + if exc.code not in RETRY_STATUS or attempt == attempts - 1: + raise + except (urllib.error.URLError, OSError) as exc: + last_error = exc + if attempt == attempts - 1: + raise + raise last_error # 循环里要么 return 要么 raise,这里只是兜底 diff --git a/src/svfs_client/paths.py b/src/svfs_client/paths.py new file mode 100644 index 0000000..9c522cf --- /dev/null +++ b/src/svfs_client/paths.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""客户端数据目录布局(纯标准库)。 + +.. code-block:: text + + / Windows: %LOCALAPPDATA%/svfs-client;其它: ~/.svfs-client + ├── registry.json 已安装插件注册表(installer 维护) + ├── settings.json 客户端设置(服务器地址 base_url 等) + ├── credentials.dat 登录令牌(DPAPI/keyring 加密;见 credentials.py) + ├── logs/client.log 诊断日志(滚动,见 diagnostics.py) + ├── plugins//plugin.svfs 每个插件一个包文件(快照即版本) + ├── storage/.json 桥 storage 能力的键值存储(按插件隔离) + └── web/ QWebEngineProfile 的持久化目录(localStorage 等) + +目录名里会出现插件 id,清单校验已经限制了字符集,这里再挡一层, +保证 id 拼进路径不会越出数据目录。 +""" + +import os +import re +import shutil + +__all__ = [ + "APP_DIR_NAME", + "data_dir", + "data_dir_file", + "resolve_data_dir", + "set_data_dir", + "ensure_layout", + "registry_file", + "settings_file", + "credentials_file", + "logs_dir", + "plugin_package", + "storage_file", + "web_profile_dir", + "object_cache_dir", + "dir_size", + "clear_dir_contents", +] + +APP_DIR_NAME = "svfs-client" + +#: 允许出现在目录名里的插件 id 字符(manifest.ID_PATTERN 的宽松超集,防止越目录) +_SAFE_ID = re.compile(r"^[A-Za-z0-9.-]+$") + + +def data_dir(explicit=None): + """数据目录。``explicit`` 给出时原样使用(测试与 ``--data`` 参数用)。""" + if explicit: + return os.path.abspath(explicit) + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return os.path.join(local_app_data, APP_DIR_NAME) + return os.path.join(os.path.expanduser("~"), "." + APP_DIR_NAME) + + +def _id_path(base, plugin_id, *parts): + if not _SAFE_ID.match(plugin_id or ""): + raise ValueError(f"插件 id 不合法: {plugin_id!r}") + return os.path.join(base, *parts, plugin_id) + + +def registry_file(base): + return os.path.join(base, "registry.json") + + +def settings_file(base): + return os.path.join(base, "settings.json") + + +def credentials_file(base): + """登录令牌的存储文件(内容由 credentials.py 决定:密文或原文)。""" + return os.path.join(base, "credentials.dat") + + +def logs_dir(base): + """诊断日志目录(滚动文件,见 diagnostics.py)。""" + return os.path.join(base, "logs") + + +def plugin_package(base, plugin_id): + """某个插件安装后的包文件路径。""" + return _id_path(base, plugin_id, "plugins") + os.sep + "plugin.svfs" + + +def storage_file(base, plugin_id): + """某个插件的桥 storage 键值文件。""" + return _id_path(base, plugin_id, "storage") + ".json" + + +def web_profile_dir(base): + """QWebEngineProfile 的持久化目录(Chromium 自己管理里面的一切)。""" + return os.path.join(base, "web") + + +def ensure_layout(base): + """把数据目录的骨架建出来(已存在则不动)。""" + for sub in ("plugins", "storage", "web", "logs"): + os.makedirs(os.path.join(base, sub), exist_ok=True) + return base + + +# ---------------------------------------------------------------------- +# 数据目录(插件路径)的运行时重定向 +# ---------------------------------------------------------------------- +# +# 数据目录里装着注册表/设置/插件包,运行中不能搬,所以采用"指针文件" +# 方案:真正的数据目录写在固定位置(默认数据目录下 data-dir.txt), +# 改完重启客户端生效;``--data`` 命令行参数仍然最优先。 + + +def data_dir_file(): + """数据目录指针文件的位置(固定在**默认**数据目录下)。""" + return os.path.join(data_dir(), "data-dir.txt") + + +def resolve_data_dir(explicit=None): + """生效的数据目录:``--data`` 参数 > 指针文件 > 默认目录。 + + 指针文件缺失、内容为空或指向的目录不存在时都回退默认目录—— + 坏掉的指针不该让客户端起不来。 + """ + if explicit: + return data_dir(explicit) + try: + with open(data_dir_file(), "r", encoding="utf-8") as handle: + target = handle.read().strip() + except OSError: + return data_dir() + if target and os.path.isabs(target) and os.path.isdir(target): + return os.path.abspath(target) + return data_dir() + + +def set_data_dir(target): + """把数据目录写进指针文件(**重启后生效**),返回错误信息或 None。 + + 传空串 = 清除重定向(回到默认目录)。 + """ + target = os.path.abspath(target) if target else "" + if not target: + try: + os.unlink(data_dir_file()) + except OSError: + pass + return None + if not os.path.isdir(target): + return f"目录不存在: {target}" + pointer = data_dir_file() + os.makedirs(os.path.dirname(pointer), exist_ok=True) + with open(pointer, "w", encoding="utf-8") as handle: + handle.write(target) + return None + + +# ---------------------------------------------------------------------- +# 通用目录工具(设置对话框展示/清理用) +# ---------------------------------------------------------------------- + + +def object_cache_dir(): + """对象缓存目录(内容按 sha256 寻址,跨包/跨项目复用)。""" + from sqlite_vfs.publish import default_cache_dir + + return default_cache_dir() + + +def dir_size(path): + """目录总字节数(递归;目录不存在返回 0)。""" + total = 0 + if os.path.isdir(path): + for root, _dirs, files in os.walk(path): + for name in files: + try: + total += os.path.getsize(os.path.join(root, name)) + except OSError: + pass # 竞态删掉的文件按 0 算 + return total + + +def clear_dir_contents(path): + """清空目录内容但保留目录本身,返回删除的条目数;目录不存在视为空。""" + removed = 0 + if os.path.isdir(path): + for name in os.listdir(path): + full = os.path.join(path, name) + if os.path.isdir(full) and not os.path.islink(full): + shutil.rmtree(full, ignore_errors=True) + else: + try: + os.unlink(full) + except OSError: + continue + removed += 1 + return removed diff --git a/src/svfs_client/registry.py b/src/svfs_client/registry.py new file mode 100644 index 0000000..5fb30c8 --- /dev/null +++ b/src/svfs_client/registry.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""已安装插件注册表(纯标准库)。 + +``registry.json`` 是插件"当前状态"的唯一事实来源: + +.. code-block:: json + + { + "version": 1, + "plugins": { + "com.example.clock": { "enabled": true, "current_snapshot_id": 3, … } + } + } + +包内容(有哪些文件、哪些版本)以包文件本身为准,注册表只记录指向它的指针: +启用状态、当前快照(版本)、来源、时间。注册表被客户端各处读取(协议层查 +"这个 host 能不能服务",界面列出装了什么),但**只有 installer 会写**—— +读写分开,谁改了什么一目了然。 + +所有修改走这里的小函数:``load()`` → 改 → ``save()`` 原子写回(临时文件 + +``os.replace``,进程崩溃也不会留下半个注册表)。 +""" + +import json +import os +import tempfile + +from . import paths + +__all__ = ["REGISTRY_VERSION", "RegistryError", "load", "save", "get", "put", "remove"] + +REGISTRY_VERSION = 1 + + +class RegistryError(Exception): + """注册表缺失字段或损坏。""" + + +def _empty(): + return {"version": REGISTRY_VERSION, "plugins": {}} + + +def load(base): + """读注册表;文件不存在返回空表,格式不对抛 :class:`RegistryError`。""" + file = paths.registry_file(base) + if not os.path.exists(file): + return _empty() + try: + with open(file, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + raise RegistryError(f"注册表损坏({file}): {exc}") from exc + if not isinstance(data, dict) or not isinstance(data.get("plugins"), dict): + raise RegistryError(f"注册表格式不对({file})") + return data + + +def save(base, data): + """原子写回注册表。""" + file = paths.registry_file(base) + os.makedirs(os.path.dirname(file), exist_ok=True) + handle_fd, tmp = tempfile.mkstemp( + dir=os.path.dirname(file), prefix=".registry-", suffix=".tmp" + ) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + os.replace(tmp, file) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def get(data, plugin_id): + """取一个注册项,不存在抛 KeyError。""" + entry = data["plugins"].get(plugin_id) + if entry is None: + raise KeyError(plugin_id) + return entry + + +def put(data, entry): + """写入/覆盖一个注册项(以 entry["id"] 为键)。""" + if "id" not in entry: + raise RegistryError("注册项缺少 id") + data["plugins"][entry["id"]] = entry + + +def remove(data, plugin_id): + """移除一个注册项,返回被移除的项(不存在返回 None)。""" + return data["plugins"].pop(plugin_id, None) diff --git a/src/svfs_client/sample.py b/src/svfs_client/sample.py new file mode 100644 index 0000000..4a87859 --- /dev/null +++ b/src/svfs_client/sample.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +"""桥调试台插件生成器(纯标准库)。 + +设置 → 「安装调试台插件」一键安装:现场造一个"完整前端"插件包——manifest + +HTML/JS/CSS/图标,把桥的全部能力做成可点击的调试面板(host.info / storage / +net / notify / title / 原生对话框),插件开发时用来验证 window.svfs 的实际 +行为。``build(out_db)`` 第一次生成 1.0.0;对同一个 out_db 再调会走与真实 +升级完全相同的流程(派生快照 + 增量打包),正好演示"快照即版本"与回滚。 + +前端通过宿主注入的 ``window.svfsReady``(Promise)拿桥;在客户端外打开时 +给出友好提示,不会白屏。 +""" + +import json +import os +import shutil +import tempfile + +from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.folder_packer import FolderPacker + +from . import PLUGIN_API_VERSION, installer + +__all__ = ["PLUGIN_ID", "write_source", "build"] + +PLUGIN_ID = "com.svfs.demo" + +#: 版本 → 说明(2.0.0 有可感知的变化,用来演示升级与回滚) +CHANGELOG = { + 1: "初始版本", + 2: "v2:新增主题色切换与变更记录", +} + + +def _manifest(version): + return { + "id": PLUGIN_ID, + "name": "SVFS 桥调试台", + "version": f"{version}.0.0", + "api_version": PLUGIN_API_VERSION, + "entry": "index.html", + "icon": "icon.svg", + "permissions": ["storage", "notify", "title", "net", "dialog"], + "description": "演示/调试客户端桥的全部能力(host.info / storage / net / notify / title / 原生对话框)。", + } + + +_ICON_SVG = """ + + + +""" + +_STYLE_CSS = """\ +:root { + --bg: #16181d; --panel: #1c1f26; --border: #2b3038; + --text: #e4e7ec; --muted: #9aa3b2; --accent: #5b9dff; +} +* { box-sizing: border-box; } +body { + margin: 0; padding: 28px; background: var(--bg); color: var(--text); + font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; +} +main { max-width: 640px; margin: 0 auto; } +header { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; } +h1 { font-size: 20px; margin: 0; } +#version { color: var(--muted); margin: 2px 0 0; font-size: 13px; } +.card { + background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; padding: 14px 16px; margin-bottom: 12px; +} +.card h2 { font-size: 13px; color: var(--muted); margin: 0 0 10px; font-weight: 600; } +dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; margin: 0; } +dt { color: var(--muted); } dd { margin: 0; font-family: Consolas, monospace; font-size: 12px; } +b { color: var(--accent); font-size: 16px; } +button { + background: #22262f; color: var(--text); border: 1px solid #3a4150; + border-radius: 6px; padding: 6px 14px; cursor: pointer; margin-right: 8px; +} +button:hover { border-color: var(--accent); color: var(--accent); } +.row { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; align-items: center; } +input, select, textarea { + background: #14171c; color: var(--text); border: 1px solid #3a4150; + border-radius: 6px; padding: 6px 8px; font-family: Consolas, monospace; font-size: 12px; +} +textarea { width: 100%; min-height: 54px; resize: vertical; } +pre { + background: #14171c; border: 1px solid var(--border); border-radius: 6px; + padding: 8px 10px; font-family: Consolas, monospace; font-size: 12px; + white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; min-height: 18px; +} +.note { color: var(--muted); font-size: 12px; } +#missing { border-color: #6b552a; color: #f5b544; } +::-webkit-scrollbar {{ width: 10px; height: 10px; }} +::-webkit-scrollbar-thumb {{ + background: #3a4150; border-radius: 5px; border: 2px solid var(--bg); +}} +::-webkit-scrollbar-thumb:hover {{ background: #6b7382; }} +::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {{ background: transparent; }} +""" + +_INDEX_TEMPLATE = """\ + + + + + +SVFS 桥调试台 + + + +
+
+ icon +
+

SVFS 桥调试台

+

正在连接宿主…

+
+
+ + + +
+

宿主信息(host.info,无需权限)

+
加载中…
+
+ +
+

存储(storage 权限,按插件隔离)

+

这个插件被打开了 … 次

+ + +
+ + + + + + +
+
(操作结果显示在这里)
+
+ +
+

接口服务(net 权限,经宿主请求服务器 API,自动附带登录 token)

+
+ + + +
+ +
未请求。相对 path 会拼在客户端配置的服务器地址后。
+
+ +
+

通知 / 标题(notify / title 权限)

+ + +
+ +
+

原生对话框(dialog 权限,宿主弹出,Promise 返回用户选择)

+
+ + + + +
+
(用户的选择显示在这里)
+

+
+
+ + + +""" + +_APP_JS_TEMPLATE = """\ +/* 桥调试台的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */ +"use strict"; + +var $ = function (id) { return document.getElementById(id); }; +var PLUGIN_VERSION = "__PLUGIN_VERSION__"; + +function randomColor() { + var letters = "0123456789abcdef", out = "#"; + for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)]; + return out; +} + +function show(id, text) { + $(id).textContent = text; + $(id).classList.remove("note"); +} + +async function main() { + if (!window.svfsReady) { + $("missing").hidden = false; + $("version").textContent = "插件 " + PLUGIN_VERSION + "(未连接宿主)"; + return; + } + var svfs = await window.svfsReady; + + var info = await svfs.host.info(); + $("version").textContent = "插件 " + PLUGIN_VERSION + " · 桥 API v" + info.apiVersion; + $("host-info").innerHTML = ""; + [["插件 id", info.pluginId], ["宿主", info.appName + " " + info.appVersion], + ["平台", info.platform], ["登录用户", info.authUser || "(未登录)"], + ["服务器", info.baseUrl || "(未配置)"]] + .forEach(function (row) { + var dt = document.createElement("dt"); dt.textContent = row[0]; + var dd = document.createElement("dd"); dd.textContent = row[1]; + $("host-info").append(dt, dd); + }); + + // ---- storage:按插件隔离的键值存储 ---- + var count = 0; + try { + count = parseInt(await svfs.storage.get("visits"), 10) || 0; + count += 1; + await svfs.storage.set("visits", count); + } catch (e) { /* 没拿到 storage 权限时静默降级 */ } + $("count").textContent = String(count); + $("reset").onclick = async function () { + await svfs.storage.set("visits", 0); + $("count").textContent = "0"; + svfs.ui.notify("计数已清零"); + }; + $("add10").onclick = async function () { + count += 10; + await svfs.storage.set("visits", count); + $("count").textContent = String(count); + }; + $("kv-get").onclick = async function () { + try { + show("storage-out", "storage.get(" + JSON.stringify($("kv-key").value) + + ") → " + JSON.stringify(await svfs.storage.get($("kv-key").value))); + } catch (e) { show("storage-out", "失败: " + e.message); } + }; + $("kv-set").onclick = async function () { + try { + var raw = $("kv-value").value; + var value; + try { value = JSON.parse(raw); } catch (e) { value = raw; } + await svfs.storage.set($("kv-key").value, value); + show("storage-out", "storage.set(" + JSON.stringify($("kv-key").value) + + ", " + JSON.stringify(value) + ") → 成功"); + } catch (e) { show("storage-out", "失败: " + e.message); } + }; + $("kv-del").onclick = async function () { + await svfs.storage.remove($("kv-key").value); + show("storage-out", "storage.remove(" + JSON.stringify($("kv-key").value) + ") → 成功"); + }; + $("kv-keys").onclick = async function () { + show("storage-out", "storage.keys() → " + JSON.stringify(await svfs.storage.keys())); + }; + + // ---- net:宿主代发 HTTP 请求(绕开 CORS,自动附带登录 token)---- + $("net-send").onclick = async function () { + if (!svfs.net) { + show("net-out", "这个宿主没有 net 能力(需要客户端支持桥 API v2+)"); + return; + } + show("net-out", "请求中…"); + try { + var request = { + method: $("net-method").value, + path: $("net-path").value, + }; + var body = $("net-body").value; + if (body && request.method !== "GET" && request.method !== "HEAD") { + request.body = body; + request.headers = { "Content-Type": "application/json" }; + } + var res = await svfs.net.request(request); + show("net-out", "HTTP " + res.status + "\\n" + (res.bodyText || "(空响应)").slice(0, 600)); + } catch (e) { + show("net-out", "请求失败: " + e.message + + "\\n(先在「设置 → 服务器地址」里配置地址,服务端需提供对应接口)"); + } + }; + + // ---- notify / title ---- + $("notify").onclick = function () { + svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")"); + }; + $("paint").onclick = function () { + svfs.ui.setTitle("SVFS Client · 调试台 " + randomColor()); + }; + + // ---- 原生对话框(dialog 权限):Promise 返回用户是否点了"确定" ---- + $("dlg-confirm").onclick = async function () { + var accepted = await svfs.ui.confirm({ + title: "确认操作", + text: "这是一条宿主原生确认框(QMessageBox.question)。", + detail: "点「确定」返回 true,点「取消」或关闭返回 false。", + }); + show("dialog-out", "ui.confirm → " + accepted); + }; + $("dlg-info").onclick = async function () { + await svfs.ui.message({ title: "提示", text: "这是一条 info 级原生消息框。" }); + show("dialog-out", "ui.message(info) → 已关闭"); + }; + $("dlg-warn").onclick = async function () { + await svfs.ui.message({ kind: "warn", title: "警告", text: "这是一条 warn 级原生消息框。" }); + show("dialog-out", "ui.message(warn) → 已关闭"); + }; + $("dlg-error").onclick = async function () { + await svfs.ui.message({ kind: "error", title: "错误", text: "这是一条 error 级原生消息框。" }); + show("dialog-out", "ui.message(error) → 已关闭"); + }; + + $("changelog").textContent = "__CHANGELOG__"; +} + +main(); +""" + + +def write_source(dest_dir, version=1): + """把示例插件源码树写到一个目录(升级对比/调试时也用得上)。""" + os.makedirs(dest_dir, exist_ok=True) + files = { + "plugin.json": json.dumps(_manifest(version), ensure_ascii=False, indent=2), + "index.html": _INDEX_TEMPLATE, + "style.css": _STYLE_CSS, + "icon.svg": _ICON_SVG, + "app.js": ( + _APP_JS_TEMPLATE.replace("__PLUGIN_VERSION__", f"{version}.0.0") + .replace("__CHANGELOG__", CHANGELOG.get(version, "")) + ), + } + for name, content in files.items(): + with open(os.path.join(dest_dir, name), "w", encoding="utf-8", newline="\n") as f: + f.write(content) + return dest_dir + + +def build(out_db, *, progress=None): + """生成示例插件包;out_db 已存在时按真实升级流程追加快照。 + + 版本自动决定:包不存在生成 1.0.0;已存在则升级到 2.0.0(再次生成则在 + 2.0.0 上追加 -2 快照……)。worker 的 ``install_sample`` 操作每次都调 + 这个(测试与体验用;界面入口已移除)。 + + 返回 (out_db, 版本号字符串)。 + """ + version = 2 if os.path.exists(out_db) else 1 + workdir = tempfile.mkdtemp(prefix="svfs-sample-") + # out_db 所在目录可能还不存在(首次生成时),建出来 + os.makedirs(os.path.dirname(os.path.abspath(out_db)), exist_ok=True) + try: + write_source(workdir, version=version) + if version == 1: + vfs = SQLiteVFS(out_db, compress=True) + try: + vfs.create_snapshot(f"{version}.0.0") + vfs.add_vfs_metadata("svfs-plugin:com.svfs.demo") + packer = FolderPacker(vfs) + packer.pack_folder(workdir, incremental=True, rehash=True, + progress=progress) + vfs.recount() + finally: + vfs.close() + return out_db, f"{version}.0.0" + # 已有包:走与 installer._upgrade 相同的"派生快照 + 增量"流程 + vfs = SQLiteVFS(out_db, compress=True) + try: + parent_id = vfs.get_stats()["snapshot_id"] + name = installer._unique_snapshot_name(vfs, f"{version}.0.0") + vfs.create_snapshot(name, parent_id=parent_id, copy_entries=True) + vfs.add_vfs_metadata("svfs-plugin:com.svfs.demo") + packer = FolderPacker(vfs) + packer.pack_folder(workdir, incremental=True, rehash=True, + progress=progress) + vfs.recount() + finally: + vfs.close() + return out_db, name + finally: + shutil.rmtree(workdir, ignore_errors=True) diff --git a/src/svfs_client/scheme.py b/src/svfs_client/scheme.py new file mode 100644 index 0000000..6d4e5ae --- /dev/null +++ b/src/svfs_client/scheme.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""``svfs://`` 自定义协议:把插件包内的文件直接喂给 WebView(Qt 层)。 + +URL 形态:``svfs://<插件id>/<包内路径>`` + +* **host = 插件 id** —— 每个插件一个独立 origin。浏览器按 origin 隔离 + localStorage / IndexedDB / cookie,插件之间互相读不到对方的数据; + 每个插件也因此是一个自成一体的前端(SPA 回退指向自己的 entry)。 +* 只服务注册表里**已启用**的插件;请求未安装/已禁用的 host 一律拒绝。 +* 包内路径只做数据库精确匹配,不与磁盘路径拼接,``..`` 越不出包。 +* SPA 回退与 contrib/fastapi_static 同一套启发式:404 且末段不像静态资源 + (没有扩展名)时回退到清单 entry。 + +必须在 QApplication 创建**之前**调用 :func:`register_scheme`。 +""" + +import os +import posixpath +import threading +from collections import OrderedDict + +from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject +from PySide6.QtWebEngineCore import ( + QWebEngineUrlScheme, + QWebEngineUrlSchemeHandler, + QWebEngineUrlRequestJob, +) + +from sqlite_vfs.mime import guess_mime + +__all__ = ["SCHEME_NAME", "register_scheme", "SvfsSchemeHandler", "looks_like_navigation"] + +SCHEME_NAME = "svfs" + + +def register_scheme(): + """注册 svfs://。只能在第一个 QApplication 之前调用一次。""" + scheme = QWebEngineUrlScheme(QByteArray(bytes(SCHEME_NAME, "ascii"))) + # SecureScheme:让插件页面成为安全上下文(localStorage、crypto 等可用); + # LocalAccessAllowed:允许页面经 WebChannel/qrc 与宿主交互; + # FetchApiAllowed:允许页面对本 origin 使用 fetch()(现代前端的基本操作)。 + scheme.setFlags( + QWebEngineUrlScheme.Flag.SecureScheme + | QWebEngineUrlScheme.Flag.LocalAccessAllowed + | QWebEngineUrlScheme.Flag.FetchApiAllowed + ) + scheme.setSyntax(QWebEngineUrlScheme.Syntax.Host) + QWebEngineUrlScheme.registerScheme(scheme) + + +def looks_like_navigation(path): + """末段没有扩展名的路径按"前端路由"对待(与 fastapi_static 同一启发式)。""" + last = path.rstrip("/").rsplit("/", 1)[-1] + return bool(last) and "." not in last + + +def plugin_url(plugin_id, path=""): + """拼一个 svfs:// URL。""" + return f"{SCHEME_NAME}://{plugin_id}/{path.lstrip('/')}" + + +class _PackageHandle: + """一个插件的只读包句柄 + 访问锁。 + + QWebEngineUrlSchemeHandler 的回调可能来自 WebEngine 的 IO 线程, + 而 sqlite3 连接不能并发使用,所以每个句柄配一把 RLock 串行读。 + """ + + def __init__(self, package_path, snapshot_id): + from sqlite_vfs.core import SQLiteVFS + + self.lock = threading.RLock() + # check_same_thread=False:回调可能来自不同线程,由上面的锁负责串行 + self.vfs = SQLiteVFS( + package_path, readonly=True, check_same_thread=False, + snapshot_id=snapshot_id, + ) + + def read(self, vfs_path): + """返回 (bytes, ok)。任何失败都归一成 (b"", False)。""" + try: + with self.lock: + return bytes(self.vfs.read_file(vfs_path)), True + except Exception: + return b"", False + + def close(self): + try: + with self.lock: + self.vfs.close() + except Exception: + pass + + +class SvfsSchemeHandler(QWebEngineUrlSchemeHandler): + """把 svfs:/// 解析成"从该插件的包里读这个文件"。 + + lookup 是一个可调用对象:``lookup(plugin_id) -> dict | None``, + 返回注册表项(含 package 路径、current_snapshot_id、entry),None 表示 + 未安装或已禁用。GUI 壳在安装/升级/回滚/卸载后调用 :meth:`invalidate` + 关闭缓存句柄(Windows 上不关就替换不了包文件)。 + """ + + #: 句柄缓存量上限。插件数量一般是个位数,装满就走 LRU。 + CACHE_LIMIT = 6 + + def __init__(self, lookup, parent=None): + super().__init__(parent) + self._lookup = lookup + self._lock = threading.Lock() + self._cache = OrderedDict() # (package_path, snapshot_id) -> _PackageHandle + + # ---- 句柄缓存 -------------------------------------------------- + + def _handle_for(self, entry): + key = (entry["package_path"], entry["current_snapshot_id"]) + with self._lock: + handle = self._cache.get(key) + if handle is None: + handle = _PackageHandle(entry["package_path"], + entry["current_snapshot_id"]) + self._cache[key] = handle + while len(self._cache) > self.CACHE_LIMIT: + _, evicted = self._cache.popitem(last=False) + evicted.close() + else: + self._cache.move_to_end(key) + return handle + + def invalidate(self, plugin_id=None): + """关闭缓存的句柄。plugin_id 给 None 时全部关闭(卸载/批量刷新)。""" + with self._lock: + stale = [ + key for key, handle in self._cache.items() + if plugin_id is None + # 包路径是本地路径(Windows 上是反斜杠),用 os.path 取插件 id + or os.path.basename(os.path.dirname(key[0])) == plugin_id + ] + for key in stale: + self._cache.pop(key).close() + + def close(self): + self.invalidate() + + # ---- 请求处理 -------------------------------------------------- + + def requestStarted(self, request): # noqa: N802 - Qt 命名 + url = request.requestUrl() + plugin_id = url.host() + if not plugin_id: + request.fail(QWebEngineUrlRequestJob.Error.UrlNotFound) + return + + # 隔离:只允许"同源发起"的请求(首个导航没有 initiator)。 + # 插件 A 的页面 fetch svfs://B/… 会在这里被拒。 + initiator = request.initiator() + if initiator.isValid() and initiator.host() and initiator.host() != plugin_id: + request.fail(QWebEngineUrlRequestJob.Error.RequestDenied) + return + + entry = self._lookup(plugin_id) + if entry is None: + request.fail(QWebEngineUrlRequestJob.Error.RequestDenied) + return + + rel = self._normalize(url.path()) + if rel is None: + request.fail(QWebEngineUrlRequestJob.Error.UrlNotFound) + return + + handle = self._handle_for(entry) + entry_path = entry.get("entry") or "index.html" + data, mime = self._resolve(handle, rel, entry_path) + if data is None: + request.fail(QWebEngineUrlRequestJob.Error.UrlNotFound) + return + request.reply(QByteArray(bytes(mime, "ascii")), self._buffer(data)) + + @staticmethod + def _resolve(handle, rel, entry_path): + """包内路径 → (bytes, mime)。404 时按"目录索引 → SPA 入口"次序回退, + 都落空返回 (None, None)。""" + data, ok = handle.read(rel or entry_path) + if ok: + return data, guess_mime(rel or entry_path) + + candidates = [] + if rel and (rel.endswith("/") or SvfsSchemeHandler._is_directory(handle, rel)): + candidates.append((rel.rstrip("/") + "/index.html", None)) + if rel == "" or looks_like_navigation(rel): + # SPA 回退:交给这个插件自己的入口 + candidates.append((entry_path, "text/html")) + for candidate, forced_mime in candidates: + data, ok = handle.read(candidate) + if ok: + return data, forced_mime or guess_mime(candidate) + return None, None + + @staticmethod + def _is_directory(handle, rel): + try: + with handle.lock: + return handle.vfs.is_dir(rel.rstrip("/")) + except Exception: + return False + + @staticmethod + def _normalize(url_path): + """URL path → 包内路径。残留 ".." 段一律拒绝;绝对路径的 + 顶层穿越(/../x)会被 normpath 收敛到根下,落回包内同样安全。""" + path = posixpath.normpath(url_path or "/") + if ".." in path.split("/"): + return None + return path.strip("/") + + def _buffer(self, data): + # 挂在 handler 上保活(Qt 官方示例同款做法),请求结束后由 Qt 回收 + buffer = QBuffer(parent=self) + buffer.setData(QByteArray(data)) + buffer.open(QIODevice.OpenModeFlag.ReadOnly) + return buffer + + +def make_lookup(base, registry_data=None): + """造一个 scheme handler 用的 lookup 闭包(纯 stdlib 部分,方便测试)。 + + 每次调用现读注册表——协议层请求频率低(静态资源由浏览器缓存),而 + 注册表很小,这样"安装/禁用立即生效"不需要任何失效协议。 + """ + from . import paths, registry as registry_mod + + def lookup(plugin_id): + try: + data = registry_data if registry_data is not None else registry_mod.load(base) + except Exception: + return None + entry = data["plugins"].get(plugin_id) + if not entry or not entry.get("enabled", False): + return None + return { + "package_path": paths.plugin_package(base, plugin_id), + "current_snapshot_id": entry.get("current_snapshot_id"), + "entry": entry.get("entry"), + } + + return lookup diff --git a/src/svfs_client/search.py b/src/svfs_client/search.py new file mode 100644 index 0000000..cc0c420 --- /dev/null +++ b/src/svfs_client/search.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""插件搜索的模糊匹配(纯标准库,可脱离 Qt 测试)。 + +侧栏搜索框的匹配规则,按分数从高到低: + +1. **精确包含**(忽略大小写):位置越靠前分越高("clk" 命中 "clock"); +2. **首字母缩写**:查询词的每个字符按序出现在候选中("sv" 命中 "SVFS 桥调试台", + "scd" 命中 "com.svfs.demo"); +3. 不匹配返回 ``None``。 + +候选文本可以给多个(显示名 + 插件 id),取最高分——用户既可能按名字搜, +也可能按 id 搜。 +""" + +__all__ = ["score", "matches"] + +#: 包含匹配的基准分(保证任何包含匹配都高于缩写匹配) +_CONTAINS_BASE = 100.0 +#: 缩写匹配的基准分 +_ACRONYM_BASE = 50.0 + + +def score(query, *candidates): + """查询词对候选文本的匹配分;不匹配返回 None。 + + 空查询视为"全部匹配"(返回 0 分,调用方据此保留全部条目)。 + """ + query = (query or "").strip().lower() + if not query: + return 0.0 + best = None + for candidate in candidates: + text = (candidate or "").lower() + if not text: + continue + value = _score_one(query, text) + if value is not None and (best is None or value > best): + best = value + return best + + +def matches(query, *candidates): + """便捷判断:查询词是否命中任一候选。""" + return score(query, *candidates) is not None + + +def _score_one(query, text): + """单个候选文本的打分。""" + position = text.find(query) + if position >= 0: + # 包含匹配:越靠前越好;同时是完整词/开头时再加一点 + value = _CONTAINS_BASE - min(position, 40) * 0.5 + if position == 0: + value += 5.0 + return value + + # 缩写匹配:查询词的字符按序出现(允许中间隔字符) + text_index = 0 + gaps = 0 + for char in query: + found = text.find(char, text_index) + if found < 0: + return None + gaps += found - text_index + text_index = found + 1 + # 间隔越小越好;长查询给一点加成(长缩写匹配上更难、更可信) + return _ACRONYM_BASE - min(gaps, 40) * 0.4 + min(len(query), 6) * 0.5 diff --git a/src/svfs_client/settings.py b/src/svfs_client/settings.py new file mode 100644 index 0000000..2f05a4c --- /dev/null +++ b/src/svfs_client/settings.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""客户端设置(纯标准库):``settings.json``。 + +配置项: + +* ``base_url`` —— 服务端地址。一个服务器同时承担两件事: + 1. **插件下载**:发布端把每个插件的 publish 产物(manifest.json + blobs/) + 同步到 ``/<插件id>/`` 下,客户端安装时只填插件 id 或相对路径 + (:func:`resolve_manifest_url` 负责拼出完整地址); + 2. **接口服务**:插件前端经桥的 ``svfs.net.request({path: "/api/…"})`` + 访问这个服务器的 API(宿主代为发请求,插件不用操心 CORS)。 +* ``auth_token`` —— **老字段,已废弃**(只为兼容旧文件保留,读写一律为空)。 + 登录令牌现在是能冒充用户的东西,改由 credentials.py 加密存储 + (Windows DPAPI / keyring),启动时 credentials.migrate 会把旧值搬过去。 +* ``auth_user`` —— 最近一次登录的用户名(登录界面用来预填)。 +* ``startup_plugin`` —— 启动直显的插件 id(空 = 默认规则:上次打开的 > + 第一个启用的插件;见 shell.MainWindow._resolve_startup_plugin)。 +* ``cache_dir`` —— 对象缓存目录(空 = 系统默认;安装插件时现读)。 +* ``net_timeout`` —— 插件 ``net`` 请求的超时秒数(0 或负数 = 用默认值)。 +* ``net_retries`` —— 幂等请求(GET/HEAD)失败后的重试次数上限。 +* ``proxy`` —— HTTP 代理地址(空 = 跟随系统代理;loopback 目标永远直连)。 +* ``ca_bundle`` —— 额外的 CA 证书文件(自签/内网 HTTPS 用;空 = 系统信任库)。 +* ``log_to_file`` —— 诊断日志是否落盘(滚动文件,见 diagnostics.py)。 + +设置文件很小、读一次毫秒级,允许在主线程同步读写(与 QSettings 同一档), +写仍然是原子的(临时文件 + ``os.replace``)。 +""" + +import json +import os +import tempfile + +from . import paths + +__all__ = [ + "DEFAULTS", + "NET_TIMEOUT_DEFAULT", + "NET_RETRIES_DEFAULT", + "load", + "save", + "normalize_base_url", + "resolve_manifest_url", + "resolve_net_url", + "resolve_cache_dir", + "net_options", + "proxy_config", + "log_to_file", + "apply_runtime", +] + +#: 插件 net 请求的默认超时(秒)与默认重试次数(见 bridge.NET_TIMEOUT) +NET_TIMEOUT_DEFAULT = 30 +NET_RETRIES_DEFAULT = 1 + +DEFAULTS = { + "base_url": "", + "auth_token": "", + "auth_user": "", + # 启动直显的插件 id(空 = 默认规则:上次打开的 > 第一个启用的插件) + "startup_plugin": "", + # 对象缓存目录(空 = 系统默认;安装时即时生效) + "cache_dir": "", + # 网络行为(见 net_options/proxy_config) + "net_timeout": NET_TIMEOUT_DEFAULT, + "net_retries": NET_RETRIES_DEFAULT, + "proxy": "", + "ca_bundle": "", + # 诊断日志落盘 + "log_to_file": True, +} + +#: 相对地址能接受的形态:插件 id(com.example.clock)或带斜杠的相对路径 +_MANIFEST_SUFFIX = "manifest.json" + + +def load(base): + """读设置;缺文件或字段缺失都用默认值补齐。坏文件视为未配置(不抛异常—— + 设置坏了不该让客户端起不来)。""" + file = paths.settings_file(base) + data = dict(DEFAULTS) + try: + with open(file, "r", encoding="utf-8") as handle: + stored = json.load(handle) + except (OSError, ValueError): + return data + if isinstance(stored, dict): + for key, default in DEFAULTS.items(): + data[key] = stored.get(key, default) + return data + + +def save(base, data): + """原子写回设置。""" + file = paths.settings_file(base) + os.makedirs(os.path.dirname(file), exist_ok=True) + handle_fd, tmp = tempfile.mkstemp( + dir=os.path.dirname(file), prefix=".settings-", suffix=".tmp" + ) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2) + os.replace(tmp, file) + except BaseException: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + +def normalize_base_url(url): + """规范化服务端地址:去尾部斜杠、校验 scheme。空值表示未配置。 + + 返回不带尾部斜杠的地址(如 ``http://192.168.1.10:8000``), + 非法输入抛 ValueError(信息可直接展示)。 + """ + if url is None: + return "" + url = str(url).strip() + if not url: + return "" + if not (url.startswith("http://") or url.startswith("https://")): + raise ValueError(f"服务端地址必须是 http(s):// 开头: {url}") + return url.rstrip("/") + + +def _base(base_url): + base = normalize_base_url(base_url) + if not base: + raise ValueError( + "尚未配置服务器地址:请先在「插件管理 → 服务器」里填写 base_url" + ) + return base + + +def resolve_manifest_url(base_url, source): + """把用户在安装框里填的内容解析成 manifest 完整地址。 + + 三种输入: + + * 完整 URL(http(s):// 开头)→ 原样使用; + * 插件 id(如 ``com.example.clock``)→ ``//manifest.json`` + (约定:发布端把 publish 产物同步到服务器的 ``/`` 子目录下); + * 相对路径(如 ``clock/stable/manifest.json``)→ 拼在 base_url 后。 + + 未配置 base_url 时,后两种抛 ValueError。 + """ + source = (source or "").strip() + if not source: + raise ValueError("请填写插件 id、相对路径或完整的 manifest 地址") + if source.startswith(("http://", "https://")): + return source + base = _base(base_url) + if "/" not in source: + return f"{base}/{source}/{_MANIFEST_SUFFIX}" + return f"{base}/{source.lstrip('/')}" + + +def resolve_net_url(base_url, url=None, path=None): + """把桥 net 请求的 url/path 解析成绝对地址。 + + ``url`` 必须是完整 http(s) 地址;``path`` 是相对 base_url 的路径 + (两者都不给、或给了 path 但未配置 base_url,都抛 ValueError)。 + """ + url = (url or "").strip() + path = (path or "").strip() + if url: + if not url.startswith(("http://", "https://")): + raise ValueError(f"net 请求的 url 必须是 http(s):// 开头: {url}") + return url + if path: + base = _base(base_url) + return f"{base}/{path.lstrip('/')}" + raise ValueError("net 请求需要 url(完整地址)或 path(相对服务器地址的路径)") + + +def resolve_cache_dir(base): + """生效的对象缓存目录:settings 里的 ``cache_dir`` 优先,未配置用系统默认。 + + 支持以 ~ 开头的路径;返回绝对路径(安装时现读,改了即时生效)。 + """ + raw = str(load(base).get("cache_dir") or "").strip() + if raw: + return os.path.abspath(os.path.expanduser(raw)) + return paths.object_cache_dir() + + +def _positive_int(value, default, *, allow_zero=True): + """配置里的数字:非法/缺失一律落回默认值(坏配置不该让网络不通)。""" + try: + number = int(value) + except (TypeError, ValueError): + return default + if number < 0 or (number == 0 and not allow_zero): + return default + return number + + +def net_options(base): + """插件 net 请求的超时与重试次数(``(timeout, retries)``)。 + + 超时给 0 = 用默认值(不允许"永不超时",那会把工作线程钉死); + 重试给 0 = 不重试(合成一个明确的开关,避免"负数重试"这种怪配置)。 + """ + data = load(base) + timeout = _positive_int(data.get("net_timeout"), NET_TIMEOUT_DEFAULT, + allow_zero=False) + retries = _positive_int(data.get("net_retries"), NET_RETRIES_DEFAULT) + return timeout, retries + + +def proxy_config(base): + """``(proxy, ca_bundle)``:代理地址与额外 CA 证书(都为空 = 跟随系统)。""" + data = load(base) + proxy = str(data.get("proxy") or "").strip() + if proxy and "://" not in proxy: + # 允许只填 host:port(对内网代理是常见写法) + proxy = f"http://{proxy}" + ca_bundle = str(data.get("ca_bundle") or "").strip() + return proxy, ca_bundle + + +def log_to_file(base): + """诊断日志是否落盘(默认开:崩了还能回看,见 diagnostics.configure)。""" + return bool(load(base).get("log_to_file", True)) + + +def apply_runtime(base): + """把设置应用到运行时:日志落盘 + 代理/CA(返回错误信息,None = 成功)。 + + 启动时**必须在登录之前**调用一次——启动动画里的静默校验就会发 HTTPS + 请求,代理与自签证书那时就得生效;设置对话框保存网络项后也要再调一次。 + 只在函数内部延迟导入 diagnostics/netutil:本模块是纯配置层,不应该在 + 导入期就把网络与日志模块拉进来。 + """ + from . import diagnostics, netutil + + diagnostics.configure( + paths.logs_dir(base) if log_to_file(base) else None) + error = netutil.apply_settings(base) + if error: + diagnostics.log(f"网络设置未生效: {error}", "startup") + return error diff --git a/src/svfs_client/shell.py b/src/svfs_client/shell.py new file mode 100644 index 0000000..f18b5ff --- /dev/null +++ b/src/svfs_client/shell.py @@ -0,0 +1,1398 @@ +#!/usr/bin/env python3 +"""主窗口:侧栏(插件列表)+ 页面栈(插件管理页 / 每插件一个常驻 WebView)。 + +数据流与 svfs_studio 相同的单向流:UI 提交 op → 工作线程执行 → 返回完整 +payload → :meth:`MainWindow.apply_payload` 把 payload 扇给侧栏与管理页。 +协议层的包句柄缓存在每次变更后统一失效(Windows 上不关句柄就换不了包文件)。 + +卡片控件只在创建时连接一次信号(连接到 ManagerPage 的页面级信号), +刷新时只填数据不重连——否则一次刷新叠一层连接,点一次按钮触发 N 次。 +""" + +import os + +from PySide6.QtCore import ( + QEasingCurve, + QParallelAnimationGroup, + QPropertyAnimation, + QSize, + QSettings, + Qt, + QThread, + QTimer, + QUrl, + Signal, +) +from PySide6.QtGui import ( + QColor, + QDesktopServices, + QFont, + QIcon, + QPainter, + QPixmap, +) +from PySide6.QtWidgets import ( + QBoxLayout, + QCheckBox, + QComboBox, + QFileDialog, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + QProgressBar, + QPushButton, + QScrollArea, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from svfs_studio import format as fmt +from svfs_studio import theme +from svfs_studio.widgets.cards import ( + Badge, + Banner, + Card, + ElidedLabel, + EmptyState, + SectionTitle, + hbox, + vbox, +) + +from . import (APP_NAME, credentials, diagnostics, paths, sample, settings, + search as search_mod, __version__) +from .app import _logo_pixmap +from .dialogs import SettingsDialog, UserCenterDialog, avatar_pixmap +from .installer import is_newer_version +from .scheme import SvfsSchemeHandler, make_lookup, plugin_url +from .webview import PluginView, QWebEngineView, create_profile + +__all__ = ["MainWindow", "ManagerPage", "PluginCard", "_plugin_icon"] + +SIDEBAR_WIDTH = 232 +#: 侧栏收起后的窄条宽度(图标栏 + 头像/设置入口) +RAIL_WIDTH = 56 +#: 图标栏每个格子的宽度(略小于窄条,留出滚动条/边距) +SIDEBAR_RAIL_GRID = 44 +#: 侧栏伸缩动画时长(毫秒) +SIDEBAR_ANIM_MS = 160 + + +def _ui_settings(): + """窗口状态(几何 / 侧栏收缩)的 QSettings。 + + 显式指定 IniFormat:``QSettings(org, app)`` 双参构造在 Windows 上固定走 + NativeFormat(注册表),``setDefaultFormat`` 管不到它;显式声明才能让 + 测试把存储重定向到临时目录。 + """ + return QSettings( + QSettings.Format.IniFormat, QSettings.Scope.UserScope, + "svfs-client", "svfs-client", + ) + + +def _plugin_icon(icon_bytes, plugin_id): + """包内图标 → QIcon;读不到就画一个首字母色块,界面永远有图标可看。""" + if icon_bytes: + pixmap = QPixmap() + if pixmap.loadFromData(bytes(icon_bytes)): + return QIcon(pixmap) + pixmap = QPixmap(32, 32) + pixmap.fill(theme.qcolor(theme.PANEL)) + painter = QPainter(pixmap) + painter.setPen(QColor(theme.ACCENT)) + font = QFont() + font.setPixelSize(16) + font.setBold(True) + painter.setFont(font) + initial = (plugin_id.rsplit(".", 1)[-1] or "?")[:1].upper() + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, initial) + painter.end() + return QIcon(pixmap) + + +class PluginCard(Card): + """管理页里的一张插件卡片。只负责展示;动作经 ManagerPage 的信号上报。""" + + def __init__(self, entry, parent=None): + super().__init__(parent, padding=theme.PAD, spacing=6) + self.entry = entry + + # ---- 第一行:图标 + 名称 + 徽章 + 打开 ---- + head = hbox(spacing=8) + self.icon_label = QLabel() + self.icon_label.setFixedSize(34, 34) + self.icon_label.setScaledContents(True) + head.addWidget(self.icon_label) + + name_box = vbox(spacing=1) + self.name_label = QLabel(entry.get("name") or entry["id"]) + theme.role(self.name_label, "h2") + name_box.addWidget(self.name_label) + self.origin_label = ElidedLabel(plugin_url(entry["id"])) + self.origin_label.setProperty("role", "faint") + name_box.addWidget(self.origin_label) + head.addLayout(name_box, 1) + + self.version_badge = Badge(entry.get("version") or "?", "accent") + self.state_badge = Badge("已启用", "ok") + self.update_badge = Badge("", "warn") + self.update_badge.hide() + head.addWidget(self.version_badge) + head.addWidget(self.state_badge) + head.addWidget(self.update_badge) + + self.open_button = QPushButton("打开") + self.open_button.setObjectName("Primary") + head.addWidget(self.open_button) + self.add_layout(head) + + # ---- 第二行:元信息 ---- + meta = hbox(spacing=10) + self.meta_label = ElidedLabel("") + theme.role(self.meta_label, "muted") + meta.addWidget(self.meta_label, 1) + self.add_layout(meta) + + # ---- 第三行:操作 ---- + actions = hbox(spacing=6) + self.enabled_check = QCheckBox("启用") + actions.addWidget(self.enabled_check) + actions.addSpacing(6) + actions.addWidget(QLabel("版本(切换即回滚)")) + self.version_combo = QComboBox() + self.version_combo.setMinimumWidth(170) + actions.addWidget(self.version_combo) + actions.addStretch(1) + self.upgrade_button = QPushButton() + self.upgrade_button.setObjectName("Primary") + self.upgrade_button.hide() + actions.addWidget(self.upgrade_button) + self.verify_button = QPushButton("校验") + self.uninstall_button = QPushButton("卸载") + self.uninstall_button.setObjectName("Danger") + actions.addWidget(self.verify_button) + actions.addWidget(self.uninstall_button) + self.add_layout(actions) + + self.fill() + + def fill(self): + """按当前 entry 刷新显示(不连接任何信号)。""" + entry = self.entry + self.version_badge.set_state("accent", entry.get("version") or "?") + enabled = entry.get("enabled", False) + self.state_badge.set_state("ok" if enabled else "muted", + "已启用" if enabled else "已禁用") + self.open_button.setEnabled(enabled) + self.origin_label.set_full_text(plugin_url(entry["id"])) + self._refresh_update() + + meta_bits = [f"origin: {plugin_url(entry['id'])}"] + if entry.get("package_size") is not None: + meta_bits.append(f"包大小 {fmt.human_size(entry['package_size'])}") + source = entry.get("source") or {} + if source.get("detail"): + meta_bits.append(f"来自 {source['detail']}") + hosts = entry.get("net_hosts") or [] + if hosts: + meta_bits.append(f"net 白名单: {', '.join(hosts)}") + self.meta_label.set_full_text(" · ".join(meta_bits)) + + self.enabled_check.blockSignals(True) + self.enabled_check.setChecked(enabled) + self.enabled_check.blockSignals(False) + + self.version_combo.blockSignals(True) + self.version_combo.clear() + current = entry.get("current_snapshot_id") + current_index = -1 + for index, snapshot in enumerate(entry.get("snapshots") or []): + label = f"{snapshot.get('name') or 'snapshot'} (#{snapshot['id']})" + self.version_combo.addItem(label, snapshot["id"]) + if snapshot["id"] == current: + current_index = index + if current_index >= 0: + self.version_combo.setCurrentIndex(current_index) + self.version_combo.setEnabled(len(entry.get("snapshots") or []) > 1) + self.version_combo.blockSignals(False) + + def set_update(self, remote_version): + """标记服务器上有更新版本(None / 不比当前新 → 隐藏)。""" + self._remote_version = remote_version + self._refresh_update() + + def _refresh_update(self): + """可升级标记只在远端版本确实比当前新时显示(升级后自动消失)。""" + remote = getattr(self, "_remote_version", None) + if remote and is_newer_version(remote, self.entry.get("version") or ""): + self.update_badge.set_state("warn", f"可升级 → {remote}") + self.update_badge.show() + self.upgrade_button.setText(f"升级到 {remote}") + self.upgrade_button.show() + else: + self.update_badge.hide() + self.upgrade_button.hide() + + +class ManagerPage(QWidget): + """插件管理页:服务器配置 + 安装入口 + 已安装插件卡片列表。 + + 卡片在创建时被连线到这里的页面级信号,之后每次刷新只更新数据。 + """ + + openRequested = Signal(str) + uninstallRequested = Signal(str) + verifyRequested = Signal(str) + enabledRequested = Signal(str, bool) + versionRequested = Signal(str, int) + serverEditRequested = Signal() + upgradeRequested = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + outer = vbox(self, 0, 0) + + header = hbox(margin=12, spacing=8) + self.title = SectionTitle( + "插件管理", + "每个插件是一个完整的 .svfs 前端包;升级即新快照,切换版本即回滚", + ) + header.addWidget(self.title, 1) + self.check_updates_button = QPushButton("检查更新") + header.addWidget(self.check_updates_button) + self.install_url_button = QPushButton("从服务器安装…") + self.install_button = QPushButton("安装本地插件…") + self.install_button.setObjectName("Primary") + header.addWidget(self.install_url_button) + header.addWidget(self.install_button) + outer.addLayout(header) + + self.banner = Banner() + self.banner.hide() + outer.addWidget(self.banner) + + # ---- 服务器卡片(插件下载 + 接口服务的 base_url + 登录状态)---- + self.server_card = Card(padding=theme.PAD, spacing=6) + server_head = hbox(spacing=8) + self.server_title = SectionTitle("服务器", "插件下载与接口服务的地址") + server_head.addWidget(self.server_title, 1) + self.server_dot = QLabel() + self.server_dot.setFixedSize(9, 9) + self.server_dot.setStyleSheet( + f"background: {theme.TEXT_FAINT}; border-radius: 4px;") + self.server_dot.setToolTip("服务器连通状态") + server_head.addWidget(self.server_dot) + self.server_status_label = QLabel("") + theme.role(self.server_status_label, "muted") + server_head.addWidget(self.server_status_label) + self.server_login_badge = Badge("未登录") + server_head.addWidget(self.server_login_badge) + self.server_logout_button = QPushButton("退出登录") + self.server_logout_button.setObjectName("Ghost") + self.server_logout_button.setVisible(False) + server_head.addWidget(self.server_logout_button) + self.server_edit_button = QPushButton("设置服务器…") + server_head.addWidget(self.server_edit_button) + self.server_login_button = QPushButton("登录…") + server_head.addWidget(self.server_login_button) + self.server_card.add_layout(server_head) + self.server_url_label = ElidedLabel("未配置") + self.server_url_label.setProperty("role", "mono") + self.server_card.add(self.server_url_label) + outer.addWidget(self.server_card) + + body = QWidget() + self.cards_box = vbox(body, 12, 8) + self.empty = EmptyState( + "还没有安装任何插件", + "一个插件就是一个完整的 .svfs 前端包,互相隔离。可以安装本地 .svfs," + "或先在「设置 → 服务器地址」里连上服务器,从服务器安装。", + ) + self.cards_box.addWidget(self.empty) + self.cards_box.addStretch(1) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(body) + outer.addWidget(scroll, 1) + + self.cards = {} + self._updates = {} # 插件id → 服务器上的新版本(检查更新结果) + self._banner_action = None # 当前横幅动作按钮要做的事(见 show_message) + self.banner.action.connect(self._on_banner_action) + + # ---- 数据 ---- + + def set_plugins(self, entries): + wanted = {entry["id"] for entry in entries} + for gone in set(self.cards) - wanted: + card = self.cards.pop(gone) + card.setParent(None) + card.deleteLater() + for entry in entries: + card = self.cards.get(entry["id"]) + if card is None: + card = PluginCard(entry) + self.cards[entry["id"]] = card + self._wire_card(card) + self.cards_box.insertWidget(self.cards_box.count() - 2, card) + else: + card.entry = entry + card.fill() + card.set_update(self._updates.get(entry["id"])) + self.empty.setVisible(not entries) + + def set_base_url(self, url): + """刷新服务器地址的显示(空串 = 未配置)。""" + if url: + self.server_url_label.set_full_text(url) + self.server_url_label.setToolTip(url) + self.server_title.set_subtitle("插件下载与接口服务的地址") + else: + self.server_url_label.set_full_text( + "未配置 —— 填好才能用「从服务器安装」的相对地址,插件才能调 net 接口" + ) + self.server_url_label.setToolTip("") + + def set_session(self, session): + """刷新登录状态的显示(session: {"username": …} 或 None)。 + + ``invalid`` 标记(会话中途失效,见 MainWindow._on_session_rejected) + 显示成"登录已失效"而不是"未登录":凭据还在,只是服务端不认了, + 用户该做的是重新登录,不是重新填服务器地址。 + """ + username = (session or {}).get("username") or "" + invalid = bool((session or {}).get("invalid")) + if username and invalid: + self.server_login_badge.set_state("warn", f"登录已失效 · {username}") + elif username: + self.server_login_badge.set_state("ok", f"已登录 · {username}") + else: + self.server_login_badge.set_state("muted", "未登录") + self.server_login_button.setText("重新登录…" if username else "登录…") + self.server_logout_button.setVisible(bool(username)) + + def _wire_card(self, card): + """创建时连接一次;pid 在闭包里固化,刷新不重连。""" + pid = card.entry["id"] + card.open_button.clicked.connect(lambda: self.openRequested.emit(pid)) + card.enabled_check.toggled.connect( + lambda checked: self.enabledRequested.emit(pid, checked) + ) + card.verify_button.clicked.connect(lambda: self.verifyRequested.emit(pid)) + card.uninstall_button.clicked.connect(lambda: self.uninstallRequested.emit(pid)) + card.upgrade_button.clicked.connect( + lambda: self.upgradeRequested.emit(pid)) + card.version_combo.currentIndexChanged.connect(self._on_version_combo) + + def _on_version_combo(self): + combo = self.sender() + for pid, card in self.cards.items(): + if card.version_combo is combo: + snapshot_id = combo.currentData() + if snapshot_id is not None: + self.versionRequested.emit(pid, int(snapshot_id)) + return + + def show_message(self, state, title, detail="", action_text=None, + action=None): + """显示横幅。 + + ``action_text`` 给出时横幅右侧出现动作按钮;``action`` 是它被点击 + 时调用的 callable(登录失效 → 重新登录;客户端有新版 → 打开下载页)。 + 每次显示都重设,避免上一次的动作挂在下一次的消息上。 + """ + self._banner_action = action if callable(action) else None + self.banner.show_message(state, title, detail, + action_text if self._banner_action else None) + + def _on_banner_action(self): + action = self._banner_action + if callable(action): + action() + + def clear_banner(self): + self.banner.hide() + + def set_updates(self, updates): + """应用「检查更新」的结果(插件id → 远端新版本),刷新所有卡片。""" + self._updates = dict(updates or {}) + for plugin_id, card in self.cards.items(): + card.set_update(self._updates.get(plugin_id)) + + def set_server_status(self, state, text="", tooltip=""): + """服务器连通状态灯:state = ok / warn / err / muted。""" + colors = { + "ok": theme.OK, "warn": theme.WARN, + "err": theme.ERR, "muted": theme.TEXT_FAINT, + } + color = colors.get(state, theme.TEXT_FAINT) + self.server_dot.setStyleSheet( + f"background: {color}; border-radius: 4px;") + self.server_dot.setToolTip(tooltip or text or "服务器连通状态") + self.server_status_label.setText(text) + + +class MainWindow(QMainWindow): + def __init__(self, base, parent=None, session=None): + super().__init__(parent) + self.base = base + # 日志落盘 + 代理/CA 就位(app.main 已经调过;这里再调一次是为了 + # 让"直接构造 MainWindow"的入口同样自洽——幂等,代价可忽略) + settings.apply_runtime(base) + self.setWindowTitle(APP_NAME) + self.resize(1080, 720) + # 登录会话(StartupFlow / 登录窗产出):桥经 session_provider + # 现读,登录状态变化对已打开的插件页面立即生效 + self._session = dict(session) if session else None + self._relogin_prompted = False # 登录失效提示只发一次(见 _on_session_rejected) + self._client_update = {} # 最近一次客户端更新检查结果 + self._tray = None # 系统托盘(有才建,见 _setup_tray) + self._sidebar_collapsed = False + + # 工作线程(与 studio 同款:服务整体搬到专用线程) + self._thread = QThread(self) + self._thread.setObjectName("svfs-client-worker") + from .worker import Client + + self.client = Client(base, parent=self) + self.client.attach_to(self._thread) + + # 协议层:lookup 每次现读注册表(禁用/回滚立即生效), + # 包句柄缓存在变更后手动失效 + self.handler = SvfsSchemeHandler(make_lookup(base), parent=self) + # Profile 提前建 + 隐藏预热视图:Chromium 的渲染进程在启动阶段 + # (窗口 show 之前)就拉起来,首次打开插件时不会再出现整窗消失 + # 重建式闪烁或长时间空白 + self.profile = create_profile(self.base, handler=self.handler) + self._warmup = QWebEngineView(self) + self._warmup.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True) + self._warmup.load(QUrl("about:blank")) + self._views = {} # plugin_id -> PluginView + self._plugins = [] + self._sidebar_sources = [] # 已启用插件(侧栏与搜索的原始清单) + self._pending_open = None # --open 的启动意图(apply_payload 消费) + self._startup_resolved = False # 启动直显只处理一次 + + self._build_ui() + self._restore_state() + self.refresh() + + # ------------------------------------------------------------------ + # UI 构建 + # ------------------------------------------------------------------ + + def _build_ui(self): + central = QWidget() + layout = QHBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ---- 侧栏 ---- + self.sidebar = sidebar = QWidget() + sidebar.setObjectName("Sidebar") + sidebar.setFixedWidth(SIDEBAR_WIDTH) + side_box = vbox(sidebar, 12, 8) + + top_row = hbox(spacing=6) + self.collapse_button = QPushButton("«") + self.collapse_button.setObjectName("Ghost") + self.collapse_button.setFixedSize(26, 26) + self.collapse_button.setToolTip("收起侧栏") + top_row.addWidget(self.collapse_button) + title = QLabel(APP_NAME) + theme.role(title, "h2") + self._sidebar_title = title + top_row.addWidget(title, 1) + side_box.addLayout(top_row) + subtitle = QLabel("插件化客户端") + theme.role(subtitle, "faint") + self._sidebar_subtitle = subtitle + side_box.addWidget(subtitle) + + # 搜索框:模糊匹配插件名与 id(纯层 search 模块打分) + self.search_edit = QLineEdit() + self.search_edit.setPlaceholderText("搜索插件…") + self.search_edit.setClearButtonEnabled(True) + self.search_edit.setFixedHeight(26) + side_box.addWidget(self.search_edit) + self.search_hint = QLabel("没有匹配的插件") + theme.role(self.search_hint, "faint") + self.search_hint.hide() + side_box.addWidget(self.search_hint) + + self.plugin_list = QListWidget() + self.plugin_list.setObjectName("PluginList") + self.plugin_list.setIconSize(QSize(22, 22)) + self.plugin_list.setFrameShape(QListWidget.Shape.NoFrame) + side_box.addWidget(self.plugin_list, 1) + + # 收起后的图标栏:只留图标(悬停显示名字),点图标即打开插件。 + # 没有它的话收起侧栏后插件就完全不可达了。 + self.rail_list = QListWidget() + self.rail_list.setObjectName("PluginRail") + self.rail_list.setFrameShape(QListWidget.Shape.NoFrame) + self.rail_list.setViewMode(QListWidget.ViewMode.IconMode) + self.rail_list.setFlow(QListWidget.Flow.TopToBottom) + self.rail_list.setWrapping(False) + self.rail_list.setMovement(QListWidget.Movement.Static) + self.rail_list.setResizeMode(QListWidget.ResizeMode.Adjust) + self.rail_list.setIconSize(QSize(24, 24)) + self.rail_list.setGridSize(QSize(SIDEBAR_RAIL_GRID, 40)) + self.rail_list.setFixedWidth(RAIL_WIDTH - 8) + self.rail_list.hide() + side_box.addWidget(self.rail_list, 1) + + # ---- 侧栏底部:账号条(头像 + 用户名 → 用户中心)+ 设置齿轮 ---- + self._bottom_box = bottom = hbox(spacing=6) + self.user_button = QPushButton("未登录") + self.user_button.setObjectName("Ghost") + self.user_button.setIconSize(QSize(24, 24)) + self.user_button.setToolTip("用户中心") + self.user_button.setCursor(Qt.CursorShape.PointingHandCursor) + bottom.addWidget(self.user_button, 1) + self.settings_button = QPushButton("⚙") + self.settings_button.setObjectName("Ghost") + self.settings_button.setFixedSize(30, 30) + self.settings_button.setToolTip("设置") + self.settings_button.setCursor(Qt.CursorShape.PointingHandCursor) + bottom.addWidget(self.settings_button) + side_box.addLayout(bottom) + layout.addWidget(sidebar) + + # ---- 页面栈 ---- + self.stack = QStackedWidget() + self.manager = ManagerPage() + self.stack.addWidget(self.manager) + layout.addWidget(self.stack, 1) + self.setCentralWidget(central) + + # ---- 状态栏 ---- + status = self.statusBar() + self.status_label = QLabel("就绪") + self.progress = QProgressBar() + self.progress.setFixedWidth(180) + self.progress.setVisible(False) + status.addWidget(self.status_label, 1) + status.addPermanentWidget(self.progress) + + # ---- 信号 ---- + self.plugin_list.currentRowChanged.connect(self._on_sidebar_row) + self.rail_list.currentRowChanged.connect(self._on_rail_row) + self.search_edit.textChanged.connect(self._on_search_changed) + self.collapse_button.clicked.connect(self._toggle_sidebar) + self.user_button.clicked.connect(self._open_user_center) + self.settings_button.clicked.connect(self._open_settings) + self.manager.install_button.clicked.connect(self._install_file) + self.manager.install_url_button.clicked.connect(self._install_url) + self.manager.check_updates_button.clicked.connect(self._check_updates) + self.manager.server_edit_button.clicked.connect(self._edit_base_url) + self.manager.server_login_button.clicked.connect(self._relogin) + self.manager.server_logout_button.clicked.connect(self.logout) + + self.manager.openRequested.connect(self.open_plugin) + self.manager.uninstallRequested.connect(self._uninstall) + self.manager.verifyRequested.connect(self._verify) + self.manager.enabledRequested.connect(self._set_enabled) + self.manager.versionRequested.connect(self._switch_version) + self.manager.upgradeRequested.connect(self._upgrade_plugin) + self.manager.serverEditRequested.connect(self._edit_base_url) + + self.manager.set_base_url(settings.load(self.base).get("base_url") or "") + self.manager.set_session(self._session) + self._update_user_chip() + self._set_sidebar_collapsed(_ui_settings().value( + "sidebar/collapsed", False, type=bool + )) + + # 服务器连通状态:启动探一次 + 每 60s 复查 + self._status_timer = QTimer(self) + self._status_timer.setInterval(60_000) + self._status_timer.timeout.connect(self._ping_server) + self._status_timer.start() + self._ping_server() + self._check_client_update() + if (self._session or {}).get("offline"): + self.manager.show_message( + "warn", "离线模式", + "服务器当前不可达,先用本地插件;接口类功能恢复连接后可用。", + ) + + self.client.progress.connect(self._on_progress) + self.client.busy_changed.connect(self._on_busy) + self.client.log.connect(lambda line: diagnostics.log(line, "worker")) + self.client.log.connect(lambda line: self.status_label.setText(line)) + # 托盘:把插件的 notify 变成真正的系统通知(不可用则退回界面内提示) + self._tray = self._setup_tray() + + def _fill_sidebar(self, current_id=None): + """按当前搜索词填充两套列表(完整列表 + 图标栏),并恢复选中。 + + ``self._sidebar_sources`` 是"已启用插件"的原始清单(含图标), + 搜索只影响显示,不改变它——清空搜索即恢复全部。 + """ + query = self.search_edit.text() + self.plugin_list.blockSignals(True) + self.rail_list.blockSignals(True) + self.plugin_list.clear() + self.rail_list.clear() + for source in self._sidebar_sources: + if not search_mod.matches(query, source["name"], source["id"]): + continue + item = QListWidgetItem(source["icon"], source["name"]) + item.setData(Qt.ItemDataRole.UserRole, source["id"]) + item.setToolTip(source["tooltip"]) + self.plugin_list.addItem(item) + # 图标栏:只放图标,名字进 tooltip + rail_item = QListWidgetItem(source["icon"], "") + rail_item.setData(Qt.ItemDataRole.UserRole, source["id"]) + rail_item.setToolTip(f"{source['name']}({source['id']})") + rail_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self.rail_list.addItem(rail_item) + self._update_search_hint() + if current_id is not None: + self._select_in_lists(current_id) + self.plugin_list.blockSignals(False) + self.rail_list.blockSignals(False) + + def _update_search_hint(self): + """搜索无结果时的提示(收起状态或没输查询词都不显示)。""" + has_query = bool(self.search_edit.text().strip()) + self.search_hint.setVisible( + has_query and self.plugin_list.count() == 0 + and not self._sidebar_collapsed + ) + + def _select_in_lists(self, plugin_id): + """把两套列表的选中行同步到指定插件(不存在时清空选中)。""" + for widget in (self.plugin_list, self.rail_list): + widget.blockSignals(True) + selected = -1 + for row in range(widget.count()): + if widget.item(row).data(Qt.ItemDataRole.UserRole) == plugin_id: + selected = row + break + widget.setCurrentRow(selected) + widget.blockSignals(False) + + def _on_search_changed(self): + self._fill_sidebar(self._current_view_id()) + + def _on_rail_row(self, row): + """图标栏点击:与完整列表同义(打开插件,选中由 open_plugin 同步)。""" + item = self.rail_list.item(row) + if item is None: + return + self.open_plugin(item.data(Qt.ItemDataRole.UserRole)) + + def _show_manager(self): + self.stack.setCurrentIndex(0) + self.setWindowTitle(APP_NAME) + + # ------------------------------------------------------------------ + # 启动直显(进入界面直接显示插件内容) + # ------------------------------------------------------------------ + + def _resolve_startup_plugin(self): + """启动直显的插件:配置 > 上次打开 > 按 id 排序第一个启用的插件。 + + 都不满足(没有插件/全部禁用)返回 None——停在插件管理页。 + """ + candidates = [settings.load(self.base).get("startup_plugin") or ""] + candidates.append(_ui_settings().value("ui/last_plugin", "", type=str) or "") + for plugin_id in candidates: + entry = self.entry_of(plugin_id) + if entry and entry.get("enabled", False): + return plugin_id + for entry in sorted(self._plugins, key=lambda e: e["id"]): + if entry.get("enabled", False): + return entry["id"] + return None + + def set_startup_plugin(self, plugin_id): + """配置启动直显的插件(空 = 默认规则);返回错误信息,None 表示成功。""" + if plugin_id and self.entry_of(plugin_id) is None: + return f"插件未安装: {plugin_id}" + data = settings.load(self.base) + data["startup_plugin"] = plugin_id or "" + settings.save(self.base, data) + return None + + # ------------------------------------------------------------------ + # payload 应用(唯一的界面刷新入口) + # ------------------------------------------------------------------ + + def refresh(self): + self.client.submit("list_plugins", on_done=self.apply_payload, + on_fail=self._on_fail_silent, label="刷新") + + def apply_payload(self, payload): + plugins = payload.get("plugins", []) + self._plugins = plugins + + # --open 的启动意图:列表就绪后打开指定插件 + pending_open = getattr(self, "_pending_open", None) + if pending_open: + self._pending_open = None + if self.entry_of(pending_open) is not None: + self.open_plugin(pending_open) + self._startup_resolved = True + + # 启动直显:只处理第一次列表(配置 > 上次打开 > 第一个启用的插件) + if not self._startup_resolved: + self._startup_resolved = True + pid = self._resolve_startup_plugin() + if pid: + self.open_plugin(pid) + + # 侧栏只列已启用的插件;禁用的去管理页处理 + current_id = self._current_view_id() + listed = [entry for entry in plugins if entry.get("enabled", False)] + self._sidebar_sources = [ + { + "id": entry["id"], + "name": entry.get("name") or entry["id"], + "icon": _plugin_icon(entry.get("icon_bytes"), entry["id"]), + "tooltip": plugin_url(entry["id"]), + } + for entry in listed + ] + self._fill_sidebar(current_id) + + # 卸载掉的插件 → 收掉常驻视图;版本变化/禁用 → 视图作废(下次打开重建) + installed_ids = {entry["id"] for entry in plugins} + for gone in set(self._views) - installed_ids: + self.close_plugin(gone) + for entry in plugins: + view = self._views.get(entry["id"]) + if view is not None and ( + view.current_version != entry.get("version") + or not entry.get("enabled", False) + ): + self.close_plugin(entry["id"]) + + # 管理页卡片 + self.manager.set_plugins(plugins) + for entry in plugins: + card = self.manager.cards.get(entry["id"]) + if card is not None: + card.icon_label.setPixmap( + _plugin_icon(entry.get("icon_bytes"), entry["id"]).pixmap(34, 34) + ) + + # 若当前页面被收掉,回到管理页 + if self.stack.currentIndex() > 0 and self._current_view_id() is None: + self._show_manager() + + if payload.get("message"): + self.status_label.setText(payload["message"]) + self.manager.show_message("ok", payload["message"]) + + # ------------------------------------------------------------------ + # 打开/关闭插件页面 + # ------------------------------------------------------------------ + + def _current_view_id(self): + widget = self.stack.currentWidget() + for pid, view in self._views.items(): + if view is widget: + return pid + return None + + def entry_of(self, plugin_id): + for entry in self._plugins: + if entry["id"] == plugin_id: + return entry + return None + + def open_plugin(self, plugin_id): + entry = self.entry_of(plugin_id) + if entry is None: + return + if not entry.get("enabled", False): + QMessageBox.information( + self, "插件已禁用", + f"{entry.get('name') or plugin_id} 目前处于禁用状态," + "请先在「插件管理」里启用。", + ) + return + view = self._views.get(plugin_id) + if view is None: + view = self._make_view(entry) + self.stack.setCurrentWidget(view) + self.setWindowTitle(f"{entry.get('name') or plugin_id} · {APP_NAME}") + # 两套列表的选中同步(图标栏与完整列表谁触发的都保持一致) + self._select_in_lists(plugin_id) + # 记住最近打开的插件(启动直显的默认规则要用) + _ui_settings().setValue("ui/last_plugin", plugin_id) + + def _make_view(self, entry): + view = PluginView( + { + "id": entry["id"], + "entry": entry.get("entry"), + "permissions": entry.get("permissions") or [], + "net_hosts": entry.get("net_hosts") or [], + "storage_file": entry.get("storage_file") + or paths.storage_file(self.base, entry["id"]), + }, + self.profile, + base_url_provider=lambda: settings.load(self.base).get("base_url") or "", + session_provider=lambda: self._session, + net_options_provider=lambda: settings.net_options(self.base), + ) + view.current_version = entry.get("version") + view.bridge().notifyRequested.connect(self._on_plugin_notify) + view.bridge().titleRequested.connect(self._on_plugin_title) + view.sessionRejected.connect(self._on_session_rejected) + self._views[entry["id"]] = view + self.stack.addWidget(view) + view.load_entry(entry.get("entry")) + return view + + def close_plugin(self, plugin_id): + view = self._views.pop(plugin_id, None) + if view is None: + return + view.shutdown() + self.stack.removeWidget(view) + view.deleteLater() + if self._current_view_id() is None: + self.setWindowTitle(APP_NAME) + + # ------------------------------------------------------------------ + # 安装 / 管理 + # ------------------------------------------------------------------ + + def _install_file(self): + path, _ = QFileDialog.getOpenFileName( + self, "选择插件包", "", "SVFS 插件包 (*.svfs);;所有文件 (*)" + ) + if not path: + return + self._submit_install("install_file", {"path": path}, os.path.basename(path)) + + def _install_url(self): + current = settings.load(self.base).get("base_url") or "" + server_hint = current or "未配置,可在「服务器」卡片设置" + url, ok = QInputDialog.getText( + self, "从服务器安装", + "插件 id、相对路径或完整的 manifest 地址\n(服务器:" + server_hint + "):", + QLineEdit.EchoMode.Normal, "", + ) + if not ok or not url.strip(): + return + try: + resolved = settings.resolve_manifest_url(current, url) + except ValueError as exc: + QMessageBox.warning(self, "无法安装", str(exc)) + return + self._submit_install("install_url", {"url": resolved}, "插件下载") + + # ------------------------------------------------------------------ + # 服务器配置(base_url) + # ------------------------------------------------------------------ + + def set_base_url(self, raw): + """设置服务器地址;返回错误信息,None 表示成功(供对话框与测试调用)。 + + 传空串/空白 = 清除配置。设置经桥的 callable 读取,已打开的插件页面 + 立刻拿到新值,不用重载。 + """ + try: + normalized = settings.normalize_base_url(raw) + except ValueError as exc: + return str(exc) + data = settings.load(self.base) + data["base_url"] = normalized + settings.save(self.base, data) + self.manager.set_base_url(normalized) + self.status_label.setText( + "服务器地址已更新" if normalized else "已清除服务器地址" + ) + # 换了服务器:连通状态与"有没有新版客户端"都得重新问 + self._ping_server() + if normalized: + self._check_client_update() + return None + + def _edit_base_url(self): + current = settings.load(self.base).get("base_url") or "" + url, ok = QInputDialog.getText( + self, "服务器地址", + "服务端地址(http(s)://),承载插件下载与接口服务:", + QLineEdit.EchoMode.Normal, current, + ) + if not ok: + return + error = self.set_base_url(url) + if error: + QMessageBox.warning(self, "地址无效", error) + + # ------------------------------------------------------------------ + # 登录会话 + # ------------------------------------------------------------------ + + def set_session(self, session): + """更新登录会话(None = 未登录);服务器卡片、侧栏头像与桥立刻感知。 + + 带 ``invalid`` 标记的会话(登录中途失效)不重置"已提示过"标记—— + 重新登录成功拿到的会话是干净的,那时才允许再次提示。 + """ + self._session = dict(session) if session else None + if not (self._session or {}).get("invalid"): + self._relogin_prompted = False + self.manager.set_session(self._session) + self._update_user_chip() + + def _on_session_rejected(self, detail): + """插件的 net 调用暴露"登录已失效"(宿主注入的 token 被 401 拒绝)。 + + 提示只发一次:重新登录之前,每个已打开的插件都会撞上同一个 401, + 重复弹提示只会变成噪音。服务端会话在内存里、重启即失效,所以这个 + 状态是常态之一——提示要克制,但必须说清该做什么。 + """ + if not (self._session or {}).get("username"): + return + diagnostics.log(detail or "登录已失效(HTTP 401)", "auth") + self._session["invalid"] = True + self.manager.set_session(self._session) + self._update_user_chip() + if self._relogin_prompted: + return + self._relogin_prompted = True + self.status_label.setText("登录已失效,请重新登录") + # 用横幅自带的动作按钮承载"重新登录…":提示条在插件管理页, + # 但登录失效时常发生在用插件的过程中(横幅带按钮就不用去找卡片) + self.manager.show_message( + "warn", "登录已失效", + "服务器不再接受当前登录凭据(服务端重启或会话过期)," + "插件接口调用会拿到 401。请点「重新登录…」重新登录。", + action_text="重新登录…", action=self._relogin, + ) + + def _relogin(self): + """弹登录界面;成功后更新会话(已打开的插件页面立刻拿到新 token)。""" + from .login import LoginWindow + + login = LoginWindow(self.base, parent=self) + login.exec() + session = login.session + if session: + self.set_session(session) + self.status_label.setText(f"已登录 {session.get('username') or ''}") + + def logout(self): + """退出登录:清掉本地凭据,并尽力通知服务器作废 token。""" + session = self._session + data = settings.load(self.base) + data["auth_token"] = "" # 老字段:迁移后不再使用 + settings.save(self.base, data) + credentials.clear(self.base) + self.set_session(None) + if session: + self.client.submit( + "logout", + {"base_url": session.get("base_url"), "token": session.get("token")}, + ) + self.status_label.setText("已退出登录") + + # ------------------------------------------------------------------ + # 侧栏收缩 / 设置 / 用户中心 + # ------------------------------------------------------------------ + + def _toggle_sidebar(self): + self._set_sidebar_collapsed(not self._sidebar_collapsed) + + def _set_sidebar_collapsed(self, collapsed, animate=None): + """展开 / 收起侧栏(宽度动画过渡,选择写进 QSettings)。 + + 收起不是"把列表藏掉":图标栏(rail_list)接过插件列表的职责, + 插件仍然可见可点,只是只剩图标。 + """ + collapsed = bool(collapsed) + if animate is None: + # 构造期(窗口还没显示)不播放动画,直接落到目标宽度 + animate = self.isVisible() + target = RAIL_WIDTH if collapsed else SIDEBAR_WIDTH + changed = collapsed != self._sidebar_collapsed + self._sidebar_collapsed = collapsed + self._apply_sidebar_mode(collapsed) + self.collapse_button.setText("»" if collapsed else "«") + self.collapse_button.setToolTip("展开侧栏" if collapsed else "收起侧栏") + self._update_user_chip() + if changed: + _ui_settings().setValue("sidebar/collapsed", collapsed) + if not animate or self.sidebar.width() == target: + self.sidebar.setFixedWidth(target) + return + # min/max 一起动画(setFixedWidth 是瞬跳的来源);结束后钉死目标宽度 + group = QParallelAnimationGroup(self) + start = self.sidebar.width() + for prop in (b"minimumWidth", b"maximumWidth"): + animation = QPropertyAnimation(self.sidebar, prop, self) + animation.setDuration(SIDEBAR_ANIM_MS) + animation.setStartValue(start) + animation.setEndValue(target) + animation.setEasingCurve(QEasingCurve.Type.InOutCubic) + group.addAnimation(animation) + group.finished.connect(lambda: self.sidebar.setFixedWidth(target)) + self._sidebar_anim = group # 保引用,防被 GC 掉 + group.start(QParallelAnimationGroup.DeletionPolicy.KeepWhenStopped) + + def _apply_sidebar_mode(self, collapsed): + """切换展开/收起两套内容与底栏排布(动画期间内容立即到位,不闪烁)。""" + for widget in (self._sidebar_title, self._sidebar_subtitle, + self.search_edit, self.plugin_list): + widget.setVisible(not collapsed) + self.rail_list.setVisible(collapsed) + self._update_search_hint() + # 底栏:展开时"头像 + 用户名 | 齿轮"横排;收起时竖排居中两个图标 + if collapsed: + self._bottom_box.setDirection(QBoxLayout.Direction.TopToBottom) + self._bottom_box.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self._bottom_box.setStretch(0, 0) + self.user_button.setFixedSize(30, 30) + else: + self._bottom_box.setDirection(QBoxLayout.Direction.LeftToRight) + self._bottom_box.setAlignment(Qt.AlignmentFlag.AlignLeft) + self._bottom_box.setStretch(0, 1) + self.user_button.setMinimumSize(0, 0) + self.user_button.setMaximumSize(16777215, 16777215) + + def _update_user_chip(self): + """侧栏底部的头像入口:随登录状态与侧栏宽度刷新。""" + username = (self._session or {}).get("username") or "" + invalid = bool((self._session or {}).get("invalid")) + self.user_button.setIcon(QIcon(avatar_pixmap(username, 26))) + self.user_button.setText( + "" if self._sidebar_collapsed else (username or "未登录") + ) + if username: + suffix = "(登录已失效)" if invalid else "" + self.user_button.setToolTip(f"用户中心({username}){suffix}") + else: + self.user_button.setToolTip("用户中心") + + def _open_user_center(self): + """头像点击 → 用户中心;退出/重登后对话框按现值自刷新。""" + dialog = UserCenterDialog( + self.base, + session_provider=lambda: self._session, + on_relogin=self._relogin, + on_logout=self.logout, + parent=self, + ) + dialog.exec() + + def _install_debug_plugin(self): + """安装/升级桥调试台插件(设置 → 桥调试台),完成后自动打开新版页面。""" + self.handler.invalidate() + self.manager.clear_banner() + self.status_label.setText("正在安装调试台…") + self.client.submit( + "install_sample", {}, + on_done=lambda payload: ( + self.apply_payload(payload), + self.open_plugin(sample.PLUGIN_ID), + ), + on_fail=self._on_fail, + label="调试台", + ) + + # ------------------------------------------------------------------ + # 更新检查 / 服务器状态 + # ------------------------------------------------------------------ + + def _check_updates(self): + """逐个插件的服务器版本比对(后台线程,结果刷新卡片徽章)。""" + self.status_label.setText("正在检查更新…") + self.client.submit( + "check_updates", {}, + on_done=self._on_updates, + on_fail=self._on_fail_silent, + label="检查更新", + ) + + def _on_updates(self, payload): + updates = payload.get("updates") or {} + self.manager.set_updates(updates) + checked = payload.get("checked", 0) + if updates: + names = "、".join(sorted(updates)) + self.status_label.setText(f"{len(updates)} 个插件有更新:{names}") + self.manager.show_message( + "warn", f"{len(updates)} 个插件可升级", + "在卡片上点「升级到 x.y.z」按钮从服务器安装新版本。", + ) + elif checked: + self.status_label.setText(f"已是最新(检查了 {checked} 个插件)") + self.manager.show_message("ok", "所有插件都是最新版本") + else: + self.status_label.setText("无法检查更新(服务器地址未配置或不可达)") + + def _upgrade_plugin(self, plugin_id): + """把某个插件升级到服务器上的新版本(走远程安装链路)。""" + try: + resolved = settings.resolve_manifest_url( + settings.load(self.base).get("base_url") or "", plugin_id) + except ValueError as exc: + QMessageBox.warning(self, "无法升级", str(exc)) + return + self._submit_install("install_url", {"url": resolved}, + f"{plugin_id} 升级") + + def _ping_server(self): + """服务器连通状态(轻量后台探测,不打扰用户)。""" + base = settings.load(self.base).get("base_url") or "" + if not base: + self.manager.set_server_status("muted", "", "尚未配置服务器地址") + return + self.client.submit( + "ping_server", {}, + on_done=self._on_server_status, + on_fail=lambda title, detail: self.manager.set_server_status( + "err", "", detail), + ) + + def _on_server_status(self, payload): + if payload.get("online") and payload.get("auth_rejected"): + # 服务器是活的,只是不认这个登录会话——不说"离线"(会把人 + # 引去查网络);重新登录后下一次探测自动变绿 + self.manager.set_server_status( + "warn", "登录已失效", + "服务器在线,但当前登录凭据已被拒绝(401):请重新登录", + ) + self._on_session_rejected("服务器以 401 拒绝了登录凭据") + return + if payload.get("online"): + latency = payload.get("latency_ms") + text = f"{latency} ms" if latency is not None else "在线" + self.manager.set_server_status("ok", text, f"服务器可用({text})") + else: + self.manager.set_server_status( + "err", "离线", payload.get("error") or "服务器不可达") + + # ------------------------------------------------------------------ + # 客户端自身的更新检查 + # ------------------------------------------------------------------ + + def _check_client_update(self): + """查服务器上有没有推荐的新版客户端(没配这个文件就静默跳过)。 + + 与插件检查更新不同,这条是"顺带看一眼":失败不提示、不弹窗, + 免得服务器没提供这个文件时变成噪音。 + """ + self.client.submit( + "check_client_update", {}, + on_done=self._on_client_update, + on_fail=self._on_fail_silent, + label="检查客户端更新", + ) + + def _on_client_update(self, payload): + self._client_update = dict(payload or {}) + if not self._client_update.get("update"): + return + latest = self._client_update.get("latest") or "" + url = self._client_update.get("url") or "" + diagnostics.log( + f"客户端有新版本 {latest}(当前 {__version__})", "updates") + self.status_label.setText(f"客户端有新版本 {latest}(当前 {__version__})") + self.manager.show_message( + "info", f"客户端有新版本 {latest}", + self._client_update.get("notes") + or f"当前版本 {__version__},建议升级后再继续使用。", + action_text="下载新版本…" if url else None, + action=(lambda: self._open_update_url(url)) if url else None, + ) + + def _open_update_url(self, url): + """新版本下载地址交给系统浏览器(客户端自己不下自己不安装)。""" + diagnostics.log(f"打开新版本下载地址: {url}", "updates") + QDesktopServices.openUrl(QUrl(url)) + + def _open_settings(self): + dialog = SettingsDialog( + self.base, apply_base_url=self.set_base_url, + on_open_manager=self._show_manager, parent=self, + plugins_provider=lambda: [ + (entry["id"], entry.get("name") or entry["id"]) + for entry in self._plugins + ], + startup_provider=lambda: settings.load(self.base).get("startup_plugin") or "", + apply_startup=self.set_startup_plugin, + on_install_debug=self._install_debug_plugin, + on_net_settings_changed=self._ping_server, + ) + dialog.exec() + + def _submit_install(self, op, payload, label): + # Windows:先关协议层句柄,否则包文件替换/删除会失败 + self.handler.invalidate() + self.manager.clear_banner() + self.status_label.setText(f"正在安装 {label}…") + self.client.submit( + op, payload, + on_done=self.apply_payload, + on_fail=self._on_fail, + label=label, + ) + + def _set_enabled(self, plugin_id, checked): + entry = self.entry_of(plugin_id) or {} + if entry and entry.get("enabled", False) == bool(checked): + return + self.handler.invalidate() + self.client.submit( + "set_enabled", {"id": plugin_id, "enabled": bool(checked)}, + on_done=self.apply_payload, on_fail=self._on_fail, label="启停", + ) + + def _switch_version(self, plugin_id, snapshot_id): + self.handler.invalidate() + self.client.submit( + "use_version", {"id": plugin_id, "snapshot_id": snapshot_id}, + on_done=self.apply_payload, on_fail=self._on_fail, label="切换版本", + ) + + def _verify(self, plugin_id): + self.client.submit( + "verify", {"id": plugin_id, "check_content": True}, + on_done=lambda payload: self._on_verify(plugin_id, payload), + on_fail=self._on_fail, label="校验", + ) + + def _on_verify(self, plugin_id, payload): + report = payload.get("verify") or {} + if report.get("ok"): + files = report.get("files_checked", 0) + deep = "(已逐文件重算 sha256)" if report.get("content_checked") else "" + self.manager.show_message("ok", f"{plugin_id} 校验通过", + f"{files} 个文件{deep}") + self.status_label.setText("校验通过") + else: + self.manager.show_message("err", f"{plugin_id} 校验未通过", + ";".join(report.get("errors", [])[:3])) + + def _uninstall(self, plugin_id): + entry = self.entry_of(plugin_id) or {} + answer = QMessageBox.question( + self, "卸载插件", + f"确定卸载 {entry.get('name') or plugin_id}?\n\n" + "包文件与该插件的键值存储都会被删除,所有版本一并消失,不可恢复。", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + return + self.handler.invalidate() + self.close_plugin(plugin_id) + self.client.submit( + "uninstall", {"id": plugin_id}, + on_done=self.apply_payload, on_fail=self._on_fail, label="卸载", + ) + + # ------------------------------------------------------------------ + # 桥事件 + # ------------------------------------------------------------------ + + def _setup_tray(self): + """有系统托盘就用它发原生通知(``notify`` 权限名副其实)。 + + 托盘不可用(无桌面会话、被组策略禁用、offscreen 测试)时返回 None, + 通知退回状态栏 + 应用内横幅——插件拿到的返回值不变,只是结果没弹到 + 桌面通知中心而已。 + """ + try: + from PySide6.QtWidgets import QSystemTrayIcon + + if not QSystemTrayIcon.isSystemTrayAvailable(): + return None + tray = QSystemTrayIcon(QIcon(_logo_pixmap(32)), self) + tray.setToolTip(APP_NAME) + tray.show() + return tray + except Exception as exc: # 缺资源/平台不支持:降级,不影响主流程 + diagnostics.log(f"系统托盘不可用,通知只显示在界面内: {exc}", "tray") + return None + + def _on_plugin_notify(self, message): + self.status_label.setText(message) + self.manager.show_message("info", "插件通知", message) + if self._tray is not None: + from PySide6.QtWidgets import QSystemTrayIcon + + self._tray.showMessage(APP_NAME, message, + QSystemTrayIcon.MessageIcon.Information, 6000) + + def _on_plugin_title(self, title): + self.setWindowTitle(title) + + # ------------------------------------------------------------------ + # 进度 / 失败 + # ------------------------------------------------------------------ + + def _on_progress(self, done, total, label): + if total > 0: + self.progress.setRange(0, total) + self.progress.setValue(done) + else: + self.progress.setRange(0, 0) + self.progress.setVisible(True) + if label: + self.status_label.setText(label) + if 0 < total <= done: + QTimer.singleShot(400, lambda: self.progress.setVisible(False)) + + def _on_busy(self, busy, label): + self.progress.setVisible(busy) + if busy: + self.progress.setRange(0, 0) + self.status_label.setText(f"{label}…") + + def _on_fail(self, title, detail): + diagnostics.log(f"{title}: {detail}", "error") + self.status_label.setText(title) + self.manager.show_message("err", title, detail) + QMessageBox.warning(self, title, detail) + + def _on_fail_silent(self, title, detail): + diagnostics.log(f"{title}: {detail}", "error") + self.status_label.setText(title) + self.manager.show_message("err", title, detail) + + # ------------------------------------------------------------------ + # 侧栏 + # ------------------------------------------------------------------ + + def _on_sidebar_row(self, row): + item = self.plugin_list.item(row) + if item is not None: + self.open_plugin(item.data(Qt.ItemDataRole.UserRole)) + + # ------------------------------------------------------------------ + # 生命周期 + # ------------------------------------------------------------------ + + def _restore_state(self): + geometry = _ui_settings().value("geometry") + if geometry is not None: + self.restoreGeometry(geometry) + + def closeEvent(self, event): # noqa: N802 + _ui_settings().setValue("geometry", self.saveGeometry()) + for plugin_id in list(self._views): + self.close_plugin(plugin_id) + if self.profile is not None: + self.profile.deleteLater() + self.handler.close() + self._thread.quit() + self._thread.wait(5000) + super().closeEvent(event) diff --git a/src/svfs_client/webview.py b/src/svfs_client/webview.py new file mode 100644 index 0000000..5cd21b8 --- /dev/null +++ b/src/svfs_client/webview.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""插件页面容器:QWebEngineView + 导航守卫 + 桥注入(Qt 层)。 + +* :func:`create_profile` —— 客户端唯一的 QWebEngineProfile(持久化到数据目录), + 并把"注入 bootstrap"的脚本挂到 profile 上,之后每个插件页面自动拥有 + ``window.svfs``(Promise API)。插件作者**不需要**引用 qwebchannel.js。 +* :class:`PluginPage` —— 导航守卫:插件页里点外链交给系统浏览器打开, + 导航到另一个插件的 origin 直接拒绝(隔离在浏览器层面再兜一道底)。 +* :class:`PluginView` —— 一个插件一个实例:加载 ``svfs:///``, + 挂自己的 QWebChannel + SvfsBridge(能力按该插件 manifest 的 permissions)。 +""" + +import json + +from PySide6.QtCore import QByteArray, Qt, QUrl, Signal +from PySide6.QtGui import QColor, QDesktopServices +from PySide6.QtWebChannel import QWebChannel +from PySide6.QtWebEngineCore import ( + QWebEnginePage, + QWebEngineProfile, + QWebEngineScript, +) +from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 - 对外转发 + +from svfs_studio import theme + +from . import diagnostics +from .bridge import SvfsBridge +from .scheme import SCHEME_NAME, plugin_url + +__all__ = ["create_profile", "install_bootstrap", "PluginPage", "PluginView"] + +_EXTERNAL_SCHEMES = ("http", "https", "mailto") +_ALLOWED_INTERNAL_SCHEMES = ("about", "qrc", "data", "blob") + + +# ---------------------------------------------------------------------- +# bootstrap:把 QWebChannel 包成 window.svfs(Promise API) +# ---------------------------------------------------------------------- + +_BRIDGE_BOOTSTRAP = """ +(function () { + 'use strict'; + if (window.__svfsBridgeInstalled) { return; } + window.__svfsBridgeInstalled = true; + + // svfsReady 先立占位,任何时机 await 它都安全(页面脚本先跑也不怕)。 + // 注意:DocumentCreation 阶段对 document 做任何挂载/网络操作都会把 + // 渲染进程搞挂(实测文档永远收不到响应体),所以真正的初始化 + // 必须推迟到 DOMContentLoaded。 + var readyResolve, readyReject, started = false; + window.svfsReady = new Promise(function (resolve, reject) { + readyResolve = resolve; + readyReject = reject; + }); + + function bind(api) { + // 注意:Qt 6 的 QWebChannel 方法调用一律返回 Promise,值要 .then 取 + window.svfs = { + host: { + info: function () { return api.hostInfo(); } + }, + storage: { + get: function (k) { + return api.storageGet(String(k)).then(function (s) { + return JSON.parse(s === undefined || s === null ? 'null' : s); + }); + }, + set: function (k, v) { + return api.storageSet(String(k), JSON.stringify(v === undefined ? null : v)); + }, + remove: function (k) { return api.storageRemove(String(k)); }, + keys: function () { return api.storageKeys(); } + }, + net: bindNet(api), + ui: bindUi(api) + }; + return window.svfs; + } + + function bindUi(api) { + var ui = { + notify: function (msg) { return api.notify(String(msg)); }, + setTitle: function (t) { return api.setTitle(String(t)); } + }; + // 原生对话框(需要 "dialog" 权限):slot 返回 JSON 字符串,这里拆成 + // 两个语义化方法。没有该权限时 api.uiDialog 不存在,字段缺省。 + if (typeof api.uiDialog === 'function') { + var callDialog = function (opts) { + return api.uiDialog(JSON.stringify(opts || {})).then(function (s) { + var r = {}; + try { r = JSON.parse(s === undefined || s === null ? '{}' : s); } + catch (err) { r = {}; } + return r; + }); + }; + ui.confirm = function (opts) { + opts = opts || {}; + opts.kind = 'confirm'; + return callDialog(opts).then(function (r) { return !!r.accepted; }); + }; + ui.message = function (opts) { + opts = opts || {}; + opts.kind = opts.kind || 'info'; + return callDialog(opts).then(function (r) { return !!r.accepted; }); + }; + } + return ui; + } + + function bindNet(api) { + // net.request:slot 立刻返回请求 id,结果经两条路送回页面(先到先得): + // 1. netResult 信号 → 宿主 runJavaScript 调 window.__svfsNetDeliver + // (QWebChannel 的"宿主→页面"信号投递在部分 Qt 版本不可靠, + // 所以 runJavaScript 是主通道); + // 2. api.netResult 信号直连(老路径,环境正常时也能到)。 + // 没有净权限时 api.netRequest 不存在。 + if (typeof api.netRequest !== 'function') { return undefined; } + var pending = {}; + window.__svfsNetDeliver = function (json) { + var msg; + try { msg = JSON.parse(json); } catch (err) { return; } + var waiter = pending[msg.reqId]; + if (!waiter) { return; } // 已被另一条路送达,或未知请求 + delete pending[msg.reqId]; + if (msg.error) { waiter.reject(new Error(msg.error)); } + else { waiter.resolve(msg); } + }; + api.netResult.connect(function (json) { + window.__svfsNetDeliver(json); + }); + return { + request: function (opts) { + return new Promise(function (resolve, reject) { + // 注意:Qt 6 的 QWebChannel slot 调用一律返回 Promise——reqId + // 必须 .then 取。直接把 Promise 当 key 用会让应答永远配不上 + // 请求(页面表现为"请求中"卡死)。 + var register = function (reqId) { + if (!reqId) { reject(new Error('没有 net 权限或请求不可用')); return; } + pending[reqId] = { resolve: resolve, reject: reject }; + }; + try { + var call = api.netRequest(JSON.stringify(opts || {})); + if (call && typeof call.then === 'function') { + call.then(register, function (err) { reject(err); }); + } else { + register(call); + } + } catch (err) { reject(err); } + }); + } + }; + } + + function start() { + if (started) { return; } + started = true; + if (typeof qt === 'undefined' || !qt || !qt.webChannelTransport) { + readyReject(new Error('svfs bridge unavailable')); + return; + } + if (typeof QWebChannel !== 'undefined') { + new QWebChannel(qt.webChannelTransport, function (channel) { + try { readyResolve(bind(channel.objects.svfs)); } + catch (err) { readyReject(err); } + }); + return; + } + // qwebchannel.js 对 Python 侧 QFile 不可见(Qt 把它编进 WebEngine 资源), + // 由页面内 script 标签加载 + var tag = document.createElement('script'); + tag.src = 'qrc:///qtwebchannel/qwebchannel.js'; + tag.onload = function () { + new QWebChannel(qt.webChannelTransport, function (channel) { + try { readyResolve(bind(channel.objects.svfs)); } + catch (err) { readyReject(err); } + }); + }; + tag.onerror = function () { readyReject(new Error('qwebchannel.js unavailable')); }; + (document.head || document.documentElement || document).appendChild(tag); + } + + if (document.readyState !== 'loading') { + setTimeout(start, 0); + } else { + document.addEventListener('DOMContentLoaded', start); + } +})(); +""" + + +def install_bootstrap(profile): + """把桥 bootstrap 挂到 profile:之后该 profile 的每个页面自动可用。""" + script = QWebEngineScript() + script.setName("svfs-bridge-bootstrap") + script.setSourceCode(_BRIDGE_BOOTSTRAP) + script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) + script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) + script.setRunsOnSubFrames(False) + profile.scripts().insert(script) + return True + + +#: 插件页面是 Chromium 渲染的,Qt 的 QSS 管不到里面的滚动条——把暗色细 +#: 滚动条作为**默认样式**注入每个页面(页面自己的 CSS 可覆盖)。颜色取自 +#: 主题常量,与桌面端滚动条观感一致。 +_WEB_SCROLLBAR_CSS = """ +(function () { + 'use strict'; + function inject() { + if (document.querySelector('style[data-svfs-scrollbar]')) { return; } + var style = document.createElement('style'); + style.setAttribute('data-svfs-scrollbar', '1'); + style.textContent = '' + + '::-webkit-scrollbar { width: 10px; height: 10px; }' + + '::-webkit-scrollbar-thumb {' + + ' background: __BORDER_STRONG__; border-radius: 5px;' + + ' border: 2px solid transparent; background-clip: content-box;' + + '}' + + '::-webkit-scrollbar-thumb:hover { background: __TEXT_FAINT__; background-clip: content-box; }' + + '::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; }'; + (document.head || document.documentElement || document).appendChild(style); + } + // 与桥 bootstrap 同样的坑:DocumentCreation 阶段对 document 挂载元素 + // 会把渲染进程搞挂,必须推迟到 DOMContentLoaded。 + if (document.readyState !== 'loading') { setTimeout(inject, 0); } + else { document.addEventListener('DOMContentLoaded', inject); } +})(); +""".replace("__BORDER_STRONG__", theme.BORDER_STRONG).replace( + "__TEXT_FAINT__", theme.TEXT_FAINT +) + + +def install_scrollbar_style(profile): + """把暗色滚动条默认样式注入该 profile 的每个页面(主框架与子框架)。""" + script = QWebEngineScript() + script.setName("svfs-scrollbar-style") + script.setSourceCode(_WEB_SCROLLBAR_CSS) + script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) + script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) + script.setRunsOnSubFrames(True) + profile.scripts().insert(script) + return True + + +def create_profile(base_dir, handler=None): + """客户端唯一的持久 Profile。必须在 QApplication 之后、任何 View 之前创建。 + + handler 给出时把 svfs:// 的请求接到它上——**必须**装:只注册 scheme 而不装 + handler,所有 svfs:// 请求都会以未知协议失败。 + """ + profile = QWebEngineProfile("svfs-client", None) + from . import paths as paths_mod + + profile.setPersistentStoragePath(paths_mod.web_profile_dir(base_dir)) + profile.setCachePath(paths_mod.web_profile_dir(base_dir)) + profile.setHttpUserAgent(f"{SCHEME_NAME}-client") + if handler is not None: + profile.installUrlSchemeHandler(QByteArray(bytes(SCHEME_NAME, "ascii")), handler) + install_bootstrap(profile) + install_scrollbar_style(profile) + return profile + + +# ---------------------------------------------------------------------- +# 页面与视图 +# ---------------------------------------------------------------------- + + +class PluginPage(QWebEnginePage): + """导航守卫:外链出去,别的插件进不来。""" + + def __init__(self, profile, plugin_id, parent=None): + super().__init__(profile, parent) + self._plugin_id = plugin_id + + def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID): # noqa: N802 + # 插件页面的控制台输出(含插件作者 print 的调试信息与报错) + # 全部进诊断日志——插件排障不再两眼一抹黑 + diagnostics.log(f"{message} ({sourceID}:{lineNumber})", + f"console:{self._plugin_id}") + return super().javaScriptConsoleMessage(level, message, lineNumber, + sourceID) + + def acceptNavigationRequest(self, url, nav_type, is_main_frame): # noqa: N802 + scheme = url.scheme() + if scheme == SCHEME_NAME: + # 同插件随便跳(SPA 路由);其它插件的 origin 拒绝 + return url.host() == self._plugin_id + if scheme in _EXTERNAL_SCHEMES: + # 外链交给系统浏览器,不把插件页面变成一个远程内容容器 + QDesktopServices.openUrl(url) + return False + return scheme in _ALLOWED_INTERNAL_SCHEMES + + def createWindow(self, window_type): + # window.open / target=_blank:插件里不允许弹出新窗口(返回 None 即拦截) + return None + + +class PluginView(QWebEngineView): + """一个插件的常驻页面。""" + + #: 宿主注入的登录 token 被服务器拒绝(来自桥)——壳层据此引导重新登录 + sessionRejected = Signal(str) + + #: 渲染进程异常退出后的占位页(点按钮重载插件入口) + _CRASH_PAGE = """ +
+

插件页面崩溃了

+

渲染进程异常退出(__REASON__)。插件本身的数据不受影响,

+

点击下面的按钮重新加载。

+ +
""" + + _CRASH_REASONS = { + "0": "正常退出", + "1": "异常退出", + "2": "进程崩溃", + "3": "被系统终止", + } + + def __init__(self, entry, profile, parent=None, base_url_provider=None, + session_provider=None, net_options_provider=None): + """entry: 注册表项(含 id / entry / permissions / storage_file)。 + + base_url_provider: 返回当前服务器地址的 callable(net 接口与 host.info + 用)。传 callable 而不是值,设置改动后立刻生效。 + session_provider: 返回登录会话({"token","username"} 或 None)的 + callable,宿主据此给同源 net 请求附加认证头。 + net_options_provider: 返回 ``(timeout, retries)`` 的 callable + (settings.net_options),同样是现读。 + """ + super().__init__(parent) + self.plugin_id = entry["id"] + self.current_version = None # 壳层在创建后填,用于检测版本变化 + self._bridge = SvfsBridge( + self.plugin_id, + entry.get("permissions") or [], + entry.get("storage_file"), + base_url_provider=base_url_provider, + session_provider=session_provider, + net_options_provider=net_options_provider, + allowed_hosts=entry.get("net_hosts") or [], + parent=self, + ) + + page = PluginPage(profile, self.plugin_id, parent=self) + # WebEngine 的页面默认白底:内容就绪前(尤其首次打开)会闪一帧白屏, + # 在暗色主题里格外扎眼——把页面底色固定为主题底色 + page.setBackgroundColor(QColor(theme.BG)) + # 渲染进程崩溃 → 崩溃占位页(可一键重载),而不是永久白屏 + page.renderProcessTerminated.connect(self._on_render_terminated) + # net 应答投递:QWebChannel 的"宿主→页面"信号投递在部分 Qt 版本 + # 不可靠(slot 调用正常、信号丢失),所以桥的 netResult 由这里经 + # runJavaScript 主动送进页面(QueuedConnection 保证在主线程执行)。 + self._bridge.netResult.connect( + self._deliver_net_result, Qt.ConnectionType.QueuedConnection + ) + # 登录凭据被服务器拒绝(401)→ 壳层提示重新登录 + self._bridge.sessionRejected.connect(self.sessionRejected) + self._entry_path = entry.get("entry") or "" + # 注意:QWebChannel 挂在 page 上(实测挂到 profile/view 上会让 + # 渲染进程收到空文档——offscreen 与真实窗口下都复现过)。 + self._channel = QWebChannel(page) + self._channel.registerObject("svfs", self._bridge) + page.setWebChannel(self._channel) + self.setPage(page) + + def bridge(self): + return self._bridge + + def _deliver_net_result(self, payload_json): + """把 net 应答(JSON 字符串)经 runJavaScript 送回页面。 + + 页面侧的 window.__svfsNetDeliver 负责按 reqId 唤醒等待中的 + Promise;与 netResult 信号直连那条路互为备份,先到先得。 + """ + try: + self.page().runJavaScript( + "window.__svfsNetDeliver && window.__svfsNetDeliver(" + + json.dumps(payload_json) + ");", 0) + except RuntimeError: + pass # 页面正在销毁 + + def load_entry(self, entry_path=None): + self._entry_path = entry_path or self._entry_path + self.load(QUrl(plugin_url(self.plugin_id, entry_path or ""))) + + def _on_render_terminated(self, status, exit_code): + """渲染进程退出:加载可一键重载的占位页,避免永久白屏。""" + reason = self._CRASH_REASONS.get(str(int(status)), f"状态 {int(status)}") + diagnostics.log(f"渲染进程退出({reason}, code={exit_code})", + f"crash:{self.plugin_id}") + html = (self._CRASH_PAGE + .replace("__REASON__", reason) + .replace("__URL__", plugin_url(self.plugin_id, self._entry_path))) + self.page().setHtml(html, QUrl(plugin_url(self.plugin_id, ""))) + + def shutdown(self): + """关页面前断开 WebChannel 并停掉 net 线程池(卸载插件时调用)。""" + try: + self._bridge.shutdown() + self._channel.deregisterObject(self._bridge) + self._channel.deleteLater() + self.page().setWebChannel(None) + except RuntimeError: + pass diff --git a/src/svfs_client/worker.py b/src/svfs_client/worker.py new file mode 100644 index 0000000..002d212 --- /dev/null +++ b/src/svfs_client/worker.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""后台线程服务:所有库操作都跑在一个专用 QThread 里(Qt 层)。 + +架构与 svfs_studio/worker.py 同款(这套模式在 Studio 里已被验证): + +* sqlite3 连接不能跨线程共享,所以 :class:`ClientService` 被整体 + ``moveToThread`` 到工作线程,**所有** SQLiteVFS 的创建与使用都发生在那里; +* 主线程只通过 :class:`Client` 的 ``submit(op, payload)`` 发请求, + 经 request-id 路由回 ``on_done`` / ``on_fail`` 回调; +* 变更类操作(安装/卸载/切换版本…)是**独占**的:正在忙时直接拒绝而不是排队; + 读取类操作(列表/详情/校验)永远可以并发; +* 库内部的 ``print()`` 进度输出经 :class:`LogSink` 转成 ``log`` 信号; +* 取消:在进度回调处抛 :class:`TaskCancelled`(不会打断已打开的数据库事务)。 +""" + +import contextlib +import io +import json +import os +import sys +import threading +import time +import traceback +import urllib.error +import urllib.request + +from PySide6.QtCore import QObject, QThread, Signal, Slot + +from . import (auth, credentials, diagnostics, installer, netutil, paths, + registry, sample, settings, __version__) + +__all__ = ["TaskCancelled", "user_message", "capture_output", "LogSink", + "ClientService", "Client"] + +#: 进度日志的行数上限(日志面板撑爆了会拖慢整个界面) +LOG_LINE_LIMIT = 4000 + +#: 同时只允许一个的变更类操作 +EXCLUSIVE_OPS = frozenset({ + "install_file", "install_url", "install_sample", + "uninstall", "set_enabled", "use_version", +}) + +#: 读取类操作(不独占) +READ_OPS = frozenset({"list_plugins", "plugin_detail", "verify"}) + + +class TaskCancelled(Exception): + """用户取消。""" + + +def user_message(exc): + """异常 → (标题, 详情)。界面绝不嗅探错误字符串,统一走这里。""" + if isinstance(exc, auth.AuthError): + return "登录失败", str(exc) + if isinstance(exc, (installer.InstallError, registry.RegistryError)): + return "插件操作失败", str(exc) + if isinstance(exc, PermissionError): + return "文件被占用", f"{exc}\n其他进程(或刚才的窗口)可能还开着这个包文件。" + return "出错了", f"{type(exc).__name__}: {exc}" + + +class LogSink(io.TextIOBase): + """把库 print() 的输出转成回调,供日志面板显示。""" + + def __init__(self, emit): + self._emit = emit + + def write(self, text): + if text and text.strip(): + self._emit(text.rstrip("\n")) + return len(text) + + +@contextlib.contextmanager +def capture_output(sink): + """处理请求期间把 stdout/stderr 重定向到日志(库的 print 进度输出)。""" + original_out, original_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = sink, sink + try: + yield + finally: + sys.stdout, sys.stderr = original_out, original_err + + +class ClientService(QObject): + """工作线程本体。信号一律从工作线程发出,Qt 自动排队到主线程。""" + + progress = Signal(int, int, str) # done, total, label(total=0 表示不定) + log = Signal(str) + finished = Signal(int, str, object) # req_id, op, payload + failed = Signal(int, str, str, str) # req_id, op, 标题, 详情 + + def __init__(self, base, parent=None): + super().__init__(parent) + self.base = base + self._cancel = threading.Event() + + # ---- 请求分发 -------------------------------------------------- + + @Slot(int, str, dict) + def dispatch(self, req_id, op, payload): + self._cancel.clear() + started = time.monotonic() + sink = LogSink(self.log.emit) + try: + handler = getattr(self, f"_op_{op}", None) + if handler is None: + raise LookupError(f"未知操作: {op}") + with capture_output(sink): + result = handler(payload or {}) + if isinstance(result, dict) and "elapsed" not in result: + result["elapsed"] = time.monotonic() - started + self.finished.emit(req_id, op, result) + except TaskCancelled: + self.failed.emit(req_id, op, "已中断", "操作在完成前被取消。") + except Exception as exc: + traceback.print_exc(file=sink) + title, detail = user_message(exc) + self.failed.emit(req_id, op, title, detail) + + def cancel(self): + self._cancel.set() + + def _check_cancel(self): + if self._cancel.is_set(): + raise TaskCancelled() + + def _progress(self): + def adapt(done, total, label=""): + self._check_cancel() + self.progress.emit(int(done), int(total), str(label)) + return adapt + + # ---- 操作实现 -------------------------------------------------- + + def _op_list_plugins(self, payload): + return {"plugins": self._snapshot_plugins()} + + def _snapshot_plugins(self): + """当前注册表 → 界面用的插件列表(带包路径、包大小与图标字节)。""" + entries = [] + for item in installer.list_installed(self.base): + row = dict(item) + package = paths.plugin_package(self.base, item["id"]) + row["package_path"] = package + row["storage_file"] = paths.storage_file(self.base, item["id"]) + if os.path.exists(package): + row["package_size"] = os.path.getsize(package) + row["icon_bytes"] = installer.read_icon(package, row.get("icon")) + entries.append(row) + return entries + + def _op_plugin_detail(self, payload): + plugin_id = payload["id"] + info = installer.inspect_package(paths.plugin_package(self.base, plugin_id)) + entry = registry.get(registry.load(self.base), plugin_id) + return { + "detail": { + "entry": dict(entry), + "manifest": info["manifest"], + "snapshots": info["snapshots"], + "verify": info["verify"], + "package_path": paths.plugin_package(self.base, plugin_id), + } + } + + def _op_install_file(self, payload): + entry = installer.install_from_file( + payload["path"], base=self.base, + check_content=payload.get("check_content", False), + progress=self._progress(), + ) + return self._after_change(entry, f"已安装 {entry['name']} {entry['version']}") + + def _op_install_url(self, payload): + entry = installer.install_from_url( + payload["url"], base=self.base, + check_content=payload.get("check_content", False), + progress=self._progress(), + # 对象缓存目录可在设置里配置(空 = 系统默认),每次安装现读 + cache_dir=settings.resolve_cache_dir(self.base), + ) + return self._after_change(entry, f"已安装 {entry['name']} {entry['version']}") + + def _op_install_sample(self, payload): + """安装/升级桥调试台插件(设置 → 桥调试台;完成后壳层会自动打开)。""" + package = paths.plugin_package(self.base, sample.PLUGIN_ID) + _, version = sample.build(package, progress=self._progress()) + entry = installer.install_from_file( + package, base=self.base, + source=("local", "桥调试台"), + ) + return self._after_change( + entry, f"调试台插件已就绪({version}),已打开新版页面" + ) + + def _op_uninstall(self, payload): + removed = installer.uninstall(self.base, payload["id"]) + return self._after_change(None, f"已卸载 {removed.get('name') or payload['id']}") + + def _op_set_enabled(self, payload): + entry = installer.set_enabled(self.base, payload["id"], payload["enabled"]) + state = "已启用" if payload["enabled"] else "已禁用" + return self._after_change(entry, f"{entry['name']} {state}") + + def _op_use_version(self, payload): + entry = installer.use_version(self.base, payload["id"], payload["snapshot_id"]) + return self._after_change(entry, f"已切换到 {entry['version']}(快照 {payload['snapshot_id']})") + + def _op_verify(self, payload): + from sqlite_vfs.core import SQLiteVFS + + package = paths.plugin_package(self.base, payload["id"]) + vfs = SQLiteVFS(package, readonly=True) + try: + report = vfs.verify(check_content=payload.get("check_content", False)) + finally: + vfs.close() + return {"verify": report, "id": payload["id"]} + + # ---- 登录认证(网络操作,与 SQLite 无关) ------------------------ + + def _op_login(self, payload): + result = auth.login( + payload.get("base_url") or "", + payload.get("username") or "", + payload.get("password") or "", + ) + return {"session": result} + + def _op_verify_auth(self, payload): + try: + result = auth.verify_token( + payload.get("base_url") or "", + payload.get("token") or "", + ) + except auth.AuthError as exc: + if getattr(exc, "network", False): + # 服务器不可达(不是凭据问题):交给启动链决定离线宽限 + return {"session": None, "offline": True, "reason": str(exc)} + raise + return {"session": {**result, "token": payload.get("token") or ""}} + + def _op_logout(self, payload): + auth.logout(payload.get("base_url") or "", payload.get("token") or "") + return {"ok": True} + + # ---- 服务器状态 / 更新检查 --------------------------------------- + + def _server_base(self): + return settings.normalize_base_url( + settings.load(self.base).get("base_url") or "") + + def _op_ping_server(self, payload): + """服务器连通性探测(服务器卡片的状态灯用)。 + + 401/403 单独标记成 ``auth_rejected``:服务器是活的,只是登录会话 + 失效了(服务端重启、token 过期)——报"离线"会把用户引到错误的 + 排查方向(去查网络),所以壳层对这两种情况显示不同的灯与文案。 + """ + base = self._server_base() + if not base: + return {"online": False, "error": "未配置服务器地址"} + started = time.monotonic() + try: + request = urllib.request.Request(f"{base}/api/ping") + token = credentials.load(self.base, base) + if token: + request.add_header("Authorization", f"Bearer {token}") + with netutil.urlopen(request, timeout=5) as response: + response.read(64 * 1024) + return {"online": True, + "latency_ms": int((time.monotonic() - started) * 1000)} + except urllib.error.HTTPError as exc: + latency = int((time.monotonic() - started) * 1000) + if exc.code in (401, 403): + diagnostics.log(f"服务器在线但登录已失效(HTTP {exc.code})", + "auth") + return {"online": True, "auth_rejected": True, + "latency_ms": latency, "status": int(exc.code)} + diagnostics.log(f"服务器探测失败: HTTP {exc.code}", "server") + return {"online": False, "error": f"服务器返回 HTTP {exc.code}"} + except Exception as exc: + diagnostics.log(f"服务器探测失败: {exc}", "server") + return {"online": False, "error": str(exc)} + + def _op_check_client_update(self, payload): + """查服务器上推荐的客户端版本:``/client/manifest.json``。 + + 与插件清单同一套静态托管方式,**不需要登录**;服务器没放这个文件 + (404)就是"没有更新信息",静默跳过。插件有更新检查、客户端原先 + 没有——这是个纯增量的可选能力。 + """ + base = self._server_base() + if not base: + return {"update": False, "checked": False, + "error": "未配置服务器地址"} + request = urllib.request.Request(f"{base}/client/manifest.json") + request.add_header("Accept", "application/json") + try: + with netutil.open_with_retry(request, timeout=10, + retries=1) as response: + data = json.loads(response.read(256 * 1024)) + except urllib.error.HTTPError as exc: + if exc.code != 404: + diagnostics.log(f"检查客户端更新失败: HTTP {exc.code}", "updates") + return {"update": False, "checked": False} + except Exception as exc: + diagnostics.log(f"检查客户端更新失败: {exc}", "updates") + return {"update": False, "checked": False, "error": str(exc)} + result = installer.client_update_status(data, __version__) + result["checked"] = True + return result + + def _op_check_updates(self, payload): + """逐个插件查服务器上的最新版本(快照名),失败的单个跳过。""" + base = self._server_base() + updates = {} + checked = 0 + if not base: + return {"updates": updates, "checked": 0} + for entry in installer.list_installed(self.base): + plugin_id = entry["id"] + try: + request = urllib.request.Request( + f"{base}/{plugin_id}/manifest.json") + request.add_header("Accept", "application/json") + with netutil.urlopen(request, timeout=10) as response: + manifest = json.loads(response.read(1024 * 1024)) + checked += 1 + remote = (manifest.get("snapshot") or {}).get("name") or "" + if remote and installer.is_newer_version( + remote, entry.get("version") or ""): + updates[plugin_id] = remote + except Exception as exc: + diagnostics.log(f"检查 {plugin_id} 更新失败: {exc}", "updates") + continue + return {"updates": updates, "checked": checked} + + # ---- 收尾 ------------------------------------------------------ + + def _after_change(self, entry, message): + """所有变更操作共用的返回:刷新后的完整插件列表 + 本次操作说明。""" + return {"plugins": self._snapshot_plugins(), "message": message, + "changed": entry} + + +class Client(QObject): + """主线程门面:submit() 发请求,on_done / on_fail 收结果。 + + 用法与 svfs_studio 的 StudioClient 一致;独占操作在忙时**拒绝**而非排队。 + """ + + _dispatch_signal = Signal(int, str, dict) # 跨线程投递到服务的 dispatch + progress = Signal(int, int, str) + log = Signal(str) + busy_changed = Signal(bool, str) + + def __init__(self, base, parent=None): + super().__init__(parent) + self.base = base + # 注意:service 不能带 parent——带 parent 的 QObject 无法 moveToThread + self.service = ClientService(base) + self._thread = None + self._pending = {} # req_id -> (op, on_done, on_fail) + self._req_seq = 0 + self._exclusive_running = False + + def attach_to(self, thread): + """把服务挂到工作线程并启动(QThread 由调用方持有与销毁)。""" + from PySide6.QtCore import Qt + + self._thread = thread + self.service.moveToThread(thread) + self._dispatch_signal.connect( + self.service.dispatch, Qt.ConnectionType.QueuedConnection + ) + self.service.finished.connect(self._on_finished) + self.service.failed.connect(self._on_failed) + self.service.progress.connect(self.progress) + self.service.log.connect(self.log) + thread.start() + + def submit(self, op, payload=None, on_done=None, on_fail=None, label=None): + """发出一个请求,返回 request id。忙时提交独占操作会被拒绝。""" + if op in EXCLUSIVE_OPS and self._exclusive_running: + if on_fail: + on_fail("正忙", "上一个变更操作还在进行中,请稍后再试。") + return -1 + self._req_seq += 1 + req_id = self._req_seq + self._pending[req_id] = (op, on_done, on_fail) + self._dispatch_signal.emit(req_id, op, payload or {}) + if op in EXCLUSIVE_OPS: + self._exclusive_running = True + self.busy_changed.emit(True, label or op) + return req_id + + def cancel(self): + self.service.cancel() + + def call_blocking(self, op, payload=None): + """测试专用:跳过线程在当前线程同步执行服务操作。""" + return getattr(self.service, f"_op_{op}")(payload or {}) + + # ---- 结果路由 -------------------------------------------------- + + def _release(self, op): + if op in EXCLUSIVE_OPS: + self._exclusive_running = False + self.busy_changed.emit(False, "") + + def _on_finished(self, req_id, op, payload): + _op, on_done, _on_fail = self._pending.pop(req_id, (op, None, None)) + self._release(op) + if on_done: + on_done(payload) + + def _on_failed(self, req_id, op, title, detail): + _op, _on_done, on_fail = self._pending.pop(req_id, (op, None, None)) + self._release(op) + if on_fail: + on_fail(title, detail) diff --git a/src/svfs_studio/__init__.py b/src/svfs_studio/__init__.py new file mode 100644 index 0000000..d9c0c2b --- /dev/null +++ b/src/svfs_studio/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""SVFS Studio —— sqlite_vfs 的桌面可视化工具(PySide6)。 + +这一层只放包级常量,不 import Qt:``format`` 与 ``analysis`` 两个模块是纯标准库的, +可以脱离 PySide6 单独测试(tests/test_studio_logic.py 就是这么做的)。 +""" + +#: 工具版本,与 sqlite-vfs 的版本号分开走(工具可以随界面改版单独升) +__version__ = "0.1.0" + +#: 窗口标题 +APP_NAME = "SVFS Studio" diff --git a/src/svfs_studio/__main__.py b/src/svfs_studio/__main__.py new file mode 100644 index 0000000..8a8e2ed --- /dev/null +++ b/src/svfs_studio/__main__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""``python -m svfs_studio`` / ``svfs-studio`` 的入口。 + +缺 PySide6 时给一句人话提示而不是一段栈——GUI 依赖是可选 extra,没装是正常状态。 +""" + +import sys + + +def main(): + try: + from .app import main as run + except ImportError as exc: # 最常见的就是没装 PySide6 + missing = getattr(exc, "name", "") or str(exc) + if "PySide6" in str(exc) or "PySide6" in missing: + print( + "SVFS Studio 需要 PySide6(核心库本身不需要任何第三方依赖)。\n" + "请任选一种方式安装:\n" + ' uv sync --extra gui (在本仓库里)\n' + ' uv pip install "sqlite-vfs[gui]"\n' + ' pip install PySide6\n' + "想省点磁盘的话,PySide6-Essentials 也够用(少了 WebEngine/3D 那些模块)。", + file=sys.stderr, + ) + return 1 + raise + return run() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/svfs_studio/analysis.py b/src/svfs_studio/analysis.py new file mode 100644 index 0000000..bfd157f --- /dev/null +++ b/src/svfs_studio/analysis.py @@ -0,0 +1,721 @@ +#!/usr/bin/env python3 +"""分析层:把库里的行变成界面要显示的结构(纯标准库,不依赖 Qt)。 + +设计上刻意只吃"朴素数据"(谓词都是 ``all_entries()`` 那样的 dict 列表、blob 列表、 +外加一个只读 sqlite3 连接),所以这些逻辑可以脱离 PySide6、也脱离真实 .svfs 文件 +单测。 + +几个口径需要留意,它们都会出现在界面上: + +* **快照差异里目录的 mtime 不参与比较**:往目录里加个文件就会改它的 mtime, + 拿它当"变更"会把差异视图刷满噪音。目录只比权限。 +* **重复内容按"同一快照内"统计**:跨快照的同一文件必然"重复",那是版本历史, + 不是浪费。 +* **占用统计有两套口径**:``stored_size``(blob 实际字节)与库文件大小(含页开销、 + 索引、被 GC 掉的空洞),两者不相等是正常的,界面上要分别显示。 +""" + +import hashlib +import os +import sqlite3 + +#: 本地目录对比时跳过这些(都是 VCS/编辑器产物,比对没有意义) +DEFAULT_SKIP_DIRS = frozenset( + {".git", ".hg", ".svn", "__pycache__", ".idea", ".vscode", "node_modules", ".venv"} +) + + +# ---------------------------------------------------------------------- +# 存储汇总 / 分布 +# ---------------------------------------------------------------------- + + +def storage_summary(entries, blobs, db_file_size=None, db_path=None): + """算「空间去哪了」这条链上每一环的数字。 + + 逻辑大小 → 去重后唯一内容 → 压缩后库内占用 → 库文件实际大小, + 每一环的差值就是去重、压缩、页开销各自吃掉的部分。 + """ + files = [row for row in entries if not row.get("is_directory")] + logical_size = sum(row.get("file_size") or 0 for row in files) + unique_bytes = sum(row.get("size") or 0 for row in blobs) + stored_bytes = sum(row.get("stored_size") or 0 for row in blobs) + referenced = sum(1 for row in files if row.get("content_hash")) + + if db_file_size is None and db_path: + try: + db_file_size = os.path.getsize(db_path) + except OSError: + db_file_size = None + + return { + "total_files": len(files), + "total_dirs": len(entries) - len(files), + "total_entries": len(entries), + "logical_size": logical_size, + "unique_bytes": unique_bytes, + "stored_bytes": stored_bytes, + "db_file_size": db_file_size, + # 去重省下的:逻辑大小 - 唯一内容大小 + "dedup_saved": max(logical_size - unique_bytes, 0), + # 压缩省下的:唯一内容大小 - 库内实际存的内容字节 + "compression_saved": max(unique_bytes - stored_bytes, 0), + # 页开销/索引/空闲页:库文件大小 - 内容字节 + "db_overhead": ( + None if db_file_size is None else db_file_size - stored_bytes + ), + "blob_count": len(blobs), + "referenced_blobs": referenced, + "unreferenced_blobs": max(len(blobs) - _distinct_hashes(files), 0), + } + + +def _distinct_hashes(files): + return len({row["content_hash"] for row in files if row.get("content_hash")}) + + +def algo_distribution(blobs): + """按压缩算法汇总占用,给环图用。 + + 返回 ``[{algo, count, size, stored_size}]``,按 stored_size 从大到小。 + """ + buckets = {} + for row in blobs: + algo = row.get("compress_algo") or "none" + bucket = buckets.setdefault( + algo, {"algo": algo, "count": 0, "size": 0, "stored_size": 0} + ) + bucket["count"] += 1 + bucket["size"] += row.get("size") or 0 + bucket["stored_size"] += row.get("stored_size") or 0 + return sorted(buckets.values(), key=lambda item: item["stored_size"], reverse=True) + + +def top_files(entries, blobs_by_hash=None, limit=15): + """最大的若干个文件,附带它在库里的实际占用与压缩率。""" + blobs_by_hash = blobs_by_hash or {} + files = [row for row in entries if not row.get("is_directory")] + files.sort(key=lambda row: row.get("file_size") or 0, reverse=True) + result = [] + for row in files[:limit]: + blob = blobs_by_hash.get(row.get("content_hash")) or {} + result.append( + { + "path": row.get("path"), + "size": row.get("file_size") or 0, + "stored_size": blob.get("stored_size"), + "algo": blob.get("compress_algo") or "-", + } + ) + return result + + +def duplicate_groups(entries, blobs_by_hash=None, limit=None): + """同一快照内多个路径共用一份内容的组(内容去重的收益就在这里)。 + + 按浪费的字节从大到小排序:``wasted_bytes = size * (路径数 - 1)``。 + """ + blobs_by_hash = blobs_by_hash or {} + groups = {} + for row in entries: + if row.get("is_directory"): + continue + content_hash = row.get("content_hash") + if not content_hash: + continue + groups.setdefault(content_hash, []).append(row.get("path")) + + result = [] + for content_hash, paths in groups.items(): + if len(paths) < 2: + continue + blob = blobs_by_hash.get(content_hash) or {} + size = blob.get("size") + if size is None: + size = 0 + result.append( + { + "hash": content_hash, + "size": size, + "refcount": len(paths), + "paths": sorted(path for path in paths if path), + "wasted_bytes": size * (len(paths) - 1), + } + ) + result.sort(key=lambda item: item["wasted_bytes"], reverse=True) + if limit is not None: + result = result[:limit] + return result + + +def refcounts(conn): + """每个 blob 被多少条 files 记录引用(跨全部快照,读路径统计)。 + + 这里直接查库而不是调库 API:SQLiteVFS 只按路径给 blob 信息,没有"这个内容的 + 引用计数"这种口径,而引用计数正是 GC 语义的关键,值得单独查一次。 + """ + rows = conn.execute( + """ + SELECT content_hash AS hash, COUNT(*) AS refs + FROM files + WHERE content_hash IS NOT NULL + GROUP BY content_hash + """ + ).fetchall() + return {row["hash"]: row["refs"] for row in rows} + + +def orphan_blobs(conn): + """未被任何快照引用的 blob(``gc_blobs()`` 能回收掉的那些)。""" + row = conn.execute( + """ + SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS size, + COALESCE(SUM(stored_size), 0) AS stored_size + FROM blobs + WHERE hash NOT IN ( + SELECT content_hash FROM files WHERE content_hash IS NOT NULL + ) + """ + ).fetchone() + return { + "count": row["count"], + "size": row["size"], + "stored_size": row["stored_size"], + } + + +def snapshot_stats(vfs, snapshot_id=None): + """某个快照的条目分布:文件数、目录数、逻辑大小、共享 blob 数。 + + "共享 blob"指这个快照引用的内容里有几份是别处(别的快照)也在用的——两个快照 + 有共同文件时,新版几乎不占额外空间,这个数字就是证据。 + """ + entries = vfs.all_entries(snapshot_id) + files = [row for row in entries if not row.get("is_directory")] + + local = {} + for row in files: + content_hash = row.get("content_hash") + if content_hash: + local[content_hash] = local.get(content_hash, 0) + 1 + + shared = 0 + shared_bytes = 0 + if local: + rows = vfs.conn.execute( + """ + SELECT content_hash AS hash, COUNT(*) AS refs, MIN(file_size) AS size + FROM files + WHERE content_hash IS NOT NULL + GROUP BY content_hash + """ + ).fetchall() + for row in rows: + content_hash = row["hash"] + # 全库引用数 > 本快照内的路径数 → 多出来的那份来自其它快照 + if content_hash in local and row["refs"] > local[content_hash]: + shared += 1 + shared_bytes += row["size"] or 0 + + return { + "files": len(files), + "dirs": len(entries) - len(files), + "logical_size": sum(row.get("file_size") or 0 for row in files), + "blob_count": len(local), + "shared_blobs": shared, + "shared_bytes": shared_bytes, + } + + +# ---------------------------------------------------------------------- +# 快照差异 +# ---------------------------------------------------------------------- + +#: 差异分类,界面按这个顺序显示 +DIFF_KINDS = ("added", "removed", "content", "type", "meta") + +DIFF_LABELS = { + "added": "新增", + "removed": "删除", + "content": "内容变更", + "type": "类型变更", + "meta": "仅元数据变更", +} + + +def snapshot_diff(entries_a, entries_b): + """比较两个快照的条目集合。 + + ``entries_a`` 是旧版、``entries_b`` 是新版。返回:: + + { + "added": [{path, old: None, new: row}], + "removed": [{path, old: row, new: None}], + "content": [{path, old: row, new: row, size_delta}], # hash 变了 + "type": [...], # 文件 ↔ 目录 + "meta": [...], # hash 相同,元数据变了 + "unchanged": int, + "counts": {kind: n}, + } + """ + map_a = {row["path"]: row for row in entries_a} + map_b = {row["path"]: row for row in entries_b} + + result = {kind: [] for kind in DIFF_KINDS} + result["unchanged"] = 0 # type: ignore + + for path, row_a in map_a.items(): + row_b = map_b.get(path) + if row_b is None: + result["removed"].append({"path": path, "old": row_a, "new": None}) + continue + kind = _classify(row_a, row_b) + if kind is None: + result["unchanged"] += 1 # type: ignore + continue + size_delta = (row_b.get("file_size") or 0) - (row_a.get("file_size") or 0) + result[kind].append( + {"path": path, "old": row_a, "new": row_b, "size_delta": size_delta} + ) + + for path, row_b in map_b.items(): + if path not in map_a: + result["added"].append({"path": path, "old": None, "new": row_b}) + + for kind in DIFF_KINDS: + result[kind].sort(key=lambda item: item["path"]) + result["counts"] = {kind: len(result[kind]) for kind in DIFF_KINDS} # type: ignore + result["total_changed"] = sum(result["counts"].values()) # type: ignore + return result + + +def _classify(row_a, row_b): + """两个同路径条目的差异类型;返回 None 表示没变。""" + dir_a = bool(row_a.get("is_directory")) + dir_b = bool(row_b.get("is_directory")) + if dir_a or dir_b: + if dir_a != dir_b: + return "type" + # 目录只比权限:往里加个文件就会改 mtime,比它等于制造噪音 + if (row_a.get("permissions") or 0) != (row_b.get("permissions") or 0): + return "meta" + return None + + if row_a.get("content_hash") != row_b.get("content_hash"): + return "content" + if (row_a.get("file_size") or 0) != (row_b.get("file_size") or 0): + # 内容哈希相同但大小不同属于库本身有问题,归到"内容变更"里让人看得见 + return "content" + for field in ("modified_time", "permissions"): + if _field(row_a, field) != _field(row_b, field): + return "meta" + return None + + +def _field(row, field): + value = row.get(field) + if field == "modified_time": + return value.isoformat() if hasattr(value, "isoformat") else value + return value + + +# ---------------------------------------------------------------------- +# 本地目录 vs 快照 +# ---------------------------------------------------------------------- + +LOCAL_KINDS = ("identical", "content", "meta", "only_local", "only_pack", "type") + +LOCAL_LABELS = { + "identical": "一致", + "content": "内容不同", + "meta": "仅元数据不同", + "only_local": "仅本地存在", + "only_pack": "仅包内存在", + "type": "类型不同", +} + + +def local_vs_snapshot_diff( + local_root, entries, read_content=None, progress=None, skip_dirs=DEFAULT_SKIP_DIRS +): + """把本地目录和快照内容对一遍。 + + ``read_content(path) -> bytes`` 传进来是为了能在测试里脱离真实库;不传就只比 + 大小与 mtime(快,但发现不了"同大小不同内容")。 + + ``progress(done, total, path)`` 会按本地文件数回调。 + """ + pack_map = { + row["path"]: row for row in entries if not row.get("is_directory") + } + pack_dirs = {row["path"] for row in entries if row.get("is_directory")} + + local_files = [] + local_dirs = set() + for base, dirs, files in os.walk(local_root): + dirs[:] = [name for name in dirs if name not in skip_dirs] + rel_base = os.path.relpath(base, local_root).replace(os.sep, "/") + if rel_base == ".": + rel_base = "" + for name in dirs: + path = f"{rel_base}/{name}".strip("/") + local_dirs.add(path) + for name in files: + abs_path = os.path.join(base, name) + rel_path = f"{rel_base}/{name}".strip("/") + local_files.append((abs_path, rel_path)) + + total = len(local_files) + result = {kind: [] for kind in LOCAL_KINDS} + seen = set() + + for index, (abs_path, rel_path) in enumerate(sorted(local_files, key=lambda item: item[1])): + seen.add(rel_path) + row = pack_map.get(rel_path) + if progress is not None: + progress(index + 1, total, rel_path) + if row is None: + kind = "type" if rel_path in pack_dirs else "only_local" + result[kind].append( + { + "path": rel_path, + "local": _local_info(abs_path), + "pack": None if kind == "only_local" else {"is_directory": True}, + } + ) + continue + + local = _local_info(abs_path) + item = {"path": rel_path, "local": local, "pack": row} + if read_content is not None: + try: + pack_bytes = read_content(rel_path) + except Exception as exc: # 读不出来也要给出结论,而不是整个页崩掉 + item["error"] = str(exc) + result["content"].append(item) + continue + pack_hash = hashlib.sha256(pack_bytes).hexdigest() + if pack_hash != row.get("content_hash"): + item["note"] = "库内记录的哈希与内容不一致" + if pack_bytes != _read_local(abs_path): + result["content"].append(item) + continue + elif (row.get("file_size") or 0) != local["size"]: + result["content"].append(item) + continue + + if not _meta_matches(row, local): + result["meta"].append(item) + else: + result["identical"].append(item) + + for rel_path, row in pack_map.items(): + if rel_path not in seen: + result["only_pack"].append({"path": rel_path, "local": None, "pack": row}) + + for kind in LOCAL_KINDS: + result[kind].sort(key=lambda item: item["path"]) + result["counts"] = {kind: len(result[kind]) for kind in LOCAL_KINDS} # type: ignore + result["total_local"] = total # type: ignore + result["total_pack"] = len(pack_map) # type: ignore + result["ok"] = not any( # type: ignore + result["counts"][kind] for kind in ("content", "only_local", "only_pack", "type") + ) + return result + + +def _read_local(abs_path): + with open(abs_path, "rb") as handle: + return handle.read() + + +def _local_info(abs_path): + stat = os.stat(abs_path) + return { + "size": stat.st_size, + "mtime": stat.st_mtime, + "permissions": stat.st_mode & 0o777, + } + + +def _meta_matches(row, local): + """mtime 用 1 秒容差(与 folder_packer 的 _MTIME_TOLERANCE 一个口径)。""" + pack_time = row.get("modified_time") + if hasattr(pack_time, "timestamp"): + pack_time = pack_time.timestamp() + if pack_time is not None: + try: + if abs(float(pack_time) - local["mtime"]) > 1.0: + return False + except (TypeError, ValueError): + pass + if (row.get("permissions") or 0) != local["permissions"]: + return False + return True + + +# ---------------------------------------------------------------------- +# SQLite 内部结构 +# ---------------------------------------------------------------------- + +#: SQLite 数据库头的字段表:(偏移, 长度, 字段名, 说明) +HEADER_FIELDS = ( + (16, 2, "page_size", "页大小"), + (18, 1, "write_version", "写入格式版本"), + (19, 1, "read_version", "读取格式版本"), + (20, 1, "reserved_bytes", "每页保留字节"), + (21, 3, "max_payload", "最大载荷比例"), + (24, 4, "change_counter", "文件变更计数"), + (28, 4, "page_count", "页数"), + (32, 4, "freelist_first", "空闲页链表首页"), + (36, 4, "freelist_count", "空闲页数"), + (40, 4, "schema_cookie", "schema cookie"), + (44, 4, "schema_format", "schema 格式号"), + (48, 4, "default_cache_size", "默认页缓存大小"), + (52, 4, "largest_root_page", "最大根页(自动清理用)"), + (56, 4, "text_encoding", "文本编码"), + (60, 4, "user_version", "user_version"), + (64, 4, "incremental_vacuum", "增量 vacuum 标记"), + (68, 4, "application_id", "application_id"), + (72, 20, "reserved_space", "保留区"), + (92, 4, "version_valid_for", "版本有效编号"), + (96, 4, "sqlite_version", "创建此库的 SQLite 版本号"), +) + +_SQLITE_MAGIC = b"SQLite format 3\x00" + + +def read_header(db_path): + """解析 SQLite 数据库头的前 100 字节。""" + with open(db_path, "rb") as handle: + raw = handle.read(100) + if len(raw) < 100: + return {"valid": False, "error": f"文件太小({len(raw)} 字节),不是完整的 SQLite 库"} + if raw[:16] != _SQLITE_MAGIC: + return {"valid": False, "error": "文件头不是 SQLite 格式(前 16 字节不匹配)"} + + fields = {} + for offset, length, name, label in HEADER_FIELDS: + chunk = raw[offset : offset + length] + if length == 1: + value = chunk[0] + elif length == 4: + value = int.from_bytes(chunk, "big") + else: + value = int.from_bytes(chunk, "big") + if name == "page_size" and value == 1: + # SQLite 用 1 表示 65536,这是头里唯一一个需要解释的特殊值 + value = 65536 + fields[name] = value + + # 文本编码:1 = UTF-8,2 = UTF-16le,3 = UTF-16be + fields["text_encoding_name"] = {1: "UTF-8", 2: "UTF-16le", 3: "UTF-16be"}.get( + fields.get("text_encoding"), "未知" # type: ignore + ) # type: ignore + fields["version"] = { + 1: "legacy (1)", + 2: "WAL (2)", + }.get(fields.get("write_version"), str(fields.get("write_version"))) # type: ignore + fields["sqlite_version_text"] = ( + f"{fields['sqlite_version'] // 1000000}." + f"{(fields['sqlite_version'] // 1000) % 1000}." + f"{fields['sqlite_version'] % 1000}" + ) + fields["valid"] = True + return fields + + +def sqlite_internals(conn, db_path): + """内部结构页要的全部数据:头字段 + pragma + 表清单 + 占用。 + + ``dbstat`` 虚拟表在部分构建里没有编译进来,那时只能给出行数与页级估算, + 所以返回的 ``occupancy_mode`` 会明确写出当前是哪种口径,避免把估算当精确值。 + """ + info = {"header": read_header(db_path), "ok": True} + try: + info["pragmas"] = { + "page_size": _scalar(conn, "PRAGMA page_size"), + "page_count": _scalar(conn, "PRAGMA page_count"), + "freelist_count": _scalar(conn, "PRAGMA freelist_count"), + "journal_mode": _scalar(conn, "PRAGMA journal_mode"), + "auto_vacuum": _scalar(conn, "PRAGMA auto_vacuum"), + "encoding": _scalar(conn, "PRAGMA encoding"), + "user_version": _scalar(conn, "PRAGMA user_version"), + "application_id": _scalar(conn, "PRAGMA application_id"), + } + except sqlite3.Error as exc: + info["ok"] = False + info["error"] = f"读取 PRAGMA 失败: {exc}" + info["pragmas"] = {} + info["objects"] = [] + info["occupancy"] = [] + info["occupancy_mode"] = "unknown" + return info + + objects = [] + try: + rows = conn.execute( + "SELECT type, name, tbl_name, rootpage, sql FROM sqlite_master " + "WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name" + ).fetchall() + for row in rows: + objects.append( + { + "type": row["type"], + "name": row["name"], + "table": row["tbl_name"], + "rootpage": row["rootpage"], + "sql": row["sql"] or "", + } + ) + except sqlite3.Error as exc: + info["ok"] = False + info["error"] = f"读取 sqlite_master 失败: {exc}" + + occupancy, mode = _occupancy(conn, objects) + info["objects"] = objects + info["occupancy"] = occupancy + info["occupancy_mode"] = mode + + page_size = info["pragmas"].get("page_size") or 0 + page_count = info["pragmas"].get("page_count") or 0 + freelist = info["pragmas"].get("freelist_count") or 0 + info["total_bytes"] = page_size * page_count + info["free_bytes"] = page_size * freelist + info["used_bytes"] = info["total_bytes"] - info["free_bytes"] + return info + + +def _occupancy(conn, objects): + """各表占用。dbstat 可用就是精确值,否则回退成"行数 + 无占用"。""" + try: + rows = conn.execute( + """ + SELECT name, SUM(pgsize) AS bytes, COUNT(*) AS pages + FROM dbstat + GROUP BY name + ORDER BY bytes DESC + """ + ).fetchall() + return [ + { + "name": row["name"], + "bytes": row["bytes"], + "pages": row["pages"], + "rows": _count_rows(conn, row["name"], objects), + } + for row in rows + ], "dbstat" + except sqlite3.Error: + # 没有 dbstat:只给行数,占用一列留空(界面上会标注"当前无精确占用统计") + result = [] + for obj in objects: + if obj["type"] != "table": + continue + count = _count_rows(conn, obj["name"], objects) + result.append({"name": obj["name"], "bytes": None, "pages": None, "rows": count}) + return result, "rowcount" + + +def _count_rows(conn, name, objects): + """数一张表的行数;dbstat 里也会出现索引名,那些不算行数。""" + table_names = {obj["name"] for obj in objects if obj["type"] == "table"} + if name not in table_names: + return None + try: + return _scalar(conn, f"SELECT COUNT(*) FROM {_quote(name)}") + except sqlite3.Error: + return None + + +def _quote(name): + return '"' + str(name).replace('"', '""') + '"' + + +def _scalar(conn, sql): + row = conn.execute(sql).fetchone() + if row is None: + return None + return row[0] + + +# ---------------------------------------------------------------------- +# SQL 控制台 +# ---------------------------------------------------------------------- + +#: 只读控制台允许的语句开头(连接本身也是 mode=ro,这里是第一道闸) +READONLY_PREFIXES = ("select", "with", "pragma", "explain") + + +def check_readonly_sql(sql): + """检查一条 SQL 能不能在只读控制台里跑。 + + 只做"看起来是不是单条只读语句"的判断,真正的兜底是 ``mode=ro`` 连接本身: + 就算这里放过了一条写语句,SQLite 也会直接拒绝。 + """ + text = (sql or "").strip() + if not text: + return False, "语句为空" + while text.endswith(";"): + text = text[:-1].strip() + if not text: + return False, "语句为空" + if ";" in text: + return False, "只允许单条语句(检测到分号分隔的多条)" + lowered = text.lstrip("(").lower() + if not lowered.startswith(READONLY_PREFIXES): + return False, "只读控制台只允许 SELECT / WITH / PRAGMA / EXPLAIN" + return True, "" + + +def run_readonly_query(conn, sql, limit=500): + """执行查询并返回 ``(columns, rows, truncated)``。""" + cursor = conn.execute(sql) + if cursor.description is None: + return [], [], False + columns = [item[0] for item in cursor.description] + rows = cursor.fetchmany(limit + 1) + truncated = len(rows) > limit + return columns, [tuple(row) for row in rows[:limit]], truncated + + +#: 预置查询:都是"想看某一层的时候第一反应会问的问题" +SQL_PRESETS = ( + ( + "快照列表", + "SELECT id, name, parent_id, total_files, total_size, digest\n" + " FROM snapshots ORDER BY id", + ), + ( + "重复内容 TOP 20", + "SELECT content_hash, COUNT(*) AS 路径数, MIN(file_size) AS 大小\n" + " FROM files WHERE content_hash IS NOT NULL\n" + " GROUP BY content_hash HAVING COUNT(*) > 1\n" + " ORDER BY 大小 * (COUNT(*) - 1) DESC LIMIT 20", + ), + ( + "未被引用的 blob", + "SELECT hash, size, stored_size, compress_algo FROM blobs\n" + " WHERE hash NOT IN (SELECT content_hash FROM files WHERE content_hash IS NOT NULL)", + ), + ( + "各算法占用", + "SELECT compress_algo, COUNT(*) AS 数量, SUM(size) AS 原始字节, SUM(stored_size) AS 库内字节\n" + " FROM blobs GROUP BY compress_algo ORDER BY 库内字节 DESC", + ), + ( + "每个快照的条目数", + "SELECT snapshot_id, COUNT(*) AS 条目, SUM(is_directory = 0) AS 文件\n" + " FROM files GROUP BY snapshot_id ORDER BY snapshot_id", + ), + ( + "签名记录", + "SELECT id, snapshot_id, algorithm, key_id, signed_time FROM signatures ORDER BY id", + ), + ( + "表与索引", + "SELECT type, name, tbl_name, rootpage FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'", + ), +) + + diff --git a/src/svfs_studio/app.py b/src/svfs_studio/app.py new file mode 100644 index 0000000..452737c --- /dev/null +++ b/src/svfs_studio/app.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""QApplication 引导:装主题、建主窗口、可选地把一批截图渲染出来。 + +截图模式(``--shots DIR``)是给"没有肉眼"的场景用的:在 offscreen 平台上把各标签页 +抓成 PNG,用来核对暗色主题、间距与对齐。 +""" + +import os +import sys + +from PySide6.QtCore import QSize, Qt, QTimer +from PySide6.QtGui import QIcon, QPainter, QPixmap +from PySide6.QtWidgets import QApplication + +from . import APP_NAME, __version__, theme +from .widgets.shell import MainWindow + + +def app_icon(): + """应用图标:仓库里有 logo 就用它,没有就现画一个(不引入图标资源依赖)。""" + from .sample import repo_root + + for name in ("resources/ico/logo.ico", "resources/images/logo.png"): + path = os.path.join(repo_root(), *name.split("/")) + if os.path.exists(path): + icon = QIcon(path) + if not icon.isNull(): + return icon + + pixmap = QPixmap(QSize(64, 64)) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setBrush(theme.qcolor(theme.ACCENT)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(4, 4, 56, 56, 14, 14) + painter.setPen(theme.qcolor(theme.BG)) + font = theme.font_ui(theme.SIZE_HERO + 8) + font.setBold(True) + painter.setFont(font) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "S") + painter.end() + return QIcon(pixmap) + + +def create_app(argv=None): + argv = list(argv if argv is not None else sys.argv) + app = QApplication(argv) + app.setApplicationName(APP_NAME) + app.setApplicationVersion(__version__) + app.setOrganizationName("svfs-studio") + theme.apply_theme(app) + app.setWindowIcon(app_icon()) + return app + + +def main(argv=None): + argv = list(argv if argv is not None else sys.argv[1:]) + shots_dir = None + package = None + index = 0 + while index < len(argv): + item = argv[index] + if item == "--shots" and index + 1 < len(argv): + shots_dir = argv[index + 1] + index += 2 + continue + if item.startswith("--shots="): + shots_dir = item.split("=", 1)[1] + index += 1 + continue + if package is None: + package = item + index += 1 + + app = create_app([sys.argv[0]]) + window = MainWindow() + window.show() + + if package: + QTimer.singleShot(0, lambda: window.open_package(os.path.abspath(package))) + if shots_dir: + from .shots import schedule_shots + + schedule_shots(app, window, shots_dir, package) + return app.exec() + + +__all__ = ["APP_NAME", "__version__", "create_app", "main", "app_icon"] diff --git a/src/svfs_studio/format.py b/src/svfs_studio/format.py new file mode 100644 index 0000000..821e96c --- /dev/null +++ b/src/svfs_studio/format.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""显示层格式化工具(纯标准库,不依赖 Qt)。 + +这里的函数都被测试直接钉住,因为"大小/时间/权限显示错"是那种看着不报错、 +但会让人把 1.5 MB 读成 15 MB 的问题。 +""" + +import codecs +import datetime + +#: human_size 用的单位(1024 进制) +UNITS = ("B", "KB", "MB", "GB", "TB", "PB") + +#: 认得出是文本就先按这些编码试,最后 latin-1 兜底(它永远不失败) +TEXT_ENCODINGS = ("utf-8", "gbk") + +#: 采样多少字节来判断是不是二进制 +SNIFF_SIZE = 8192 + +#: 文本字节里允许出现的控制字符(\t \n \r \f \b),其余控制字符都算"非文本" +_ALLOWED_CONTROL = frozenset(b"\t\n\r\f\b") + + +def human_size(size, precision=2): + """字节数转人类可读,1024 进制(与 README 里的口径一致)。""" + if size is None: + return "-" + try: + value = float(size) + except (TypeError, ValueError): + return "-" + negative = value < 0 + value = abs(value) + index = 0 + while value >= 1024 and index < len(UNITS) - 1: + value /= 1024.0 + index += 1 + if index == 0: + text = f"{int(value)} B" + else: + text = f"{value:.{precision}f} {UNITS[index]}" + return f"-{text}" if negative else text + + +def human_count(number): + """给大数字加千分位,避免 1234567 这种读不出量级的显示。""" + if number is None: + return "-" + try: + return f"{int(number):,}" + except (TypeError, ValueError): + return str(number) + + +def human_time(value, fmt="%Y-%m-%d %H:%M"): + """datetime / 库里的时间字符串 → 显示用字符串。 + + 库里的时间用 SQLite 风格的 ``YYYY-MM-DD HH:MM:SS`` 存,也有的行是 datetime + 对象(SQLiteVFS 读出来就转好了)。两种都要认。 + """ + if value is None or value == "": + return "-" + if isinstance(value, str): + parsed = _parse_time(value) + if parsed is None: + return value + value = parsed + if isinstance(value, datetime.datetime): + return value.strftime(fmt) + return str(value) + + +def _parse_time(value): + """尽量把库里的时间串解析成 datetime,解析不了返回 None。""" + for parser in ( + lambda v: datetime.datetime.fromisoformat(v.replace("Z", "+00:00")), + lambda v: datetime.datetime.strptime(v, "%Y-%m-%d %H:%M:%S"), + lambda v: datetime.datetime.strptime(v, "%Y-%m-%dT%H:%M:%S"), + ): + try: + return parser(value) + except ValueError: + continue + return None + + +def short_hash(content_hash, length=12): + """哈希只显示前几位;完整值在详情面板里可复制。""" + if not content_hash: + return "-" + return content_hash[:length] + + +def perm_string(mode): + """权限位转 rwx 字符串。 + + 库里存的是 ``st_mode & 0o777``(八进制 644 存成十进制 420),所以这里不涉及 + 文件类型位,也不该画那个前导的 ``-``/``d``。 + """ + if mode is None: + return "-" + try: + bits = int(mode) + except (TypeError, ValueError): + return str(mode) + chars = [] + for shift in (6, 3, 0): + chunk = (bits >> shift) & 0o7 + chars.append("r" if chunk & 0o4 else "-") + chars.append("w" if chunk & 0o2 else "-") + chars.append("x" if chunk & 0o1 else "-") + return "".join(chars) + + +def perm_octal(mode): + """权限位转 ``644`` 这种八进制字面量,配合 perm_string 一起显示。""" + if mode is None: + return "-" + try: + return f"{int(mode):03o}" + except (TypeError, ValueError): + return str(mode) + + +def ratio_text(stored, original): + """压缩率。压不动(stored >= original)时不写 ``100%`` 那种废话,直接说"未压缩"。""" + if not original: + return "-" + try: + value = float(stored) / float(original) + except (TypeError, ValueError, ZeroDivisionError): + return "-" + return f"{value * 100:.1f}%" + + +def percent_text(part, whole, precision=1): + """占比,whole 为 0 时返回 ``-``(不返回 0%,那会骗人)。""" + if not whole: + return "-" + try: + return f"{float(part) / float(whole) * 100:.{precision}f}%" + except (TypeError, ValueError, ZeroDivisionError): + return "-" + + +def sniff_text(data, limit=SNIFF_SIZE): + """判断一段字节能不能当文本显示,并给出编码与解码结果。 + + 返回 ``(is_binary, encoding, text)``: + + * 有 BOM 就先按 BOM 来(UTF-8 / UTF-16); + * 采样里出现 NUL 或过多控制字符 → 判为二进制,text 为 None; + * 否则按 utf-8 → gbk 顺序试解,最后 latin-1 兜底(一定成功,但可能满屏乱码, + 所以编码名会带到界面上,让人知道当前是什么口径)。 + """ + if data is None: + return True, None, None + sample = bytes(data[:limit]) + + if sample.startswith(codecs.BOM_UTF8): + return False, "utf-8-sig", _decode(sample, "utf-8-sig") + if sample.startswith(codecs.BOM_UTF16_LE) or sample.startswith(codecs.BOM_UTF16_BE): + return False, "utf-16", _decode(sample, "utf-16") + + if _looks_binary(sample): + return True, None, None + + for encoding in TEXT_ENCODINGS: + try: + return False, encoding, sample.decode(encoding) + except UnicodeDecodeError: + continue + return False, "latin-1", _decode(sample, "latin-1") + + +def _decode(data, encoding): + try: + return data.decode(encoding) + except (UnicodeDecodeError, LookupError): + return data.decode(encoding, errors="replace") + + +def _looks_binary(sample): + """NUL 是最硬的二进制信号;再补一个"控制字符占比过高"的判断。""" + if not sample: + return False + if b"\x00" in sample: + return True + control = sum(1 for byte in sample if byte < 0x20 and byte not in _ALLOWED_CONTROL) + return control / len(sample) > 0.30 + + +def hexdump(data, offset=0, width=16, limit=None): + """生成十六进制视图的文本行。 + + 返回 ``(lines, truncated)``,每行形如:: + + 00000000 48 65 6c 6c 6f 2c 20 53 56 46 53 21 0a 00 00 00 |Hello, SVFS!....| + """ + if data is None: + return [], False + raw = bytes(data) + truncated = False + if limit is not None and len(raw) > limit: + raw = raw[:limit] + truncated = True + + half = width // 2 + lines = [] + for start in range(0, len(raw), width): + chunk = raw[start : start + width] + groups = [] + for index in range(0, width, half): + piece = chunk[index : index + half] + groups.append(" ".join(f"{byte:02x}" for byte in piece).ljust(half * 3 - 1)) + hex_part = " ".join(groups) + text = "".join(chr(byte) if 0x20 <= byte < 0x7F else "." for byte in chunk) + lines.append(f"{offset + start:08x} {hex_part} |{text}|") + return lines, truncated diff --git a/src/svfs_studio/sample.py b/src/svfs_studio/sample.py new file mode 100644 index 0000000..e0f45c9 --- /dev/null +++ b/src/svfs_studio/sample.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""示例数据:仓库自带的旧格式包,以及一个"随手可打"的演示目录。 + +演示目录刻意做成 main.py 里演示脚本的同一棵树(含两份内容完全相同的 vendor.js), +因为"同一个文件只存了一份"是这个库最值得一眼看到的能力。 +""" + +import os +import sys +import tempfile + +#: 演示目录里的文件:(相对路径, 内容) +DEMO_FILES = ( + ("index.html", b"\nSVFS demo\n" * 8), + ("app.js", b"console.log('hello svfs');\n" * 40), + ("assets/logo.svg", b"" * 50), + ("assets/vendor.js", b"export const VERSION = 1;\n" * 60), + # 与 assets/vendor.js 内容完全相同:打包后只占一份空间 + ("admin/vendor.js", b"export const VERSION = 1;\n" * 60), + ("docs/README.md", "打包示例\n\n这是个演示用的文件夹。\n".encode("utf-8")), +) + +DEMO_DIRS = ("empty_dir",) + + +def repo_root(): + """仓库根目录(图标等资源以它为基准)。 + + 开发环境从包位置反推(src/svfs_studio/sample.py → 上三级); + 冻结环境(PyInstaller / Nuitka,见 packaging/)没有仓库可言, + 资源随包分发在可执行文件旁——PyInstaller 在 ``sys._MEIPASS``, + Nuitka standalone 在可执行文件同级的目录。 + """ + if getattr(sys, "frozen", False): + candidates = ( + getattr(sys, "_MEIPASS", None), + os.path.dirname(os.path.abspath(sys.executable)), + ) + for base in candidates: + if base and os.path.isdir(os.path.join(base, "resources")): + return base + return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def legacy_sample_path(): + """仓库里自带的 appcation.svfs(schema v1)。不在就返回 None。""" + candidate = os.path.join(repo_root(), "appcation.svfs") + return candidate if os.path.exists(candidate) else None + + +def build_demo_folder(target_dir): + """在 target_dir 里造一棵演示用的目录树,返回该目录。""" + for rel_path, data in DEMO_FILES: + full = os.path.join(target_dir, *rel_path.split("/")) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "wb") as handle: + handle.write(data) + for rel_path in DEMO_DIRS: + os.makedirs(os.path.join(target_dir, *rel_path.split("/")), exist_ok=True) + return target_dir + + +def make_demo_package(db_path, source_dir=None): + """打一个演示包出来(临时目录里造源目录)。供"没有包可看"时的入口与测试使用。""" + from sqlite_vfs.core import SQLiteVFS + from sqlite_vfs.folder_packer import FolderPacker + + temp = tempfile.mkdtemp(prefix="svfs_demo_") + source = os.path.join(temp, "dist") + os.makedirs(source, exist_ok=True) + build_demo_folder(source) + + vfs = SQLiteVFS(db_path, compress=True) + try: + FolderPacker(vfs, fs_name="demo").pack_folder(source) + finally: + vfs.close() + return db_path, source diff --git a/src/svfs_studio/shots.py b/src/svfs_studio/shots.py new file mode 100644 index 0000000..7df4074 --- /dev/null +++ b/src/svfs_studio/shots.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""截图harness:把每个标签页抓成 PNG。 + +界面好不好看没法用断言验,但"有没有错位、颜色对不对、中文有没有变成方块"可以看。 +主窗口跑在 offscreen 平台上也能 ``grab()`` 出真实渲染结果,所以这个 harness 既能在 +人肉核对时用(``python -m svfs_studio --shots DIR 包路径``),也能在测试里同步调用。 +""" + +import os + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication + + +def capture_tabs(window, out_dir, prefix="tab", settle_rounds=3): + """逐个标签页截图,返回保存的文件路径列表。**同步**执行,供测试直接调用。 + + 文件名只用 ASCII(取标签页上的 ``slug`` 属性):中文文件名在中文 Windows 上会按 + 本地代码页写盘,跨平台工具看起来就是乱码——工具自己产出的文件不该带这个坑。 + """ + os.makedirs(out_dir, exist_ok=True) + saved = [] + tabs = window.tabs + original = tabs.currentIndex() + for index in range(tabs.count()): + tabs.setCurrentIndex(index) + _settle(settle_rounds) + widget = tabs.widget(index) + slug = "" + if widget is not None: + slug = str(widget.property("slug") or "") + slug = _ascii_slug(slug) or f"tab{index + 1}" + path = os.path.join(out_dir, f"{prefix}-{index + 1}-{slug}.png") + window.grab().save(path) + saved.append(path) + # 父级 QStackedWidget 也要跟着切,否则截到的是空状态页 + window.pages.setCurrentIndex(1) + tabs.setCurrentIndex(original) + _settle(settle_rounds) + return saved + + +def _settle(rounds=3): + for _ in range(rounds): + QApplication.processEvents() + + +def _ascii_slug(text): + keep = [] + for char in str(text): + if char.isascii() and (char.isalnum() or char in "-_"): + keep.append(char) + elif char.isspace(): + keep.append("_") + return "".join(keep).strip("_") + + +def schedule_shots(app, window, shots_dir, package=None, timeout_ms=20000, on_done=None): + """给命令行用的异步版本:等包加载完 → 截图 → 退出。 + + 包没加载成功也会截图(空状态页与横幅同样值得核对),只是会多留一份 note。 + """ + state = {"elapsed": 0, "done": False, "target": package} + + def step(): + if state["done"]: + return + state["elapsed"] += 200 + if state["target"] and window.payload is None and state["elapsed"] < timeout_ms: + QTimer.singleShot(200, step) + return + state["done"] = True + saved = capture_tabs(window, shots_dir) + notes = [f"截图 {len(saved)} 张 → {shots_dir}"] + for path in saved: + notes.append(" " + os.path.basename(path)) + if window.payload is None and state["target"]: + notes.append(f"注意:{state['target']} 没能在超时前打开,截的是当前状态") + print("\n".join(notes)) + if on_done is not None: + on_done(saved) + app.quit() + + QTimer.singleShot(300, step) diff --git a/src/svfs_studio/theme.py b/src/svfs_studio/theme.py new file mode 100644 index 0000000..e566945 --- /dev/null +++ b/src/svfs_studio/theme.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""暗色主题:调色板、字体、QSS。 + +界面所有颜色都从这里取,不要在部件里写死颜色值——自绘图表和 QSS 用的是同一套 +常量,改一个地方整套视觉一起变。 +""" + +from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette + +# ---------------------------------------------------------------------- +# 调色板 +# ---------------------------------------------------------------------- + +BG = "#16181d" # 窗口底 +PANEL = "#1c1f26" # 面板/卡片 +ELEVATED = "#22262f" # 浮起(按钮、菜单) +SUNKEN = "#14171c" # 输入框、表格内凹 +HOVER = "#2a2f3a" +BORDER = "#2b3038" +BORDER_STRONG = "#3a4150" +TEXT = "#e4e7ec" +TEXT_MUTED = "#9aa3b2" +TEXT_FAINT = "#6b7382" +ACCENT = "#5b9dff" +ACCENT_TEXT = "#0b1220" +SELECT = "#2b4a7d" +OK = "#3ddc97" +WARN = "#f5b544" +ERR = "#ff6b6b" +INFO = "#58c4dd" +VIOLET = "#c792ea" + +#: 压缩算法配色(环图、图例、表格里的算法色块共用) +ALGO_COLORS = { + "none": TEXT_FAINT, + "zlib": ACCENT, + "huffman": VIOLET, +} + +#: 差异分类配色 +DIFF_COLORS = { + "added": OK, + "removed": ERR, + "content": WARN, + "type": VIOLET, + "meta": INFO, +} + +#: 本地对比分类配色 +LOCAL_COLORS = { + "identical": OK, + "content": WARN, + "meta": INFO, + "only_local": ACCENT, + "only_pack": VIOLET, + "type": ERR, +} + +#: 存储链条(逻辑 → 去重 → 压缩 → 库文件)配色 +STAGE_COLORS = ("#5b9dff", "#3ddc97", "#f5b544", "#8b93a1") + +RADIUS = 5 +PAD = 10 +GAP = 6 + +# ---------------------------------------------------------------------- +# 字号档位 +# ---------------------------------------------------------------------- +# 界面上一律从这里取字号,不写字面量:想整体调大调小只改这一处。 +# 桌面工具的信息密度比网页高(一屏要塞表格、树、图表),所以整体比默认小一档, +# 靠层级(粗细 + 颜色)区分主次,而不是靠字号。 +SIZE_MICRO = 9 # 图表里的次要标注 +SIZE_TINY = 10 # 等宽内容:哈希、权限、十六进制 +SIZE_SMALL = 11 # 次要说明、图例、状态栏 +SIZE_BASE = 12 # 正文 +SIZE_HEADING = 13 # 小节标题 +SIZE_TITLE = 15 # 页面内标题 +SIZE_HERO = 19 # 卡片主数字 + +#: 表格/列表行高。比字号大一档就够,多出来的都是浪费 +ROW_HEIGHT = 22 +#: 顶栏高度 +HEADER_HEIGHT = 44 + +UI_FAMILIES = ( + "Microsoft YaHei UI", + "Microsoft YaHei", + "PingFang SC", + "Noto Sans CJK SC", + "Segoe UI", + "Inter", +) +MONO_FAMILIES = ( + "Cascadia Mono", + "Consolas", + "JetBrains Mono", + "DejaVu Sans Mono", + "Courier New", +) + + +def algo_color(algo): + return ALGO_COLORS.get(algo or "none", TEXT_FAINT) + + +def _first_available(candidates, fallback): + families = set(QFontDatabase.families()) + for name in candidates: + if name in families: + return name + return fallback + + +_ui_family = None +_mono_family = None + + +def font_ui(size=SIZE_BASE, weight=QFont.Weight.Normal): + """界面字体。 + + 两个都用像素字号(``setPixelSize``)而不是点字号:QSS 里写的是 ``font-size: 12px``, + 而 ``QFont(family, 12)`` 是 12 **点**(96 DPI 下约 16px)。两者混用会让一部分控件 + 比另一部分大三分之一——界面原本整体偏大就是这个原因。 + + 字体族交给 Qt 按顺序挑(``setFamilies``),比在 Python 里先探测一次更稳: + offscreen 平台上一个字体都列不出来(``QFontDatabase.families()`` 是空的), + 探测失败就会退到某个度量很宽的兜底字体,把布局算歪。 + """ + global _ui_family + if _ui_family is None: + _ui_family = _first_available(UI_FAMILIES, "sans-serif") + font = QFont() + font.setFamilies(list(UI_FAMILIES)) + font.setPixelSize(int(size)) + font.setWeight(weight) + return font + + +def font_mono(size=SIZE_TINY, weight=QFont.Weight.Normal): + """等宽字体:哈希、SQL、十六进制视图用。字号同为像素。""" + global _mono_family + if _mono_family is None: + _mono_family = _first_available(MONO_FAMILIES, "monospace") + font = QFont() + font.setFamilies(list(MONO_FAMILIES)) + font.setPixelSize(int(size)) + font.setWeight(weight) + font.setStyleHint(QFont.StyleHint.Monospace) + return font + + +def mono_family(): + """给 QSS 用的等宽字体名(QSS 里必须写具体字体族)。""" + font_mono() + return _mono_family + + +def ui_family(): + font_ui() + return _ui_family + + +# ---------------------------------------------------------------------- +# QSS +# ---------------------------------------------------------------------- + + +def stylesheet(): + return f""" +QWidget {{ color: {TEXT}; }} +QMainWindow, QDialog {{ background: {BG}; }} +QToolTip {{ + background: {ELEVATED}; color: {TEXT}; + border: 1px solid {BORDER_STRONG}; border-radius: {RADIUS}px; padding: 4px 6px; +}} + +/* ---------- 容器 ---------- */ +#Header {{ background: {PANEL}; border-bottom: 1px solid {BORDER}; }} +#Sidebar {{ background: {PANEL}; border-right: 1px solid {BORDER}; }} +#Card {{ + background: {PANEL}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; +}} +#Banner {{ + background: {ELEVATED}; border: 1px solid {BORDER_STRONG}; + border-radius: {RADIUS}px; padding: 8px 10px; +}} +#BannerWarn {{ background: #2a2418; border: 1px solid #6b552a; border-radius: {RADIUS}px; padding: 8px 10px; }} +#BannerErr {{ background: #2b1a1c; border: 1px solid #6b3235; border-radius: {RADIUS}px; padding: 8px 10px; }} +#BannerOk {{ background: #16281f; border: 1px solid #2b5f45; border-radius: {RADIUS}px; padding: 8px 10px; }} +#Divider {{ background: {BORDER}; max-height: 1px; min-height: 1px; border: none; }} +#CardAccent {{ border-radius: 1px; }} +#EmptyCard {{ + background: {PANEL}; border: 1px dashed {BORDER_STRONG}; border-radius: {RADIUS}px; +}} + +/* ---------- 文字层级(用动态属性 role 区分) ---------- */ +QLabel[role="h1"] {{ font-size: {SIZE_HERO}px; font-weight: 600; }} +QLabel[role="h2"] {{ font-size: {SIZE_HEADING}px; font-weight: 600; }} +QLabel[role="muted"] {{ color: {TEXT_MUTED}; }} +QLabel[role="faint"] {{ color: {TEXT_FAINT}; }} +QLabel[role="mono"] {{ font-family: "{mono_family()}"; }} +QLabel[role="cardValue"] {{ font-size: {SIZE_HERO}px; font-weight: 600; }} +QLabel[role="cardTitle"] {{ color: {TEXT_MUTED}; font-size: {SIZE_SMALL}px; }} +QLabel[role="ok"] {{ color: {OK}; }} +QLabel[role="warn"] {{ color: {WARN}; }} +QLabel[role="err"] {{ color: {ERR}; }} + +/* ---------- 徽章 ---------- */ +#Badge {{ + border-radius: 8px; padding: 1px 6px; font-size: {SIZE_TINY}px; font-weight: 600; + border: 1px solid {BORDER_STRONG}; color: {TEXT_MUTED}; background: {ELEVATED}; +}} + +/* ---------- 按钮 ---------- */ +QPushButton, QToolButton {{ + background: {ELEVATED}; color: {TEXT}; + border: 1px solid {BORDER_STRONG}; border-radius: {RADIUS}px; + padding: 5px 10px; +}} +QPushButton:hover, QToolButton:hover {{ background: {HOVER}; border-color: #47505f; }} +QPushButton:pressed, QToolButton:pressed {{ background: #1f232b; }} +QPushButton:disabled, QToolButton:disabled {{ + color: {TEXT_FAINT}; background: #1a1d23; border-color: {BORDER}; +}} +QPushButton#Primary {{ + background: {ACCENT}; border-color: {ACCENT}; color: {ACCENT_TEXT}; font-weight: 600; +}} +QPushButton#Primary:hover {{ background: #6ea9ff; border-color: #6ea9ff; }} +QPushButton#Primary:disabled {{ background: #2f3d55; border-color: #2f3d55; color: {TEXT_FAINT}; }} +QPushButton#Danger {{ color: {ERR}; border-color: #57373a; }} +QPushButton#Danger:hover {{ background: #2e1e20; border-color: #7a4448; }} +QPushButton#Ghost {{ background: transparent; border-color: transparent; color: {TEXT_MUTED}; padding: 4px 8px; }} +QPushButton#Ghost:hover {{ background: {HOVER}; color: {TEXT}; }} + +/* ---------- 输入 ---------- */ +QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QTextEdit {{ + background: {SUNKEN}; border: 1px solid {BORDER_STRONG}; border-radius: {RADIUS}px; + padding: 4px 7px; selection-background-color: {SELECT}; selection-color: {TEXT}; +}} +QLineEdit:focus, QComboBox:focus, QSpinBox:focus, +QPlainTextEdit:focus, QTextEdit:focus {{ border-color: {ACCENT}; }} +QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled {{ + color: {TEXT_FAINT}; background: #191c22; border-color: {BORDER}; +}} +QLineEdit[role="mono"], QPlainTextEdit[role="mono"], QTextEdit[role="mono"] {{ + font-family: "{mono_family()}"; +}} +QComboBox::drop-down {{ border: none; width: 20px; }} +QComboBox::down-arrow {{ + image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; + border-top: 5px solid {TEXT_MUTED}; width: 0; height: 0; margin-right: 6px; +}} +QComboBox QAbstractItemView {{ + background: {ELEVATED}; border: 1px solid {BORDER_STRONG}; + selection-background-color: {SELECT}; outline: none; padding: 2px; +}} +QCheckBox, QRadioButton {{ spacing: 6px; }} +QCheckBox::indicator, QRadioButton::indicator {{ + width: 14px; height: 14px; border: 1px solid {BORDER_STRONG}; + background: {SUNKEN}; border-radius: 3px; +}} +QRadioButton::indicator {{ border-radius: 7px; }} +QCheckBox::indicator:checked, QRadioButton::indicator:checked {{ + background: {ACCENT}; border-color: {ACCENT}; +}} +QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {ACCENT}; }} + +/* ---------- 标签页 ---------- */ +QTabWidget::pane {{ border: none; border-top: 1px solid {BORDER}; }} +QTabBar {{ background: transparent; qproperty-drawBase: 0; }} +QTabBar::tab {{ + background: transparent; color: {TEXT_MUTED}; + padding: 6px 12px; margin: 0; border: none; + border-bottom: 2px solid transparent; +}} +QTabBar::tab:hover {{ color: {TEXT}; }} +QTabBar::tab:selected {{ color: {TEXT}; border-bottom: 2px solid {ACCENT}; }} +QTabBar::tab:disabled {{ color: {TEXT_FAINT}; }} + +/* ---------- 表格 / 树 ---------- */ +QTreeView, QTableView, QListView {{ + background: {PANEL}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; + alternate-background-color: #1f232a; gridline-color: {BORDER}; + selection-background-color: {SELECT}; selection-color: {TEXT}; + outline: none; +}} +QTreeView::item, QTableView::item {{ padding: 2px 4px; border: none; }} +QTreeView::item:selected, QTableView::item:selected {{ background: {SELECT}; }} +QTreeView::item:hover, QTableView::item:hover {{ background: {HOVER}; }} +QHeaderView {{ background: transparent; }} +QHeaderView::section {{ + background: #1f232a; color: {TEXT_MUTED}; padding: 5px 7px; + border: none; border-right: 1px solid {BORDER}; border-bottom: 1px solid {BORDER}; +}} +QHeaderView::section:hover {{ color: {TEXT}; }} +QTableView QTableCornerButton::section {{ background: #1f232a; border: none; }} + +/* ---------- 滚动条 ---------- */ +QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }} +QScrollBar:horizontal {{ background: transparent; height: 10px; margin: 2px; }} +QScrollBar::handle {{ background: #39404d; border-radius: {RADIUS}px; }} +QScrollBar::handle:hover {{ background: #4a5364; }} +QScrollBar::handle:vertical {{ min-height: 28px; }} +QScrollBar::handle:horizontal {{ min-width: 28px; }} +QScrollBar::add-line, QScrollBar::sub-line {{ width: 0; height: 0; }} +QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }} + +/* ---------- 进度条 ---------- */ +QProgressBar {{ + background: {SUNKEN}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; + height: 6px; text-align: center; color: {TEXT_MUTED}; font-size: {SIZE_MICRO}px; +}} +QProgressBar::chunk {{ background: {ACCENT}; border-radius: 3px; }} + +/* ---------- 其它 ---------- */ +/* 分栏抓取条:平时是一条"缝"(跟窗口底色一致,等于留白),悬停才亮起来。 + 5px 是手感好的命中区,但视觉上不吵。 */ +QSplitter::handle {{ background: {BG}; }} +QSplitter::handle:horizontal {{ width: 5px; }} +QSplitter::handle:vertical {{ height: 5px; }} +QSplitter::handle:hover {{ background: {BORDER_STRONG}; }} +QStatusBar {{ background: {PANEL}; border-top: 1px solid {BORDER}; color: {TEXT_MUTED}; }} +QStatusBar::item {{ border: none; }} +QDockWidget {{ color: {TEXT}; }} +QDockWidget::title {{ + background: {PANEL}; padding: 5px 8px; + border-bottom: 1px solid {BORDER}; text-align: left; +}} +QMenu {{ background: {ELEVATED}; border: 1px solid {BORDER_STRONG}; border-radius: {RADIUS}px; padding: 4px; }} +QMenu::item {{ padding: 4px 20px 4px 10px; border-radius: {RADIUS}px; }} +QMenu::item:selected {{ background: {SELECT}; }} +QMenu::item:disabled {{ color: {TEXT_FAINT}; }} +QMenu::separator {{ height: 1px; background: {BORDER}; margin: 4px 6px; }} +QListWidget {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; padding: 2px; }} +QListWidget::item {{ padding: 4px 6px; border-radius: {RADIUS}px; color: {TEXT_MUTED}; }} +QListWidget::item:selected {{ background: {SELECT}; color: {TEXT}; }} +QListWidget::item:hover {{ background: {HOVER}; color: {TEXT}; }} +QGroupBox {{ + border: 1px solid {BORDER}; border-radius: {RADIUS}px; + margin-top: 10px; padding: 10px 8px 8px 8px; +}} +QGroupBox::title {{ + subcontrol-origin: margin; left: 10px; padding: 0 4px; color: {TEXT_MUTED}; +}} +QScrollArea {{ border: none; background: transparent; }} +QScrollArea > QWidget > QWidget {{ background: transparent; }} +QTreeView::branch {{ background: transparent; }} + +/* ---------- 滚动条:细暗条、无箭头(默认 Fusion 的灰滚轮在暗色里很扎眼) ---------- */ +QScrollBar:vertical {{ + background: transparent; width: 10px; margin: 2px; border: none; +}} +QScrollBar::handle:vertical {{ + background: {BORDER_STRONG}; border-radius: 4px; min-height: 28px; +}} +QScrollBar::handle:vertical:hover {{ background: {TEXT_FAINT}; }} +QScrollBar::handle:vertical:pressed {{ background: {TEXT_MUTED}; }} +QScrollBar:horizontal {{ + background: transparent; height: 10px; margin: 2px; border: none; +}} +QScrollBar::handle:horizontal {{ + background: {BORDER_STRONG}; border-radius: 4px; min-width: 28px; +}} +QScrollBar::handle:horizontal:hover {{ background: {TEXT_FAINT}; }} +QScrollBar::handle:horizontal:pressed {{ background: {TEXT_MUTED}; }} +QScrollBar::add-line, QScrollBar::sub-line {{ + width: 0; height: 0; background: none; border: none; +}} +QScrollBar::add-page, QScrollBar::sub-page {{ background: none; }} +""" + + +def apply_theme(app): + """把 Fusion 风格 + 暗色调色板 + QSS 装到 QApplication 上。""" + app.setStyle("Fusion") + + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(BG)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(TEXT)) + palette.setColor(QPalette.ColorRole.Base, QColor(SUNKEN)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(PANEL)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(ELEVATED)) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor(TEXT)) + palette.setColor(QPalette.ColorRole.Text, QColor(TEXT)) + palette.setColor(QPalette.ColorRole.Button, QColor(ELEVATED)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(TEXT)) + palette.setColor(QPalette.ColorRole.BrightText, QColor(ERR)) + palette.setColor(QPalette.ColorRole.Link, QColor(ACCENT)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(SELECT)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(TEXT)) + palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(TEXT_FAINT)) + for role in (QPalette.ColorRole.WindowText, QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText): + palette.setColor(QPalette.ColorGroup.Disabled, role, QColor(TEXT_FAINT)) + app.setPalette(palette) + + font = font_ui() + app.setFont(font) + app.setStyleSheet(stylesheet()) + return app + + +def role(widget, name): + """给部件打上 QSS 里用的 role,并让样式立刻重算。""" + widget.setProperty("role", name) + style = widget.style() + style.unpolish(widget) + style.polish(widget) + return widget + + +def repolish(widget): + """动态属性改了之后让它立刻生效(Qt 不会自己重算)。""" + style = widget.style() + style.unpolish(widget) + style.polish(widget) + return widget + + +def qcolor(value, alpha=None): + """字符串 → QColor,可选叠加透明度(图表填充用)。""" + color = QColor(value) + if alpha is not None: + color.setAlpha(alpha) + return color diff --git a/src/svfs_studio/views/__init__.py b/src/svfs_studio/views/__init__.py new file mode 100644 index 0000000..1b36a4c --- /dev/null +++ b/src/svfs_studio/views/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""各标签页。主窗口按名字取 builder:``build_``。 + +加一页视图只需要写一个模块 + 在这里导出 + 在 shell.TABS 里登记(工具箱那种附加页 +直接在 _build_workspace 里挂上即可)。 +""" + +from .base import BaseView +from .browser import build_browser +from .integrity import build_integrity +from .internals import build_internals +from .ops import build_ops +from .overview import build_overview +from .snapshots import build_snapshots +from .storage import build_storage + +__all__ = [ + "BaseView", + "build_browser", + "build_integrity", + "build_internals", + "build_ops", + "build_overview", + "build_snapshots", + "build_storage", +] diff --git a/src/svfs_studio/views/base.py b/src/svfs_studio/views/base.py new file mode 100644 index 0000000..a5e7b1b --- /dev/null +++ b/src/svfs_studio/views/base.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""视图基类:每个标签页都是"内容页 + 占位页"两层。 + +占位页统一处理两种没有数据的情况:还没打开包、打开的包是旧格式/坏文件。 +这样各视图只管写"有数据时显示什么",不用各自重复一遍空状态判断。 +""" + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QScrollArea, QStackedWidget, QWidget + +from ..widgets.cards import EmptyState, vbox + + +class BaseView(QWidget): + #: 没打开包时的占位文案 + PLACEHOLDER_TITLE = "还没有打开包" + PLACEHOLDER_DETAIL = "用顶栏的「打开包…」选一个 .svfs,或者「打个示例包看看」。" + #: 旧格式包的占位文案 + LEGACY_DETAIL = "这个包是旧格式(schema v1/v2),先到「工具箱 → 迁移」升到 v3 再看这里。" + #: True = 内容放进滚动区:窗口矮的时候滚动而不是把内容压扁(概览这类密页面要开) + SCROLLABLE = False + + def __init__(self, window): + super().__init__() + self.window = window + self.payload = None + outer = vbox(self, 10, 6) + self._stack = QStackedWidget() + outer.addWidget(self._stack) + + self.content = self.build_content() + if self.SCROLLABLE: + area = QScrollArea() + area.setWidgetResizable(True) + area.setFrameShape(QScrollArea.Shape.NoFrame) + # 需要时才出横向滚动条:真放不下时给条路,但别把内容裁掉 + area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + area.setWidget(self.content) + self._stack.addWidget(area) + else: + self._stack.addWidget(self.content) + self.placeholder = EmptyState( + self.PLACEHOLDER_TITLE, self.PLACEHOLDER_DETAIL + ) + self._stack.addWidget(self.placeholder) + self._stack.setCurrentIndex(1) + + # ------------------------------------------------------------------ + + def build_content(self): # pragma: no cover - 由子类实现 + raise NotImplementedError + + def set_data(self, payload): + """主窗口每次拿到完整 payload 都会调这里。""" + self.payload = payload + if not payload or payload.get("legacy") is not None or payload.get("error"): + if payload and payload.get("legacy") is not None: + self.placeholder.set_text( + f"旧格式包(schema v{payload['legacy']}):这里需要 v3 结构", + self.LEGACY_DETAIL, + ) + else: + self.placeholder.set_text(self.PLACEHOLDER_TITLE, self.PLACEHOLDER_DETAIL) + self._stack.setCurrentIndex(1) + self.on_empty(payload) + return + self._stack.setCurrentIndex(0) + self.update_view(payload) + + def on_empty(self, payload): # pragma: no cover - 子类按需覆写 + """旧格式/未打开时的额外处理(例如清掉上一次的内容)。""" + + def update_view(self, payload): # pragma: no cover - 子类实现 + raise NotImplementedError diff --git a/src/svfs_studio/views/browser.py b/src/svfs_studio/views/browser.py new file mode 100644 index 0000000..ddba8e6 --- /dev/null +++ b/src/svfs_studio/views/browser.py @@ -0,0 +1,1257 @@ +#!/usr/bin/env python3 +"""文件页:左侧树 + 右侧详情与内容预览。 + +树是一次性从 ``all_entries()`` 建起来的(一次查询拿到整棵树的元数据),既快又方便做 +过滤与排序;预览才按需去库里读内容,并按块分页,避免手滑点开一个大文件。 +""" + +import os + +from PySide6.QtCore import QModelIndex, QSortFilterProxyModel, Qt, QTimer +from PySide6.QtGui import QColor, QIcon, QImage, QKeySequence, QPainter, QPixmap, QShortcut +from PySide6.QtGui import QStandardItem, QStandardItemModel +from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QFileDialog, + QLabel, + QLineEdit, + QMenu, + QPlainTextEdit, + QPushButton, + QSplitter, + QStackedWidget, + QTreeView, + QVBoxLayout, + QWidget, +) + +from .. import theme +from ..worker import EDIT_SIZE_LIMIT +from ..format import ( + hexdump, + human_size, + human_time, + perm_octal, + perm_string, + ratio_text, + short_hash, + sniff_text, +) +from ..widgets.cards import Badge, Card, CopyLabel, InfoGrid, SectionTitle, hbox, vbox +from .base import BaseView + +#: 预览每页读多少字节 +PAGE_SIZE = 256 * 1024 +#: 十六进制视图一次最多画多少字节 +HEX_LIMIT = 32 * 1024 + +COLUMNS = ("名称", "大小", "修改时间", "权限", "内容哈希") + +ROLE_PATH = Qt.ItemDataRole.UserRole + 1 +ROLE_SIZE = Qt.ItemDataRole.UserRole + 2 +ROLE_IS_DIR = Qt.ItemDataRole.UserRole + 3 +ROLE_HASH = Qt.ItemDataRole.UserRole + 4 +#: 排序用的角色:大小要按数字排、时间要按 ISO 串排,都不能按显示文本排 +ROLE_SORT = Qt.ItemDataRole.UserRole + 9 + + +def _icon(is_dir): + """节点图标:用 QPainter 现画两个形状,省掉一套图标资源依赖(结果缓存复用)。""" + cache = _icon.__dict__.setdefault("cache", {}) + if is_dir in cache: + return cache[is_dir] + pixmap = QPixmap(14, 14) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + if is_dir: + painter.setBrush(QColor(theme.ACCENT)) + painter.drawRoundedRect(1, 4, 12, 9, 2, 2) + painter.drawRoundedRect(1, 2, 8, 5, 2, 2) + else: + painter.setBrush(QColor(theme.TEXT_FAINT)) + painter.drawRoundedRect(3, 1, 8, 12, 2, 2) + painter.end() + icon = QIcon(pixmap) + cache[is_dir] = icon + return icon + + +def _dir_row(name, path, permissions): + """目录行:只有名称与权限两列有内容。""" + cells = [] + for column, text in enumerate(("", "", "", perm_string(permissions), "")): + if column == 0: + item = QStandardItem(str(name)) + item.setIcon(_icon(True)) + item.setForeground(QColor(theme.TEXT)) + item.setData(path, ROLE_PATH) + item.setData(True, ROLE_IS_DIR) + item.setData(-1, ROLE_SORT) # 目录排在文件前面(数字更小) + item.setToolTip(path) + elif column == 3: + item = QStandardItem(text) + item.setFont(theme.font_mono(theme.SIZE_TINY)) + item.setForeground(QColor(theme.TEXT_MUTED)) + item.setData(permissions or 0, ROLE_SORT) + item.setToolTip(f"权限位 {perm_octal(permissions)}") + else: + item = QStandardItem(text) + item.setData("", ROLE_SORT) + item.setEditable(False) + cells.append(item) + return cells + + +def _file_row(entry): + size = entry.get("file_size") or 0 + modified = entry.get("modified_time") + permissions = entry.get("permissions") + content_hash = entry.get("content_hash") + path = entry["path"] + + name_item = QStandardItem(str(entry.get("name") or path)) + name_item.setIcon(_icon(False)) + name_item.setData(path, ROLE_PATH) + name_item.setData(False, ROLE_IS_DIR) + # ROLE_SIZE 必须挂上:model.entry() 靠它取大小(内容编辑的可用性、预览分页都依赖) + name_item.setData(size, ROLE_SIZE) + name_item.setData(size, ROLE_SORT) + name_item.setData(content_hash, ROLE_HASH) + name_item.setToolTip(path) + + size_item = QStandardItem(human_size(size)) + size_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + size_item.setData(size, ROLE_SORT) + size_item.setForeground(QColor(theme.TEXT_MUTED)) + + time_item = QStandardItem(human_time(modified)) + time_item.setData(_sort_time(modified), ROLE_SORT) + time_item.setForeground(QColor(theme.TEXT_MUTED)) + + perm_item = QStandardItem(perm_string(permissions)) + perm_item.setFont(theme.font_mono(theme.SIZE_TINY)) + perm_item.setForeground(QColor(theme.TEXT_MUTED)) + perm_item.setData(permissions or 0, ROLE_SORT) + perm_item.setToolTip(f"权限位 {perm_octal(permissions)}") + + hash_item = QStandardItem(short_hash(content_hash, 10) if content_hash else "") + hash_item.setFont(theme.font_mono(theme.SIZE_TINY)) + hash_item.setForeground(QColor(theme.TEXT_FAINT)) + hash_item.setData(content_hash or "", ROLE_SORT) + hash_item.setToolTip(content_hash or "(没有关联内容)") + + cells = [name_item, size_item, time_item, perm_item, hash_item] + for item in cells: + item.setEditable(False) + return cells + + +def _sort_time(value): + if value is None: + return "" + return value.isoformat() if hasattr(value, "isoformat") else str(value) + + +class FileTreeModel(QStandardItemModel): + """从扁平的条目列表建树(父目录缺条目时补一个占位节点)。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setHorizontalHeaderLabels(list(COLUMNS)) + + def load(self, entries): + self.removeRows(0, self.rowCount()) + nodes = {"": self.invisibleRootItem()} + + def dir_node(path): + if path in nodes: + return nodes[path] + parent_path = path.rsplit("/", 1)[0] if "/" in path else "" + row = _dir_row(path.rsplit("/", 1)[-1], path, None) + dir_node(parent_path).appendRow(row) + nodes[path] = row[0] + return row[0] + + for entry in sorted(entries, key=lambda item: item["path"]): + path = entry["path"] + parent_path = entry.get("parent_path") or "" + if entry.get("is_directory"): + node = dir_node(path) + cells = _dir_row(entry.get("name") or path, path, entry.get("permissions")) + parent = nodes.get(parent_path) + if parent is None: + parent = dir_node(parent_path) + parent.appendRow(cells) + nodes[path] = cells[0] + else: + dir_node(parent_path).appendRow(_file_row(entry)) + return self + + def find(self, path): + """按路径找节点(跳转/导出时定位用)。""" + if not path: + return QModelIndex() + for row in self.match( + self.index(0, 0), ROLE_PATH, path, 1, Qt.MatchFlag.MatchRecursive + ): + return row + return QModelIndex() + + def entry(self, path): + """从模型里取回某个路径的元数据(详情面板算总页数要用)。""" + index = self.find(path) + if not index.isValid(): + return None + return { + "path": path, + "size": index.data(ROLE_SIZE) or 0, + "is_directory": bool(index.data(ROLE_IS_DIR)), + "content_hash": index.data(ROLE_HASH), + } + + +class FileFilterProxy(QSortFilterProxyModel): + """按路径子串过滤,并保留命中项的所有父目录(否则命中项挂不到树上)。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setRecursiveFilteringEnabled(True) + self._needle = "" + self._only = "all" + + def set_needle(self, text): + self._needle = (text or "").strip().lower() + self.invalidateRowsFilter() + + def set_only(self, mode): + self._only = mode + self.invalidateRowsFilter() + + def filterAcceptsRow(self, row, parent): # noqa: N802 + model = self.sourceModel() + index = model.index(row, 0, parent) + is_dir = bool(model.data(index, ROLE_IS_DIR)) + if self._only == "files" and is_dir: + return False + if self._only == "dirs" and not is_dir: + return False + if not self._needle: + return True + path = (model.data(index, ROLE_PATH) or "").lower() + return self._needle in path + + +class BrowserView(BaseView): + PLACEHOLDER_TITLE = "还没有打开包" + PLACEHOLDER_DETAIL = "打开一个包后,这里会显示它的文件树、元数据与内容预览。" + + def build_content(self): + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(self._build_tree_side()) + splitter.addWidget(self._build_detail_side()) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 4) + splitter.setSizes([580, 620]) + + shortcut = QShortcut(QKeySequence(QKeySequence.StandardKey.Find), self) + shortcut.activated.connect(self._focus_search) + return splitter + + def _focus_search(self): + self.search.setFocus() + self.search.selectAll() + + # ------------------------------------------------------------------ + + def _build_tree_side(self): + panel = QWidget() + layout = vbox(panel, 0, 6) + + self.search = QLineEdit() + self.search.setPlaceholderText("按路径过滤(Ctrl+F)") + self.search.setClearButtonEnabled(True) + self.search.textChanged.connect(lambda text: self.proxy.set_needle(text)) + layout.addWidget(self.search) + + row = hbox(spacing=6) + self.only_box = QComboBox() + self.only_box.addItem("全部条目", "all") + self.only_box.addItem("只看文件", "files") + self.only_box.addItem("只看目录", "dirs") + self.only_box.currentIndexChanged.connect( + lambda: self.proxy.set_only(self.only_box.currentData()) + ) + row.addWidget(self.only_box) + expand = QPushButton("展开") + expand.setObjectName("Ghost") + expand.clicked.connect(lambda: self.tree.expandAll()) + collapse = QPushButton("折叠") + collapse.setObjectName("Ghost") + collapse.clicked.connect(lambda: self.tree.collapseAll()) + row.addWidget(expand) + row.addWidget(collapse) + row.addStretch(1) + self.count_label = QLabel("") + theme.role(self.count_label, "faint") + row.addWidget(self.count_label) + layout.addLayout(row) + + # 编辑这一排单独一行:加文件、建目录、改名、删除都直接写当前快照 + edit_row = hbox(spacing=6) + edit_label = QLabel("编辑") + theme.role(edit_label, "faint") + edit_row.addWidget(edit_label) + self.add_button = QPushButton("添加文件") + self.add_button.setToolTip("把本地文件或整个文件夹加进当前快照(内容按 sha256 去重)") + self.add_button.clicked.connect(self._add_files) + self.mkdir_button = QPushButton("新建目录") + self.mkdir_button.setToolTip("在选中的目录下建一个空目录(没选中就是根目录)") + self.mkdir_button.clicked.connect(self._make_directory) + self.rename_button = QPushButton("重命名") + self.rename_button.setToolTip("移动/改名选中的文件或目录") + self.rename_button.clicked.connect(self._rename_selected) + self.delete_button = QPushButton("删除") + self.delete_button.setObjectName("Danger") + self.delete_button.setToolTip("从当前快照里删掉;内容在没有快照引用时才会被回收") + self.delete_button.clicked.connect(self._delete_selected) + for button in ( + self.add_button, + self.mkdir_button, + self.rename_button, + self.delete_button, + ): + button.setEnabled(False) + edit_row.addWidget(button) + edit_row.addStretch(1) + layout.addLayout(edit_row) + + self.model = FileTreeModel(self) + self.proxy = FileFilterProxy(self) + self.proxy.setSourceModel(self.model) + self.proxy.setSortRole(ROLE_SORT) + self.tree = QTreeView() + self.tree.setModel(self.proxy) + self.tree.setAlternatingRowColors(True) + self.tree.setUniformRowHeights(True) + self.tree.setSortingEnabled(True) + self.tree.sortByColumn(0, Qt.SortOrder.AscendingOrder) + self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.tree.customContextMenuRequested.connect(self._show_menu) + self.tree.selectionModel().currentChanged.connect(self._on_selected) + header = self.tree.header() + header.setStretchLastSection(False) + header.resizeSection(0, 220) + header.resizeSection(1, 82) + header.resizeSection(2, 128) + header.resizeSection(3, 86) + layout.addWidget(self.tree, 1) + self._current = None + return panel + + def _build_detail_side(self): + panel = QWidget() + layout = vbox(panel, 0, 6) + + self.path_label = QLabel("未选中任何条目") + self.path_label.setStyleSheet("font-weight: 600;") + self.path_label.setWordWrap(True) + self.path_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + layout.addWidget(self.path_label) + + badges = hbox(spacing=6) + self.kind_badge = Badge("—", "muted") + self.hash_badge = Badge("—", "muted") + badges.addWidget(self.kind_badge) + badges.addWidget(self.hash_badge) + badges.addStretch(1) + self.export_button = QPushButton("导出") + self.export_button.setEnabled(False) + self.export_button.clicked.connect(self._export_selected) + badges.addWidget(self.export_button) + layout.addLayout(badges) + + self.info = InfoGrid(columns=2) + layout.addWidget(self.info) + + self.other_card = Card(padding=theme.PAD, spacing=4) + self.other_title = SectionTitle("同内容的其它路径", "") + self.other_card.add(self.other_title) + self.other_list = QVBoxLayout() + self.other_card.add_layout(self.other_list) + self.other_card.setVisible(False) + layout.addWidget(self.other_card) + + preview_header = hbox(spacing=6) + title = QLabel("内容预览") + theme.role(title, "h2") + preview_header.addWidget(title) + self.mode_box = QComboBox() + self.mode_box.addItem("文本", "text") + self.mode_box.addItem("十六进制", "hex") + self.mode_box.addItem("图片", "image") + self.mode_box.currentIndexChanged.connect(lambda: self._reload_page()) + preview_header.addWidget(self.mode_box) + preview_header.addStretch(1) + self.save_button = QPushButton("保存") + self.save_button.setObjectName("Primary") + self.save_button.setToolTip("把编辑后的内容写回包内(Ctrl+S)") + self.save_button.setVisible(False) + self.save_button.clicked.connect(self.save_edit) + preview_header.addWidget(self.save_button) + self.edit_button = QPushButton("编辑内容") + self.edit_button.setObjectName("Ghost") + self.edit_button.setCheckable(True) + self.edit_button.setToolTip( + f"就地编辑这个文本文件并保存回包内(上限 {EDIT_SIZE_LIMIT // 1024} KB)" + ) + self.edit_button.toggled.connect(self._on_edit_toggled) + preview_header.addWidget(self.edit_button) + self.page_prev = QPushButton("上一页") + self.page_next = QPushButton("下一页") + self.page_label = QLabel("") + theme.role(self.page_label, "faint") + for button in (self.page_prev, self.page_next): + button.setObjectName("Ghost") + button.setEnabled(False) + self.page_prev.clicked.connect(lambda: self._page(-1)) + self.page_next.clicked.connect(lambda: self._page(1)) + preview_header.addWidget(self.page_prev) + preview_header.addWidget(self.page_label) + preview_header.addWidget(self.page_next) + layout.addLayout(preview_header) + + self.preview = QStackedWidget() + self.text_view = QPlainTextEdit() + self.text_view.setReadOnly(True) + self.text_view.setFont(theme.font_mono(theme.SIZE_TINY)) + self.text_view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.text_view.setPlaceholderText("选中一个文件以查看内容") + self.hex_view = QPlainTextEdit() + self.hex_view.setReadOnly(True) + self.hex_view.setFont(theme.font_mono(theme.SIZE_TINY)) + self.hex_view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setMinimumHeight(140) + self.preview.addWidget(self.text_view) + self.preview.addWidget(self.hex_view) + self.preview.addWidget(self.image_label) + layout.addWidget(self.preview, 1) + + self.preview_note = QLabel("") + theme.role(self.preview_note, "faint") + self.preview_note.setWordWrap(True) + layout.addWidget(self.preview_note) + + # 选中变化先攒一下:按住方向键翻树时不该每个都去库里读一遍 + self._debounce = QTimer(self) + self._debounce.setSingleShot(True) + self._debounce.setInterval(90) + self._debounce.timeout.connect(self._request_detail) + + self._pending_path = None + self._page_index = 0 + self._page_count = 1 + self._last_data = b"" + + # ---- 文本编辑状态 ---- + self._editing = False + self._edit_dirty = False + self._edit_encoding = "utf-8" + self._edit_loading = False + self._switch_guard = False + self.text_view.textChanged.connect(self._on_text_changed) + shortcut = QShortcut(QKeySequence.StandardKey.Save, self) + shortcut.activated.connect(self._save_shortcut) + return panel + + # ------------------------------------------------------------------ + + def update_view(self, payload): + entries = payload.get("entries") or [] + self._set_edit_enabled(True) + self.model.load(entries) + self.tree.expandToDepth(0) + files = sum(1 for row in entries if not row["is_directory"]) + self.count_label.setText(f"{files} 文件 / {len(entries) - files} 目录") + if self._current and self.model.find(self._current).isValid(): + self.select_path(self._current) + else: + self._clear_detail() + + def on_empty(self, payload): + self.model.removeRows(0, self.model.rowCount()) + self._current = None + self.count_label.setText("") + self._set_edit_enabled(False) + self._clear_detail() + + def select_path(self, path): + index = self.proxy.mapFromSource(self.model.find(path)) + if index.isValid(): + self.tree.setCurrentIndex(index) + self.tree.scrollTo(index, QTreeView.ScrollHint.PositionAtCenter) + + # ------------------------------------------------------------------ + + def _selected_path(self): + index = self.tree.currentIndex() + if not index.isValid(): + return None + return self.proxy.mapToSource(index).data(ROLE_PATH) + + def _on_selected(self, current, _previous): + if not current.isValid(): + return + path = self.proxy.mapToSource(current).data(ROLE_PATH) + if path == self._current: + return + # 正在编辑且有未保存修改:先问一句;用户取消就把选中项拨回去 + if not self._switch_guard and not self._maybe_discard_before_switch(): + self._switch_guard = True + try: + self.select_path(self._current) + finally: + self._switch_guard = False + return + self._current = path + self._page_index = 0 + self._update_selection_buttons() + self._debounce.start() + + def _request_detail(self): + path = self._current + if not path or not self.payload: + return + self._pending_path = path + self.window.run( + "file_detail", {"path": path}, on_done=self._on_detail, on_fail=self._on_detail_failed + ) + + def _on_detail(self, payload): + # 用户可能已经翻到别的文件了,这份结果过期就别渲染 + if (payload.get("entry") or {}).get("path") != self._pending_path: + return + self._fill_detail(payload) + + def _on_detail_failed(self, title, detail): + self._clear_detail() + self.preview_note.setText(f"{title}:{detail}") + + def _clear_detail(self): + self.info.clear() + self.path_label.setText("未选中任何条目") + self.kind_badge.set_state("muted", "—") + self.hash_badge.set_state("muted", "—") + self.other_card.setVisible(False) + _clear_layout(self.other_list) + self.text_view.setPlainText("") + self.hex_view.setPlainText("") + self.image_label.clear() + self.preview_note.setText("") + self.export_button.setEnabled(False) + self.edit_button.setEnabled(False) + self._set_paging(0, 1) + + def _fill_detail(self, payload): + entry = payload["entry"] + blob = payload.get("blob") or {} + path = entry["path"] + is_dir = bool(entry.get("is_directory")) + + self.path_label.setText(path) + self.kind_badge.set_state( + "accent" if is_dir else "muted", + "目录" if is_dir else (payload.get("mime") or "文件"), + ) + content_hash = entry.get("content_hash") + self.hash_badge.set_state( + "ok" if content_hash else "muted", + f"sha256 {short_hash(content_hash, 10)}" if content_hash else "无内容", + ) + + self.info.clear() + if not is_dir: + self.info.add("大小", human_size(entry.get("file_size"))) + self.info.add("修改时间", human_time(entry.get("modified_time"))) + self.info.add( + "权限", + f"{perm_string(entry.get('permissions'))}({perm_octal(entry.get('permissions'))})", + ) + self.info.add("创建时间", human_time(entry.get("created_time"))) + if content_hash: + self.info.add("内容哈希", CopyLabel(content_hash)) + if blob: + self.info.add("压缩算法", blob.get("compress_algo") or "none") + self.info.add( + "库内占用", + f"{human_size(blob.get('stored_size'))} / {human_size(blob.get('size'))}" + f"({ratio_text(blob.get('stored_size'), blob.get('size'))})", + ) + refcount = payload.get("refcount") or 0 + cross = payload.get("cross_snapshot") or 0 + self.info.add( + "引用计数", + f"{refcount} 次" + (f"(其中 {cross} 次来自其它快照)" if cross else ""), + ) + + other_paths = [item for item in (payload.get("other_paths") or []) if item != path] + _clear_layout(self.other_list) + if other_paths: + self.other_card.setVisible(True) + self.other_title.set_subtitle( + f"同一份内容被 {len(other_paths) + 1} 个路径共用,库里只存了一份" + ) + for item in other_paths[:10]: + label = _JumpLabel(item, self.select_path) + self.other_list.addWidget(label) + if len(other_paths) > 10: + more = QLabel(f"…另外 {len(other_paths) - 10} 个路径") + theme.role(more, "faint") + self.other_list.addWidget(more) + else: + self.other_card.setVisible(False) + + self.export_button.setEnabled(True) + self._update_content_edit_availability() + if is_dir: + self.text_view.setPlainText("") + self.hex_view.setPlainText("") + self.image_label.clear() + self.preview.setCurrentIndex(0) + self.preview_note.setText("目录没有内容可预览。") + self._set_paging(0, 1) + return + if self._editing: + # 编辑中:详情(哈希/大小)照常刷新,但**不能**重载预览—— + # 那会把编辑器里未保存的内容覆盖掉。 + return + self._reload_page(reset=True) + + # ------------------------------------------------------------------ + + def _page(self, delta): + target = self._page_index + delta + if 0 <= target < self._page_count: + self._page_index = target + self._request_content() + + def _reload_page(self, reset=False): + if reset: + self._page_index = 0 + self._request_content() + + def _request_content(self, size=None): + path = self._current + if not path: + return + if size is None: + offset = self._page_index * PAGE_SIZE + size = PAGE_SIZE + else: + offset = 0 + self.window.run( + "read_file", + {"path": path, "offset": offset, "size": size}, + on_done=self._on_content, + on_fail=lambda title, detail: self.preview_note.setText(f"{title}:{detail}"), + ) + + def _on_content(self, payload): + if payload.get("path") != self._current: + return + data = payload.get("data") or b"" + self._last_data = data + if self._edit_loading: + self._edit_loading = False + self._load_editor(data) + return + entry = self.model.entry(self._current) or {} + total = entry.get("size") or 0 + self._page_count = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) if total else 1 + self._set_paging(self._page_index, self._page_count) + self._render(data, total) + + def _render(self, data, total): + mode = self.mode_box.currentData() + if mode == "hex": + lines, truncated = hexdump(data, limit=HEX_LIMIT) + self.hex_view.setPlainText("\n".join(lines)) + note = f"十六进制:本页显示 {human_size(min(len(data), HEX_LIMIT))}" + if truncated: + note += f",本页共 {human_size(len(data))}" + self.preview_note.setText(note) + self.preview.setCurrentIndex(1) + return + + if mode == "image": + image = QImage() + if not data or not image.loadFromData(data): + self.image_label.clear() + self.preview_note.setText("这些字节没法按图片解码,换「十六进制」看。") + self.preview.setCurrentIndex(2) + return + size = self.image_label.size() + pixmap = QPixmap.fromImage(image).scaled( + max(size.width() - 8, 120), + max(size.height() - 8, 120), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.image_label.setPixmap(pixmap) + self.preview_note.setText( + f"{image.width()}×{image.height()} 像素 · 本页 {human_size(len(data))}" + ) + self.preview.setCurrentIndex(2) + return + + is_binary, encoding, text = sniff_text(data) + if is_binary: + self.text_view.setPlainText("") + self.preview_note.setText("看起来是二进制内容,切到「十六进制」看字节。") + self.preview.setCurrentIndex(0) + return + self.text_view.setPlainText(text or "") + self.preview.setCurrentIndex(0) + note = f"按 {encoding} 解码 · 本页 {human_size(len(data))}" + if total and len(data) < total: + note += f"(整文件 {human_size(total)},可翻页,或导出后用编辑器打开)" + self.preview_note.setText(note) + + def _set_paging(self, index, count): + self._page_index = index + self._page_count = max(1, count) + self.page_label.setText(f"{index + 1}/{self._page_count}") + self.page_prev.setEnabled(index > 0) + self.page_next.setEnabled(index + 1 < self._page_count) + + # ------------------------------------------------------------------ + # 文本内容编辑 + # ------------------------------------------------------------------ + # 只对"整份塞得进编辑器"的文本文件开放(上限 EDIT_SIZE_LIMIT): + # 编辑框里只有一部分字节时就允许保存,等于把没显示的部分截掉——这个坑必须堵死。 + # 保存走 save_text 操作:按原编码编回字节 → 临时文件 → add_file(replace=True), + # 与打包共用同一条内容寻址写入路径。 + + def _update_content_edit_availability(self): + """按当前选中条目决定「编辑内容」的可用性。""" + entry = self.model.entry(self._current) if self._current else None + editable = bool(entry) and not entry.get("is_directory") + if editable: + size = entry.get("size") or 0 + # 空文件(0 字节)也允许进编辑——往空文件里写内容是正常需求 + editable = size <= EDIT_SIZE_LIMIT + if not editable: + self.edit_button.setToolTip( + f"这个文件有 {human_size(size)},超过就地编辑上限" + f"({EDIT_SIZE_LIMIT // 1024} KB)——导出改好后用「添加文件」替换回来" + ) + if editable: + self.edit_button.setToolTip( + f"就地编辑这个文本文件并保存回包内(上限 {EDIT_SIZE_LIMIT // 1024} KB)" + ) + self.edit_button.setEnabled(bool(entry) and editable) + + def _on_edit_toggled(self, checked): + if checked: + self.enter_edit_mode() + else: + self.exit_edit_mode() + + def enter_edit_mode(self): + """进入编辑:重新按完整大小读一份(预览可能只显示了一页)。""" + if not self._current: + self.edit_button.setChecked(False) + return + self._editing = True + self._edit_dirty = False + self._edit_loading = True + self.mode_box.setEnabled(False) + for button in (self.page_prev, self.page_next): + button.setEnabled(False) + self.save_button.setVisible(True) + self.save_button.setEnabled(False) + self.text_view.setPlaceholderText("正在读取完整内容…") + self._request_content(size=EDIT_SIZE_LIMIT) + + def _load_editor(self, data): + """把完整内容装进编辑器(进入编辑模式时调用)。""" + is_binary, encoding, text = sniff_text(data) + if is_binary: + self._editing = False + self.edit_button.setChecked(False) + self._leave_edit_ui() + self.preview_note.setText("这是二进制内容,没法当文本编辑。") + return + self._edit_encoding = encoding or "utf-8" + # setPlainText 会触发 textChanged;刚装进去的内容不算"用户改动" + self._edit_dirty = False + self.text_view.setPlainText(text or "") + self._edit_dirty = False + self.text_view.setReadOnly(False) + self.text_view.setStyleSheet(f"border: 1px solid {theme.ACCENT};") + self.save_button.setEnabled(False) + note = f"编辑中 · 按 {self._edit_encoding} 保存 · Ctrl+S 或「保存」写回包内" + if self._edit_encoding == "latin-1": + note += "(原文件不是 UTF-8/GBK,新输入的中文会保存失败)" + self.preview_note.setText(note) + + def _on_text_changed(self): + if not self._editing: + return + self._edit_dirty = True + self.save_button.setEnabled(True) + self.preview_note.setText("有未保存的修改 · Ctrl+S 或「保存」写回包内") + + def _save_shortcut(self): + if self._editing and self._edit_dirty: + self.save_edit() + + def save_edit(self): + """把编辑器里的文本写回包内。""" + if not self._editing or not self._current: + return None + text = self.text_view.toPlainText() + path = self._current + self._run_edit( + "save_text", + {"path": path, "text": text, "encoding": self._edit_encoding}, + label="保存内容", + done=lambda payload: self._on_saved(payload), + ) + return path + + def _on_saved(self, payload): + saved = payload.get("saved") or {} + self._edit_dirty = False + self.window.apply_payload(payload) + message = ( + f"已保存 {saved.get('path')}({human_size(saved.get('size'))}," + f"新内容哈希 {short_hash(saved.get('hash'), 10)})" + ) + # 详情面板刷新到新哈希/新大小。不能靠重新 select_path:这个文件在树里 + # 本来就是当前项,setCurrentIndex 不会再发 currentChanged 信号,面板就不动了。 + self._current = saved.get("path") + self._pending_path = None + self._debounce.start() + if payload.get("signatures"): + message += "。改动刷新了快照摘要,之前的签名已失效——需要的话重新签一次。" + self.window.notify(message) + # 留在编辑模式,方便继续改;退出由用户点「退出编辑」 + self.edit_button.setText("退出编辑") + + def exit_edit_mode(self, force=False): + """退出编辑。有未保存修改时先问一句(force=True 跳过询问)。""" + if not self._editing: + self._leave_edit_ui() + return + if self._edit_dirty and not force: + from PySide6.QtWidgets import QMessageBox + + answer = QMessageBox.question( + self, + "放弃修改?", + f"{self._current} 有未保存的修改,退出会丢弃这些改动。", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + # 退不出去就把开关拨回来(不触发下一次 toggle 的离开逻辑) + self.edit_button.blockSignals(True) + self.edit_button.setChecked(True) + self.edit_button.blockSignals(False) + return + self._editing = False + self._edit_dirty = False + self._leave_edit_ui() + # 回到只读预览:重读当前页 + if self._current: + self._reload_page(reset=True) + + def _leave_edit_ui(self): + self.text_view.setReadOnly(True) + self.text_view.setStyleSheet("") + self.save_button.setVisible(False) + self.save_button.setEnabled(False) + self.mode_box.setEnabled(True) + self.edit_button.setText("编辑内容") + if self._current: + self._update_content_edit_availability() + + def _maybe_discard_before_switch(self): + """切换选中项前处理未保存修改。返回 False 表示用户取消了切换。""" + if not self._editing or not self._edit_dirty: + return True + from PySide6.QtWidgets import QMessageBox + + answer = QMessageBox.question( + self, + "未保存的修改", + f"{self._current} 有未保存的修改。切换到别的文件会丢弃这些改动,继续吗?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if answer == QMessageBox.StandardButton.Yes: + self._editing = False + self._edit_dirty = False + self.edit_button.blockSignals(True) + self.edit_button.setChecked(False) + self.edit_button.blockSignals(False) + self._leave_edit_ui() + return True + return False + + # ------------------------------------------------------------------ + # 包内编辑 + # ------------------------------------------------------------------ + + def _set_edit_enabled(self, enabled): + for button in ( + getattr(self, "add_button", None), + getattr(self, "mkdir_button", None), + ): + if button is not None: + button.setEnabled(enabled) + self._update_selection_buttons() + + def _update_selection_buttons(self): + has_selection = bool(self._current) + self.rename_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + + def _selected_dir(self): + """当前选中的是目录就返回它,否则返回它所在的目录(新建/添加的落点)。""" + path = self._selected_path() + if not path: + return "" + entry = self.model.entry(path) or {} + if entry.get("is_directory"): + return path + return path.rsplit("/", 1)[0] if "/" in path else "" + + def _make_directory(self): + """按钮入口:问名字,然后交给 create_directory。""" + from PySide6.QtWidgets import QInputDialog + + name, ok = QInputDialog.getText(self, "新建目录", "目录名:") + if not ok: + return + self.create_directory(name, self._selected_dir()) + + def create_directory(self, name, parent=""): + """在 parent 下建目录(不弹窗,供测试与按钮共用)。""" + name = (name or "").strip().strip("/") + if not name: + return None + if "/" in name: + self.window.notify("目录名里不能带斜杠,要建多层请分几次") + return None + parent = (parent or "").strip().strip("/") + path = f"{parent}/{name}" if parent else name + if self.model.find(path).isValid(): + self.window.notify(f"{path} 已经存在") + return None + self._run_edit( + "mkdir", + {"path": path}, + label="新建目录", + done=lambda payload: self._after_edit(payload, f"已建目录 {path}", focus=path), + ) + return path + + def _rename_selected(self): + """按钮入口:问新名字,然后交给 rename_to。""" + from PySide6.QtWidgets import QInputDialog + + path = self._selected_path() + if not path: + return + name, ok = QInputDialog.getText( + self, "重命名", f"「{path}」改成:", text=path.rsplit("/", 1)[-1] + ) + if not ok: + return + self.rename_to(path, name) + + def rename_to(self, path, name): + """把 path 改名成同目录下的 name(不弹窗)。""" + if not path: + return None + name = (name or "").strip().strip("/") + old_name = path.rsplit("/", 1)[-1] + if not name or name == old_name: + return None + if "/" in name: + self.window.notify("这里只改名,不能换成别的路径(要换目录请用「添加文件」重新放)") + return None + parent = path.rsplit("/", 1)[0] if "/" in path else "" + dest = f"{parent}/{name}" if parent else name + if self.model.find(dest).isValid(): + self.window.notify(f"{dest} 已存在,换个名字") + return None + entry = self.model.entry(path) or {} + if entry.get("is_directory"): + affected = self._subtree_count(path) + note = f"目录连同里面的 {affected} 个条目一起改了名。" + else: + note = "只改路径与名字,内容(blob)不动,不会多占空间。" + self._run_edit( + "rename_path", + {"source": path, "dest": dest}, + label="重命名", + done=lambda payload: self._after_edit( + payload, f"已把 {path} 改名为 {dest}。{note}", focus=dest + ), + ) + return dest + + def _add_files(self): + dest = self._selected_dir() + paths = QFileDialog.getOpenFileNames(self, "选择要加进包里的文件")[0] + if not paths: + # 文件对话框选不了目录,所以再给一条"加整个文件夹"的路 + folder = QFileDialog.getExistingDirectory( + self, "或者选一个文件夹加进来(它会整棵进去)" + ) + if not folder: + return + paths = [folder] + self.add_local(paths, dest) + + def _add_directory(self): + dest = self._selected_dir() + folder = QFileDialog.getExistingDirectory(self, "选择要加进包里的文件夹") + if folder: + self.add_local([folder], dest) + + def add_local(self, paths, dest="", replace=None): + """把本地文件/文件夹加进当前快照。 + + ``replace=None`` 表示"先问一下有没有同名"(按钮路径); + True/False 则直接按这个决定走(测试与"确定替换"的路径)。 + """ + if replace is None: + conflicts = self._conflicting_paths(paths, dest) + if not conflicts: + replace = False + else: + replace = self._ask_conflicts(conflicts) + if replace is None: + return + self._run_edit( + "add_paths", + {"local_paths": list(paths), "dest_dir": dest, "replace_existing": bool(replace)}, + label="加进包里", + done=self._on_added, + ) + + def _ask_conflicts(self, conflicts): + """有同名时问一句:替换 / 跳过 / 取消。返回 True / False / None。""" + listing = conflicts[:8] + hidden = max(len(conflicts) - len(listing), 0) + message = ( + "这几个路径在当前快照里已经有了:\n\n" + + "\n".join(listing) + + (f"\n…另外 {hidden} 个" if hidden else "") + + "\n\n「是」= 替换(覆盖它们的元数据指向,内容仍按 sha256 去重);" + "「否」= 跳过这些路径。" + ) + answer = QMessageBox.question( + self, + "有同名条目", + message, + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, + ) + if answer == QMessageBox.StandardButton.Cancel: + return None + return answer == QMessageBox.StandardButton.Yes + + def _conflicting_paths(self, local_paths, dest): + """加之前先算一遍会不会撞路径(本地扫描,不碰库)。""" + existing = { + row["path"] for row in (self.payload or {}).get("entries") or [] + } + conflicts = [] + for local in local_paths: + local = os.path.abspath(local) + if os.path.isdir(local): + base = os.path.basename(local) + for root, _dirs, names in os.walk(local): + for name in names: + rel = os.path.relpath(os.path.join(root, name), os.path.dirname(local)) + candidate = f"{dest}/{rel}".replace(os.sep, "/").strip("/") + if candidate in existing: + conflicts.append(candidate) + else: + candidate = f"{dest}/{os.path.basename(local)}".strip("/") + if candidate in existing: + conflicts.append(candidate) + return sorted(conflicts) + + def _on_added(self, payload): + added = len(payload.get("added_files") or []) + len(payload.get("added_dirs") or []) + skipped = len(payload.get("conflicts") or []) if not payload.get("replaced") else 0 + failed = payload.get("failed") or [] + parts = [f"加进 {added} 个条目"] + if skipped: + parts.append(f"跳过同名的 {skipped} 个") + if failed: + parts.append(f"{len(failed)} 个失败") + if payload.get("dest_dir"): + parts.append(f"落在 {payload['dest_dir']}/") + self._after_edit(payload, ",".join(parts) + "。") + + def _delete_selected(self): + """按钮入口:先确认,再交给 delete_path。""" + path = self._selected_path() + if not path: + return + affected = self._subtree_entries(path) + entries = len(affected) or 1 + is_dir = bool((self.model.entry(path) or {}).get("is_directory")) + total_bytes = sum(row.get("file_size") or 0 for row in affected) + detail = ( + f"会删掉 {entries} 个条目(逻辑大小 {human_size(total_bytes)})" + if is_dir + else "会删掉这个文件条目" + ) + answer = QMessageBox.question( + self, + "删除", + f"从当前快照里删除「{path}」?\n\n{detail}。\n\n" + "注意:库里是内容寻址 + 引用计数——同一份内容如果还被别的路径或别的快照引用," + "它就只是少了一个引用而不会真的消失;一条引用都不剩时才会被回收。\n" + "没有回收站,这个操作不能撤销。", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + self.delete_path(path) + + def delete_path(self, path): + """从当前快照删掉一个路径(含子项)。不弹窗,供测试与按钮共用。""" + if not path: + return None + self._run_edit( + "remove_paths", + {"paths": [path]}, + label="删除", + done=lambda payload: self._after_edit(payload, f"已删除 {path}。"), + ) + return path + + def _subtree_entries(self, path): + """这个路径牵动的全部条目(含自己与子项)。只读 payload,不碰库。""" + entries = (self.payload or {}).get("entries") or [] + prefix = path + "/" + return [ + row + for row in entries + if row["path"] == path or row["path"].startswith(prefix) + ] + + def _subtree_count(self, path): + return len(self._subtree_entries(path)) - 1 + + def _run_edit(self, op, payload, label, done): + if not self.payload: + self.window.notify("先打开一个包") + return + + def wrapped(result): + done(result) + + self.window.run(op, payload, on_done=wrapped, label=label) + + def _after_edit(self, payload, message, focus=None): + self.window.apply_payload(payload) + if focus: + self._current = None + self.select_path(focus) + if payload.get("signatures"): + message += "(改动刷新了快照摘要,之前的签名已失效——需要的话重新签一次。)" + self.window.notify(message) + + # ------------------------------------------------------------------ + + def _show_menu(self, position): + index = self.tree.indexAt(position) + menu = QMenu(self) + if index.isValid(): + source = self.proxy.mapToSource(index) + path = source.data(ROLE_PATH) + content_hash = source.data(ROLE_HASH) + is_dir = bool(source.data(ROLE_IS_DIR)) + menu.addAction("复制路径", lambda: QApplication.clipboard().setText(path)) + if content_hash: + menu.addAction( + "复制内容哈希", + lambda: QApplication.clipboard().setText(content_hash), + ) + menu.addAction("导出到文件", self._export_selected) + if self.payload and self.add_button.isEnabled(): + menu.addSeparator() + where = "此处" if is_dir else "它所在的目录" + menu.addAction(f"添加文件到{where}", self._add_files) + menu.addAction(f"添加文件夹到{where}", self._add_directory) + menu.addAction(f"新建目录到{where}", self._make_directory) + menu.addSeparator() + menu.addAction("重命名", self._rename_selected) + menu.addAction("删除(含子项)", self._delete_selected) + menu.addSeparator() + menu.addAction("展开全部", self.tree.expandAll) + menu.addAction("折叠全部", self.tree.collapseAll) + menu.exec(self.tree.viewport().mapToGlobal(position)) + + def _export_selected(self): + path = self._selected_path() + if not path: + return + start = self.window.settings.value("last_export_dir", "", type=str) or "" + target, _filter = QFileDialog.getSaveFileName( + self, "导出到", os.path.join(start, os.path.basename(path)) + ) + if not target: + return + self.window.settings.setValue("last_export_dir", os.path.dirname(target)) + self.window.run( + "export_path", + {"vfs_path": path, "local_path": target}, + on_done=lambda result: self.window.notify( + f"已导出到 {result.get('target')}" + ), + label="导出", + ) + + +class _JumpLabel(QLabel): + """点一下就跳到对应路径的小链接。""" + + def __init__(self, path, on_click): + super().__init__(path) + self._path = path + self._on_click = on_click + self.setFont(theme.font_mono(theme.SIZE_TINY)) + self.setStyleSheet(f"color: {theme.ACCENT};") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setToolTip("点击跳转") + + def mousePressEvent(self, event): # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton: + self._on_click(self._path) + + +def _clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.hide() + widget.deleteLater() + + +def build_browser(window): + return BrowserView(window) diff --git a/src/svfs_studio/views/integrity.py b/src/svfs_studio/views/integrity.py new file mode 100644 index 0000000..6492ae6 --- /dev/null +++ b/src/svfs_studio/views/integrity.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""完整性页:摘要校验与 HMAC 签名。 + +两个必须说清楚的边界,界面上也直接写着: + +* ``verify()``(不带 check_content)只比元数据摘要,**发现不了"同大小换了内容"**; + 要发现得逐文件重算 sha256,也就是 ``verify(check_content=True)``,代价是把内容都读一遍。 +* 签名是对称的 HMAC-SHA256:`signatures` 表只用来检测"快照在签名之后被改过", + 它**不是密钥托管**,密钥得自己从环境变量/KMS 里来。 +""" + +from PySide6.QtWidgets import ( + QCheckBox, + QGridLayout, + QLabel, + QLineEdit, + QPlainTextEdit, + QPushButton, + QWidget, +) + +from .. import theme +from ..format import human_time, short_hash +from ..widgets.cards import ( + Badge, + Card, + CopyLabel, + InfoGrid, + SectionTitle, + TableItem, + Grid, + hbox, + vbox, +) +from .base import BaseView + + +class IntegrityView(BaseView): + PLACEHOLDER_TITLE = "还没有打开包" + PLACEHOLDER_DETAIL = "打开一个包后,这里可以校验摘要、逐文件重算哈希,并做 HMAC 签名。" + + def build_content(self): + page = QWidget() + layout = vbox(page, 0, 8) + + # ---- 校验 ---- + check_card = Card(padding=theme.PAD, spacing=6) + header = hbox(spacing=6) + header.addWidget( + SectionTitle("摘要校验", "对当前快照的 path 排序后求 sha256(与库里记录的值比)"), + 1, + ) + self.meta_button = QPushButton("校验元数据") + self.meta_button.setObjectName("Primary") + self.meta_button.setToolTip("很快:只比快照摘要与库内记录") + self.meta_button.clicked.connect(lambda: self._run_verify(False)) + self.content_button = QPushButton("逐文件校验内容") + self.content_button.setToolTip("慢:把每个文件的内容读出来重算 sha256") + self.content_button.clicked.connect(lambda: self._run_verify(True)) + header.addWidget(self.meta_button) + header.addWidget(self.content_button) + check_card.add_layout(header) + + self.result_badge = Badge("尚未校验", "muted") + self.result_detail = QLabel("") + self.result_detail.setWordWrap(True) + theme.role(self.result_detail, "muted") + check_card.add(self.result_badge) + check_card.add(self.result_detail) + + self.digest_info = InfoGrid(columns=1) + check_card.add(self.digest_info) + + self.errors_view = QPlainTextEdit() + self.errors_view.setReadOnly(True) + self.errors_view.setFont(theme.font_mono(theme.SIZE_TINY)) + self.errors_view.setMaximumHeight(120) + self.errors_view.setVisible(False) + check_card.add(self.errors_view) + layout.addWidget(check_card) + + # ---- 签名 ---- + sign_card = Card(padding=theme.PAD, spacing=6) + sign_card.add( + SectionTitle( + "HMAC 签名", + "对快照摘要做 HMAC-SHA256;密钥是对称的,库里只存签名不存密钥", + ) + ) + form = QGridLayout() + form.setHorizontalSpacing(10) + form.setVerticalSpacing(8) + form.addWidget(QLabel("密钥"), 0, 0) + self.key_input = QLineEdit() + self.key_input.setEchoMode(QLineEdit.EchoMode.Password) + self.key_input.setPlaceholderText("签名/验签用的密钥(不会落库)") + form.addWidget(self.key_input, 0, 1) + form.addWidget(QLabel("key_id"), 0, 2) + self.key_id_input = QLineEdit() + self.key_id_input.setPlaceholderText("可选,比如 ci-2026-09") + form.addWidget(self.key_id_input, 0, 3) + form.setColumnStretch(1, 1) + form.setColumnStretch(3, 1) + sign_card.add_layout(form) + + buttons = hbox(spacing=6) + self.show_key = QCheckBox("显示密钥") + self.show_key.toggled.connect( + lambda checked: self.key_input.setEchoMode( + QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password + ) + ) + self.sign_button = QPushButton("签名当前快照") + self.sign_button.clicked.connect(self._sign) + self.verify_button = QPushButton("用这个密钥验签") + self.verify_button.clicked.connect(self._verify_signature) + buttons.addWidget(self.show_key) + buttons.addStretch(1) + buttons.addWidget(self.sign_button) + buttons.addWidget(self.verify_button) + sign_card.add_layout(buttons) + + self.sign_note = QLabel("") + self.sign_note.setWordWrap(True) + theme.role(self.sign_note, "faint") + sign_card.add(self.sign_note) + + self.signature_table = Grid(["id", "key_id", "算法", "签名", "签名时间"]) + sign_card.add(self.signature_table) + layout.addWidget(sign_card, 1) + return page + + # ------------------------------------------------------------------ + + def update_view(self, payload): + stats = payload.get("stats") or {} + digest = stats.get("digest") + self._fill_digest_info(payload) + self._fill_signatures(payload) + + signatures = payload.get("signatures") or [] + if signatures and digest: + self.sign_note.setText( + f"当前快照有 {len(signatures)} 条签名记录。签名只覆盖摘要," + "所以摘要变了签名就失效——这正好是「改过内容就验不过」的语义。" + ) + elif not digest: + self.sign_note.setText( + "当前快照还没有摘要,签名前会先算一次(写入操作里 recount() 也会刷新它)。" + ) + + def on_empty(self, payload): + self.result_badge.set_state("muted", "尚未校验") + self.result_detail.setText("") + self.digest_info.clear() + self.signature_table.clear() + self.errors_view.setVisible(False) + + def _fill_digest_info(self, payload): + stats = payload.get("stats") or {} + digest = stats.get("digest") + snapshots = payload.get("snapshots") or [] + self.digest_info.clear() + self.digest_info.add( + "当前快照", + f"{payload.get('snapshot_name') or '-'}(id={payload.get('snapshot_id')})", + ) + self.digest_info.add("文件数", f"{stats.get('total_files') or 0}") + if digest: + self.digest_info.add("库内记录的摘要", CopyLabel(digest)) + else: + self.digest_info.add( + "库内记录的摘要", "未计算(校验时会当成「不通过」)", color=theme.WARN + ) + self.digest_info.add("快照总数", f"{len(snapshots)}") + + def _fill_signatures(self, payload): + rows = [] + for record in payload.get("signatures") or []: + signature = record.get("signature") or "" + rows.append( + [ + TableItem(record.get("id"), sort_value=record.get("id") or 0), + TableItem(record.get("key_id") or "(未命名)"), + TableItem(record.get("algorithm") or "hmac-sha256", mono=True), + TableItem( + short_hash(signature, 16), + sort_value=signature, + mono=True, + tooltip=signature, + ), + TableItem( + human_time(record.get("signed_time")), + sort_value=str(record.get("signed_time") or ""), + ), + ] + ) + self.signature_table.set_rows(rows, sort_column=0) + + # ------------------------------------------------------------------ + + def _run_verify(self, check_content): + if check_content: + answer = _confirm( + self, + "逐文件校验内容", + "会把当前快照里每个文件的内容都读出来重算 sha256,文件多或文件大时会比较慢。\n\n" + "回报:它能发现「元数据一样但内容被换过」的情况,而普通校验发现不了。\n\n继续吗?", + ) + if not answer: + return + self.window.run( + "verify", + {"check_content": check_content}, + on_done=self._on_verify, + label="逐文件校验" if check_content else "校验元数据", + ) + + def _on_verify(self, payload): + report = payload.get("report") or {} + errors = report.get("errors") or [] + ok = report.get("ok") + self.result_badge.set_state("ok" if ok else "err", "通过" if ok else "未通过") + elapsed = payload.get("elapsed") or 0 + detail = ( + f"检查了 {report.get('files_checked', 0)} 个文件" + f",逐文件重算哈希 {report.get('content_checked', 0)} 个" + f",耗时 {elapsed:.2f} 秒。" + ) + if report.get("digest_ok"): + detail += " 摘要与库内记录一致。" + elif report.get("expected_digest") is None: + detail += " 库内没有记录摘要——按当前实现这算不通过(免得改完内容还能「验过」)。" + else: + detail += " 摘要与库内记录不一致。" + self.result_detail.setText(detail) + + self.errors_view.setVisible(bool(errors)) + if errors: + self.errors_view.setPlainText("\n".join(str(item) for item in errors[:200])) + + self.digest_info.clear() + stats = (self.payload or {}).get("stats") or {} + self.digest_info.add("期望摘要", report.get("expected_digest") or "(无记录)", + mono=True) + self.digest_info.add("实际摘要", CopyLabel(report.get("actual_digest") or "-")) + + def _sign(self): + key = self.key_input.text() + if not key: + self.sign_note.setText("请先填密钥:签名是 HMAC,没有密钥算不出签名。") + return + if not (self.payload or {}).get("stats", {}).get("digest"): + self.sign_note.setText("当前快照还没有摘要,签名会先算摘要。") + self.window.run( + "sign", + { + "key": key, + "key_id": self.key_id_input.text().strip() or None, + "snapshot_id": (self.payload or {}).get("snapshot_id"), + }, + on_done=self._on_sign, + label="签名", + ) + + def _on_sign(self, payload): + signature = payload.get("signature") or "" + self.sign_note.setText( + f"已签名(key_id={payload.get('key_id') or '未命名'}):{signature[:32]}…" + " 密钥没有写进库,请自己保存好——丢了就没法验签。" + ) + self.window.refresh() + + def _verify_signature(self): + if not self.key_input.text(): + self.sign_note.setText("请先填用于验签的密钥。") + return + self.window.run( + "verify_signature", + { + "key": self.key_input.text(), + "snapshot_id": (self.payload or {}).get("snapshot_id"), + "key_id": self.key_id_input.text().strip() or None, + }, + on_done=self._on_verify_signature, + label="验签", + ) + + def _on_verify_signature(self, payload): + report = payload.get("report") or {} + if report.get("ok"): + self.sign_note.setText( + f"验签通过:key_id={report.get('key_id') or '未命名'}," + f"签名时间 {human_time(report.get('signed_time'))}。" + "说明快照摘要自签名以来没变过。" + ) + else: + errors = report.get("errors") or ["签名不匹配"] + self.sign_note.setText("验签不通过:" + ";".join(str(item) for item in errors)) + + +def _confirm(parent, title, text): + from PySide6.QtWidgets import QMessageBox + + answer = QMessageBox.question( + parent, + title, + text, + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + return answer == QMessageBox.StandardButton.Ok + + +def build_integrity(window): + return IntegrityView(window) diff --git a/src/svfs_studio/views/internals.py b/src/svfs_studio/views/internals.py new file mode 100644 index 0000000..a3b1729 --- /dev/null +++ b/src/svfs_studio/views/internals.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""内部结构页:把包当成一个 SQLite 文件来看 + 只读 SQL 控制台。 + +两件事放在一页是因为它们服务同一个目的:当"通过 API 看到的东西"和预期不符时, +直接看库里的字节、页、表、行。比如"文件明明删了为什么还占空间"——dbstat 的表占用 +和 blobs 的行数会直接把答案摆出来。 + +SQL 控制台是双层只读:语句白名单(只放 SELECT/WITH/PRAGMA/EXPLAIN 的单条语句) ++ 连接本身就是 ``mode=ro``。任一层都能独立挡住写操作。 +""" + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QComboBox, + QHeaderView, + QLabel, + QPlainTextEdit, + QPushButton, + QSplitter, + QTableWidget, + QTableWidgetItem, + QWidget, +) + +from .. import analysis, theme +from ..format import human_count, human_size, percent_text +from ..widgets.cards import ( + Badge, + Card, + Grid, + InfoGrid, + SectionTitle, + TableItem, + hbox, + vbox, +) +from ..widgets.charts import MeterRow +from .base import BaseView + + +class InternalsView(BaseView): + PLACEHOLDER_TITLE = "还没有打开包" + PLACEHOLDER_DETAIL = "打开一个包(哪怕是旧格式)后,这里能看到它的 SQLite 内部结构与只读 SQL 控制台。" + #: 旧格式包也能看内部结构,所以这个页面的占位文案不一样 + LEGACY_DETAIL = "文件树看不了,但内部结构与 SQL 控制台仍然可用——正好可以用来确认它到底是什么。" + + def build_content(self): + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(self._build_internals_side()) + splitter.addWidget(self._build_sql_side()) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 2) + splitter.setSizes([430, 330]) + return splitter + + # ------------------------------------------------------------------ + + def _build_internals_side(self): + page = QWidget() + layout = vbox(page, 0, 6) + + top = hbox(spacing=10) + + header_card = Card(padding=theme.PAD, spacing=6) + header_row = hbox(spacing=6) + header_row.addWidget(SectionTitle("文件头", "SQLite 数据库头的前 100 字节"), 1) + self.header_badge = Badge("—", "muted") + header_row.addWidget(self.header_badge) + header_card.add_layout(header_row) + self.header_info = InfoGrid(columns=2) + header_card.add(self.header_info) + top.addWidget(header_card, 1) + + page_card = Card(padding=theme.PAD, spacing=6) + page_row = hbox(spacing=6) + page_row.addWidget(SectionTitle("页与空间", "SQLite 按页读写,空闲页不会自动归还给操作系统"), 1) + page_card.add_layout(page_row) + self.page_info = InfoGrid(columns=2) + page_card.add(self.page_info) + self.free_meter = MeterRow(label="空闲页占比", color=theme.WARN) + page_card.add(self.free_meter) + self.page_note = QLabel("") + theme.role(self.page_note, "faint") + self.page_note.setWordWrap(True) + page_card.add(self.page_note) + top.addWidget(page_card, 1) + layout.addLayout(top) + + table_card = Card(padding=theme.PAD, spacing=6) + table_row = hbox(spacing=6) + table_row.addWidget(SectionTitle("表与索引", "占用来自 dbstat 虚拟表(精确到页)"), 1) + self.occupancy_badge = Badge("—", "muted") + table_row.addWidget(self.occupancy_badge) + table_card.add_layout(table_row) + self.table = Grid(["类型", "名称", "所属表", "根页", "行数", "占用", "页数"]) + table_card.add(self.table, 1) + layout.addWidget(table_card, 1) + return page + + def _build_sql_side(self): + page = QWidget() + layout = vbox(page, 0, 6) + + row = hbox(spacing=6) + row.addWidget(SectionTitle("只读 SQL", "只允许单条 SELECT / WITH / PRAGMA / EXPLAIN"), 1) + self.preset_box = QComboBox() + self.preset_box.addItem("预置查询", None) + for title, sql in analysis.SQL_PRESETS: + self.preset_box.addItem(title, sql) + self.preset_box.currentIndexChanged.connect(self._apply_preset) + row.addWidget(self.preset_box) + self.run_button = QPushButton("运行") + self.run_button.setObjectName("Primary") + self.run_button.setToolTip("Ctrl+Enter") + self.run_button.clicked.connect(self._run_sql) + row.addWidget(self.run_button) + layout.addLayout(row) + + self.sql_edit = QPlainTextEdit() + self.sql_edit.setFont(theme.font_mono(theme.SIZE_SMALL)) + self.sql_edit.setPlaceholderText( + "SELECT path, file_size, content_hash FROM files ORDER BY file_size DESC LIMIT 20;\n\n" + "(Ctrl+Enter 运行;连接是 mode=ro,也挡不住的话白名单先挡)" + ) + self.sql_edit.setMinimumHeight(56) + self.sql_edit.setMaximumHeight(104) + layout.addWidget(self.sql_edit) + + shortcut = QShortcut(QKeySequence("Ctrl+Return"), self) + shortcut.activated.connect(self._run_sql) + shortcut2 = QShortcut(QKeySequence("Ctrl+Enter"), self) + shortcut2.activated.connect(self._run_sql) + + self.sql_note = QLabel("") + theme.role(self.sql_note, "muted") + self.sql_note.setWordWrap(True) + layout.addWidget(self.sql_note) + + self.result_table = QTableWidget(0, 0) + self.result_table.verticalHeader().setVisible(False) + self.result_table.setAlternatingRowColors(True) + self.result_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.result_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.result_table.setShowGrid(False) + self.result_table.horizontalHeader().setStretchLastSection(True) + layout.addWidget(self.result_table, 1) + return page + + # ------------------------------------------------------------------ + + def update_view(self, payload): + if payload.get("legacy") is not None or payload.get("error"): + self._load_internals() + return + self._load_internals() + + def on_empty(self, payload): + self.header_info.clear() + self.page_info.clear() + self.table.clear() + self.result_table.setRowCount(0) + self.result_table.setColumnCount(0) + + def _load_internals(self): + self.window.run("internals", {}, on_done=self._on_internals) + + def _on_internals(self, payload): + header = payload.get("header") or {} + if not header.get("valid"): + self.header_badge.set_state("err", "不是 SQLite 文件") + self.header_info.clear() + self.header_info.add("问题", header.get("error") or "文件头解析失败", color=theme.ERR) + self.page_info.clear() + self.table.clear() + self.page_note.setText("") + return + self.header_badge.set_state("ok", "SQLite format 3") + + self.header_info.clear() + self.header_info.add("页大小", f"{human_count(header.get('page_size'))} 字节") + self.header_info.add("页数", human_count(header.get("page_count"))) + self.header_info.add("变更计数", human_count(header.get("change_counter"))) + self.header_info.add("读写版本", str(header.get("version"))) + self.header_info.add("文本编码", header.get("text_encoding_name") or "-") + self.header_info.add( + "schema 格式", str(header.get("schema_format")) + ) + self.header_info.add("user_version", str(header.get("user_version"))) + self.header_info.add("application_id", str(header.get("application_id"))) + self.header_info.add("创建它的 SQLite", header.get("sqlite_version_text") or "-") + + pragmas = payload.get("pragmas") or {} + total = payload.get("total_bytes") or 0 + free = payload.get("free_bytes") or 0 + used = payload.get("used_bytes") or 0 + self.page_info.clear() + self.page_info.add("库文件大小", human_size(total)) + self.page_info.add("已用", human_size(used)) + self.page_info.add("空闲页", f"{human_count(pragmas.get('freelist_count'))}({human_size(free)})") + self.page_info.add("journal 模式", str(pragmas.get("journal_mode") or "-")) + self.page_info.add("auto_vacuum", str(pragmas.get("auto_vacuum") or 0)) + self.free_meter.set_value(free / total if total else 0, percent_text(free, total)) + self.page_note.setText( + "空闲页来自删除过的数据(比如 GC 掉的 blob、删掉的快照)。" + "它不会被自动归还,想回收得执行 VACUUM——那样会重写整个库文件。" + ) + + mode = payload.get("occupancy_mode") + self.occupancy_badge.set_state( + "ok" if mode == "dbstat" else "warn", + "dbstat 精确占用" if mode == "dbstat" else "无 dbstat:只统计行数", + ) + rows = [] + for item in payload.get("occupancy") or []: + rows.append( + [ + TableItem(_object_type(item["name"], payload.get("objects") or [])), + TableItem(item["name"], mono=True), + TableItem(_owner(item["name"], payload.get("objects") or [])), + TableItem(_root_page(item["name"], payload.get("objects") or [])), + TableItem( + human_count(item["rows"]) if item.get("rows") is not None else "-", + sort_value=item.get("rows") or -1, + align_right=True, + ), + TableItem( + human_size(item["bytes"]) if item.get("bytes") is not None else "-", + sort_value=item.get("bytes") or -1, + align_right=True, + ), + TableItem( + human_count(item["pages"]) if item.get("pages") is not None else "-", + sort_value=item.get("pages") or -1, + align_right=True, + ), + ] + ) + self.table.set_rows(rows, sort_column=5, descending=True) + + # ------------------------------------------------------------------ + + def _apply_preset(self): + sql = self.preset_box.currentData() + if sql: + self.sql_edit.setPlainText(sql) + self._run_sql() + + def _run_sql(self): + sql = self.sql_edit.toPlainText().strip() + if not sql: + self.sql_note.setText("先写一条查询。") + return + self.window.run( + "run_sql", + {"sql": sql}, + on_done=self._on_result, + on_fail=self._on_sql_failed, + label="查询", + ) + + def _on_sql_failed(self, title, detail): + self.sql_note.setText(f"{title}:{detail}") + self.result_table.setRowCount(0) + self.result_table.setColumnCount(0) + + def _on_result(self, payload): + columns = payload.get("columns") or [] + rows = payload.get("rows") or [] + elapsed = (payload.get("elapsed") or 0) * 1000 + self.result_table.setSortingEnabled(False) + self.result_table.setColumnCount(len(columns)) + self.result_table.setHorizontalHeaderLabels([str(name) for name in columns]) + self.result_table.setRowCount(len(rows)) + for row_index, row in enumerate(rows): + for column_index, value in enumerate(row): + if isinstance(value, bytes): + text = f"<{len(value)} 字节二进制>" + elif value is None: + text = "NULL" + else: + text = str(value) + item = QTableWidgetItem(text) + if isinstance(value, (int, float)): + item.setTextAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + if value is None: + item.setForeground(theme.qcolor(theme.TEXT_FAINT)) + self.result_table.setItem(row_index, column_index, item) + self.result_table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Interactive + ) + self.result_table.resizeColumnsToContents() + note = f"返回 {len(rows)} 行 × {len(columns)} 列,耗时 {elapsed:.1f} ms" + if payload.get("truncated"): + note += "(只取前 500 行,需要更多请自己加 LIMIT/OFFSET)" + self.sql_note.setText(note) + + # ------------------------------------------------------------------ + +def _object_type(name, objects): + for obj in objects: + if obj["name"] == name: + return {"table": "表", "index": "索引", "view": "视图", "trigger": "触发器"}.get( + obj["type"], obj["type"] + ) + return "-" + + +def _owner(name, objects): + for obj in objects: + if obj["name"] == name: + return obj.get("table") or "-" + return "-" + + +def _root_page(name, objects): + for obj in objects: + if obj["name"] == name: + return TableItem(str(obj.get("rootpage")), sort_value=obj.get("rootpage") or 0) + return "-" + + +def build_internals(window): + return InternalsView(window) diff --git a/src/svfs_studio/views/ops.py b/src/svfs_studio/views/ops.py new file mode 100644 index 0000000..923b2c8 --- /dev/null +++ b/src/svfs_studio/views/ops.py @@ -0,0 +1,1080 @@ +#!/usr/bin/env python3 +"""工具箱:打包 / 解包 / 迁移 / 回收 / 发布 / 拉取 / 本地对比。 + +这些是"写路径",全部走库的公开 API(FolderPacker、migration.migrate、publish.export_objects、 +ObjectFetcher…),所以结果与 CLI 逐字节一致——界面上不会出现第二套实现。 + +进度与中断:库的 pack_folder / migrate / export_objects / ObjectFetcher 都支持进度回调, +回调抛异常会一路向上抛,所以这几个任务的「中断」是真的能中断的(在下一个进度点生效)。 +库没有进度回调的操作(解包、逐文件校验)只显示不确定进度,且不给假的取消按钮。 +""" + +import os + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QFileDialog, + QGridLayout, + QLabel, + QLineEdit, + QListWidget, + QMessageBox, + QPushButton, + QSpinBox, + QStackedWidget, + QTreeWidget, + QTreeWidgetItem, + QWidget, +) + +from .. import analysis, theme +from ..format import human_count, human_size, short_hash +from ..widgets.cards import ( + Badge, + Card, + InfoGrid, + SectionTitle, + TableItem, + Grid, + hbox, + vbox, +) +from .base import BaseView + +#: 支持"真中断"的操作(库在这些任务的循环里有进度回调,异常能向上抛) +CANCELLABLE = {"pack", "migrate", "publish", "fetch", "compare_local"} + + +class OpsView(BaseView): + PLACEHOLDER_TITLE = "工具箱" + PLACEHOLDER_DETAIL = ( + "这里不需要先打开包:打包、迁移、拉取、本地对比都可以直接指定路径。" + "解包、发布、回收针对的是当前打开的那个包。" + ) + + # ------------------------------------------------------------------ + # 搭建 + # ------------------------------------------------------------------ + + def build_content(self): + page = QWidget() + layout = vbox(page, 0, 8) + self._cancellable = False + + row = hbox(spacing=10) + row.addWidget(self._build_op_list()) + self.stack = QStackedWidget() + for builder in ( + self._build_pack_page, + self._build_unpack_page, + self._build_migrate_page, + self._build_maintain_page, + self._build_publish_page, + self._build_fetch_page, + self._build_compare_page, + ): + self.stack.addWidget(builder()) + row.addWidget(self.stack, 1) + layout.addLayout(row, 1) + + layout.addWidget(self._build_result_card()) + return page + + def _build_op_list(self): + panel = QWidget() + panel.setMaximumWidth(190) + layout = vbox(panel, 0, 6) + layout.addWidget(SectionTitle("操作", "写路径统一走库自己的 API")) + self.op_list = QListWidget() + for title in ( + "打包文件夹", + "解包到目录", + "迁移旧格式", + "回收 / 重算", + "发布到对象存储", + "从清单拉取", + "与本地目录对比", + ): + self.op_list.addItem(title) + self.op_list.setCurrentRow(0) + self.op_list.currentRowChanged.connect(self._on_op_changed) + layout.addWidget(self.op_list, 1) + + self.cancel_button = QPushButton("中断当前任务") + self.cancel_button.setObjectName("Danger") + self.cancel_button.setEnabled(False) + self.cancel_button.setToolTip("在下一个进度点生效(打包/迁移/发布/拉取/对比支持)") + self.cancel_button.clicked.connect(self._cancel) + layout.addWidget(self.cancel_button) + self.window.client.busy_changed.connect(self._on_busy) + return panel + + def _build_result_card(self): + card = Card(padding=theme.PAD, spacing=6) + header = hbox(spacing=6) + self.result_title = SectionTitle("最近一次操作", "结果会显示在这里,详细输出在「日志」里") + header.addWidget(self.result_title, 1) + self.result_badge = Badge("还没有执行过操作", "muted") + header.addWidget(self.result_badge) + card.add_layout(header) + self.result_info = InfoGrid(columns=2) + card.add(self.result_info) + self.result_table = Grid(["路径", "哈希", "大小", "说明"]) + self.result_table.setVisible(False) + card.add(self.result_table, 1) + self.result_note = QLabel("") + theme.role(self.result_note, "muted") + self.result_note.setWordWrap(True) + self.result_note.setVisible(False) + card.add(self.result_note) + self.copy_button = QPushButton("复制") + self.copy_button.setObjectName("Ghost") + self.copy_button.setVisible(False) + self.copy_button.clicked.connect(self._copy_result) + card.add(self.copy_button) + self._copy_text = "" + return card + + # ------------------------------------------------------------------ + # 各页表单 + # ------------------------------------------------------------------ + + def _line(self, placeholder="", value=""): + edit = QLineEdit(value) + edit.setPlaceholderText(placeholder) + return edit + + def _with_browse(self, edit, handler, text="选择"): + box = QWidget() + layout = hbox(box, 0, 6) + layout.addWidget(edit, 1) + button = QPushButton(text) + button.clicked.connect(handler) + layout.addWidget(button) + return box + + def _form(self, title, subtitle=""): + page = QWidget() + layout = vbox(page, 0, 8) + layout.addWidget(SectionTitle(title, subtitle)) + grid = QGridLayout() + grid.setHorizontalSpacing(10) + grid.setVerticalSpacing(8) + grid.setColumnStretch(1, 1) + layout.addLayout(grid) + page.grid = grid + page.layout_ref = layout + self._grid_row = {id(page): 0} + return page + + def _row(self, page, label, widget, hint=""): + grid = page.grid + index = self._grid_row[id(page)] + label_widget = QLabel(label) + theme.role(label_widget, "muted") + label_widget.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop) + grid.addWidget(label_widget, index, 0) + grid.addWidget(widget, index, 1) + if hint: + note = QLabel(hint) + theme.role(note, "faint") + note.setWordWrap(True) + grid.addWidget(note, index + 1, 1) + self._grid_row[id(page)] = index + 2 + else: + self._grid_row[id(page)] = index + 1 + return widget + + def _buttons(self, page, primary_text, handler, extra=None): + row = hbox(spacing=6) + if extra is not None: + row.addWidget(extra) + row.addStretch(1) + button = QPushButton(primary_text) + button.setObjectName("Primary") + button.clicked.connect(handler) + row.addWidget(button) + page.layout_ref.addLayout(row) + return button + + # ---- 打包 ---- + + def _build_pack_page(self): + page = self._form("打包文件夹 → 单个 .svfs", "同一个文件出现两次时只会存一份(内容寻址)") + self.pack_source = self._line("要打包的文件夹") + self._row( + page, + "源目录", + self._with_browse(self.pack_source, self._pick_pack_source), + ) + self.pack_target = self._line("输出 .svfs 路径") + self._row( + page, + "输出文件", + self._with_browse(self.pack_target, self._pick_pack_target), + "指向一个已存在的包 + 勾选「增量打包」= 往那个包里加文件(空包就是这么填起来的)", + ) + self.pack_name = self._line("packed_filesystem") + self.pack_name.setText("packed_filesystem") + self._row(page, "包名", self.pack_name) + compress_row = QWidget() + compress_layout = hbox(compress_row, 0, 6) + self.pack_compress = QCheckBox("压缩内容") + self.pack_compress.setChecked(True) + self.pack_algo = QComboBox() + self.pack_algo.addItem("zlib(推荐)", "zlib") + self.pack_algo.addItem("huffman(旧算法,别用)", "huffman") + compress_layout.addWidget(self.pack_compress) + compress_layout.addWidget(self.pack_algo) + compress_layout.addStretch(1) + self._row( + page, + "压缩", + compress_row, + "zlib 是标准库 C 实现:压缩比更好、快几个数量级;huffman 只为读旧包保留", + ) + self.pack_exclude = self._line("*.tmp, *.log, node_modules") + self._row(page, "排除", self.pack_exclude, "glob 语法,逗号分隔;作用于相对路径与文件名") + incremental_row = QWidget() + incremental_layout = hbox(incremental_row, 0, 6) + self.pack_incremental = QCheckBox("增量打包(保留历史)") + self.pack_rehash = QCheckBox("逐文件重算哈希") + incremental_layout.addWidget(self.pack_incremental) + incremental_layout.addWidget(self.pack_rehash) + incremental_layout.addStretch(1) + self._row( + page, + "增量", + incremental_row, + "增量默认按 mtime+size 判断变化(快,但可能漏掉同一秒内的同大小改动);" + "勾上「逐文件重算哈希」更可靠但要把文件都读一遍", + ) + self.pack_snapshot = self._line("可选,给这一版起个名字") + self._row(page, "快照名", self.pack_snapshot, "只有增量打包才支持在新快照里累加") + self._buttons(page, "开始打包", self._run_pack) + return page + + # ---- 解包 ---- + + def _build_unpack_page(self): + page = self._form("解包到目录", "导出当前快照(或指定子路径),并恢复时间戳与权限位") + self.unpack_output = self._line("导出到哪个目录") + self._row(page, "目标目录", self._with_browse(self.unpack_output, self._pick_unpack_output)) + self.unpack_path = self._line("留空 = 导出整个文件系统") + self._row(page, "子路径", self.unpack_path, "也可以从「文件」页右键单个条目导出") + self.unpack_ignore_time = QCheckBox("忽略时间戳(不恢复 mtime)") + self._row(page, "选项", self.unpack_ignore_time) + self._buttons(page, "开始解包", self._run_unpack) + return page + + # ---- 迁移 ---- + + def _build_migrate_page(self): + page = self._form( + "迁移旧格式(v1 / v2 → v3)", + "库自带的 vfsmigrate 同一份代码:deldr 重建 + sha256 去重 + 原子替换", + ) + self.migrate_source = self._line("旧格式的 .svfs") + self._row(page, "源文件", self._with_browse(self.migrate_source, self._pick_migrate_source)) + self.migrate_detect = QLabel("还没检测。点右边的按钮看看它是哪个版本。") + theme.role(self.migrate_detect, "muted") + self.migrate_detect.setWordWrap(True) + detect_button = QPushButton("检测版本") + detect_button.clicked.connect(self._detect_version) + detect_row = QWidget() + detect_layout = hbox(detect_row, 0, 6) + detect_layout.addWidget(self.migrate_detect, 1) + detect_layout.addWidget(detect_button) + self._row(page, "版本", detect_row) + self.migrate_output = self._line("留空 = 原地迁移(先写临时文件再原子替换)") + self._row(page, "输出文件", self._with_browse(self.migrate_output, self._pick_migrate_output)) + self.migrate_backup = QCheckBox("迁移前留一份 .bak 备份") + self.migrate_backup.setChecked(True) + self.migrate_name = self._line("可选,新库的包名") + self._row(page, "选项", self.migrate_backup) + self._row(page, "包名", self.migrate_name) + self._buttons(page, "开始迁移", self._run_migrate) + return page + + # ---- 回收 / 重算 ---- + + def _build_maintain_page(self): + page = self._form("回收与统计", "包名可以在这里改;回收与重算都会重算快照摘要") + self.name_edit = self._line("整包的名字(filesystem_metadata.name)") + self.rename_button = QPushButton("改名") + self.rename_button.clicked.connect(self._run_rename) + name_row = QWidget() + name_layout = hbox(name_row, 0, 6) + name_layout.addWidget(self.name_edit, 1) + name_layout.addWidget(self.rename_button) + self._row( + page, + "包名", + name_row, + "只写 filesystem_metadata.name 一列;不会动条目、快照与内容", + ) + self.gc_info = InfoGrid(columns=1) + self._row(page, "未引用内容", self.gc_info) + self.gc_button = QPushButton("回收未引用的内容") + self.gc_button.setObjectName("Primary") + self.gc_button.clicked.connect(self._run_gc) + self._row( + page, + "回收", + self.gc_button, + "gc_blobs:删掉没有任何快照引用的内容。引用计数是隐式的——删路径不等于删内容," + "只有引用归零才会被回收", + ) + self.recount_button = QPushButton("重算统计与摘要") + self.recount_button.clicked.connect(self._run_recount) + self._row( + page, + "重算", + self.recount_button, + "recount:按库内实际内容重算 total_files / total_size 并刷新摘要。" + "摘要一变,之前对旧摘要的签名就失效了", + ) + self._buttons(page, "刷新未引用统计", lambda: self.window.refresh()) + return page + + # ---- 发布 ---- + + def _build_publish_page(self): + page = self._form( + "发布成对象存储布局", + "public/blobs// + manifest.json:对象名就是内容哈希", + ) + self.publish_dir = self._line("输出目录,例如 public/") + self._row(page, "输出目录", self._with_browse(self.publish_dir, self._pick_publish_dir)) + self.publish_snapshot = QComboBox() + self.publish_snapshot.addItem("最新快照", None) + self._row(page, "快照", self.publish_snapshot) + self.publish_shard = QSpinBox() + self.publish_shard.setRange(0, 4) + self.publish_shard.setValue(2) + self._row(page, "分片位数", self.publish_shard, "0 = 不分片;2 = blobs/ab/abcdef…") + options = QWidget() + options_layout = hbox(options, 0, 6) + self.publish_overwrite = QCheckBox("覆盖已存在的对象") + self.publish_objects = QCheckBox("同时导出对象") + self.publish_objects.setChecked(True) + self.publish_objects.setToolTip("不勾就只写 manifest.json(对象已经在存储上了)") + options_layout.addWidget(self.publish_objects) + options_layout.addWidget(self.publish_overwrite) + options_layout.addStretch(1) + self._row(page, "选项", options) + self._buttons(page, "开始发布", self._run_publish) + return page + + # ---- 拉取 ---- + + def _build_fetch_page(self): + page = self._form( + "从清单拉取", "对象名 = sha256,所以每个下载到的对象都能立刻验证" + ) + self.fetch_manifest = self._line("manifest.json 路径或 http(s) URL") + self._row( + page, + "清单", + self._with_browse(self.fetch_manifest, self._pick_fetch_manifest), + ) + self.fetch_output = self._line("拉到哪个目录") + self._row(page, "目标目录", self._with_browse(self.fetch_output, self._pick_fetch_output)) + self.fetch_workers = QSpinBox() + self.fetch_workers.setRange(1, 32) + self.fetch_workers.setValue(8) + self._row(page, "并发数", self.fetch_workers) + options = QWidget() + options_layout = hbox(options, 0, 6) + self.fetch_verify = QCheckBox("校验对象哈希") + self.fetch_verify.setChecked(True) + self.fetch_link = QCheckBox("硬链接到缓存(省磁盘)") + options_layout.addWidget(self.fetch_verify) + options_layout.addWidget(self.fetch_link) + options_layout.addStretch(1) + self._row( + page, + "选项", + options, + "硬链接会把文件指到缓存里那份共享内容上:省空间,但不要再去改它的时间戳/权限", + ) + self._buttons(page, "开始拉取", self._run_fetch) + return page + + # ---- 本地对比 ---- + + def _build_compare_page(self): + page = self._form( + "与本地目录对比", + "逐文件比 sha256:用来验证「打包 → 解包」这条链路真的无损", + ) + self.compare_dir = self._line("本地文件夹") + self._row(page, "本地目录", self._with_browse(self.compare_dir, self._pick_compare_dir)) + self.compare_info = QLabel( + "对比的是当前打开的那个包、当前快照。" + "内容一致但 mtime/权限不同会单独归到「仅元数据不同」。" + ) + theme.role(self.compare_info, "faint") + self.compare_info.setWordWrap(True) + self._row(page, "对比对象", self.compare_info) + self.compare_tree = QTreeWidget() + self.compare_tree.setColumnCount(3) + self.compare_tree.setHeaderLabels(["路径", "本地", "包内"]) + self.compare_tree.setAlternatingRowColors(True) + self.compare_tree.itemDoubleClicked.connect( + lambda item, _col: self._open_compare_item(item) + ) + self.compare_tree.setMinimumHeight(150) + page.layout_ref.addWidget(self.compare_tree) + self._buttons(page, "开始对比", self._run_compare) + return page + + # ------------------------------------------------------------------ + # 交互 + # ------------------------------------------------------------------ + + def _on_op_changed(self, row): + self.stack.setCurrentIndex(max(row, 0)) + self._refresh_forms() + + def _on_busy(self, busy, op): + """只有"支持真中断"的任务才点亮中断按钮,不做假的取消。""" + self.cancel_button.setEnabled(bool(busy) and self._cancellable) + if not busy: + self._cancellable = False + + def _cancel(self): + self.window.client.cancel() + self.window.notify("已请求中断,会在下一个进度点生效") + + def _start(self, op, payload, on_done, label, cancellable=False): + self._cancellable = cancellable + self.result_badge.set_state("info", f"{label} 进行中…") + self.result_info.clear() + self.result_table.setVisible(False) + self.result_note.setVisible(False) + self.copy_button.setVisible(False) + self.window.run(op, payload, on_done=self._wrap_done(on_done, label), label=label) + + def _wrap_done(self, on_done, label): + def handler(payload): + self._cancellable = False + on_done(payload) + + return handler + + # ------------------------------------------------------------------ + # 打包 + # ------------------------------------------------------------------ + + def _pick_pack_source(self): + path = QFileDialog.getExistingDirectory(self, "选择要打包的文件夹", self.pack_source.text()) + if path: + self.pack_source.setText(path) + if not self.pack_target.text(): + self.pack_target.setText(os.path.join(os.path.dirname(path), os.path.basename(path) + ".svfs")) + + def _pick_pack_target(self): + path, _filter = QFileDialog.getSaveFileName( + self, "输出到", self.pack_target.text() or "packed.svfs", "SVFS 包 (*.svfs)" + ) + if path: + self.pack_target.setText(path) + + def quick_pack(self, source, target): + """顶栏/空状态页点「打包文件夹…」时落到这一页。""" + self.op_list.setCurrentRow(0) + self.pack_source.setText(source) + self.pack_target.setText(target) + self.window.goto_tab("ops") + self._run_pack() + + def focus_pack_into(self, target): + """把文件打进一个已经存在的包(空包建好之后的下一步)。 + + 这里必须勾上「增量打包」:全量打包第一步是清空整库,会把刚建好的空包 + (以及里面的历史)一起清掉。 + + 只预填表单,**不弹目录选择框**——用户是带着"去看看怎么打包"的预期点过来的, + 迎面砸一个模态对话框会把人困住;聚焦在源目录输入框上,旁边的「选择」按钮就在手边。 + """ + self.op_list.setCurrentRow(0) + self.pack_target.setText(target) + self.pack_incremental.setChecked(True) + self.pack_snapshot.setPlaceholderText("可选,给这一版起个名字(例如 first-drop)") + self.window.goto_tab("ops") + if not self.pack_source.text().strip(): + self.pack_source.setFocus() + else: + self.pack_snapshot.setFocus() + + def _run_pack(self): + source = self.pack_source.text().strip() + target = self.pack_target.text().strip() + if not source or not target: + self.result_badge.set_state("err", "请先填源目录与输出文件") + return + if os.path.isdir(target): + self.result_badge.set_state("err", "输出路径是个目录,请给一个文件名") + return + if os.path.exists(target) and not os.path.exists(source): + self.result_badge.set_state("err", "源目录不存在") + return + incremental = self.pack_incremental.isChecked() + if os.path.exists(target) and not incremental: + answer = QMessageBox.question( + self, + "覆盖现有的包?", + f"{target} 已经存在。\n\n" + "全量打包的第一步是清空整库(所有快照、所有内容),然后重新写入。\n" + "想保留历史请改用「增量打包」。\n\n确定继续吗?", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + patterns = [ + item.strip() + for item in self.pack_exclude.text().replace(",", ",").split(",") + if item.strip() + ] + self._start( + "pack", + { + "source": source, + "target": target, + "name": self.pack_name.text().strip() or "packed_filesystem", + "compress": self.pack_compress.isChecked(), + "algorithm": self.pack_algo.currentData(), + "exclude": patterns, + "incremental": incremental, + "rehash": self.pack_rehash.isChecked(), + "snapshot_name": self.pack_snapshot.text().strip() or None, + }, + on_done=self._on_pack_done, + label="打包", + cancellable=True, + ) + + def _on_pack_done(self, payload): + stats = payload.get("stats") or {} + ok = payload.get("ok") + self.result_badge.set_state("ok" if ok else "warn", "打包完成" if ok else "部分条目写入失败") + self.result_info.clear() + self.result_info.add("源目录", payload.get("source"), mono=True) + self.result_info.add("输出文件", payload.get("target"), mono=True) + self.result_info.add("库文件大小", human_size(payload.get("target_size"))) + self.result_info.add("文件数", human_count(payload.get("total_files"))) + self.result_info.add("逻辑大小", human_size(payload.get("total_size"))) + if payload.get("incremental"): + self.result_info.add( + "增量结果", + f"新增 {stats.get('added', 0)} / 更新 {stats.get('updated', 0)} / " + f"未变 {stats.get('unchanged', 0)} / 删除 {stats.get('removed', 0)}", + ) + if payload.get("excluded"): + self.result_info.add("被排除", f"{payload['excluded']} 个文件") + self.result_info.add("耗时", f"{(payload.get('elapsed') or 0):.2f} 秒") + self.result_title.set_subtitle( + f"{human_size(payload.get('total_size'))} 的目录打成了 " + f"{human_size(payload.get('target_size'))}(压缩 + 去重之后)" + ) + self.result_note.setVisible(True) + self.result_note.setText("要不要直接打开这个包看看?") + self.copy_button.setVisible(True) + self._copy_text = payload.get("target") or "" + self.copy_button.setText("复制路径") + self.window.open_package(payload.get("target")) + + # ------------------------------------------------------------------ + # 解包 + # ------------------------------------------------------------------ + + def _pick_unpack_output(self): + path = QFileDialog.getExistingDirectory(self, "导出到", self.unpack_output.text()) + if path: + self.unpack_output.setText(path) + + def _run_unpack(self): + output = self.unpack_output.text().strip() + if not output: + self.result_badge.set_state("err", "请先填目标目录") + return + self._start( + "unpack", + { + "output": output, + "vfs_path": self.unpack_path.text().strip() or None, + "ignore_timestamps": self.unpack_ignore_time.isChecked(), + }, + on_done=self._on_unpack_done, + label="解包", + ) + + def _on_unpack_done(self, payload): + count = payload.get("count") or 0 + if not count: + self.result_badge.set_state( + "warn", "导出 0 个条目 —— 库可能有问题,具体原因在内核日志里" + ) + else: + self.result_badge.set_state("ok", f"已导出 {count} 个文件/目录") + self.result_info.clear() + self.result_info.add("目标目录", payload.get("output"), mono=True) + self.result_info.add("来源", payload.get("vfs_path") or "/(整个快照)") + self.result_info.add("耗时", f"{(payload.get('elapsed') or 0):.2f} 秒") + self.result_note.setVisible(True) + self.result_note.setText( + "想确认导出是否无损?把原目录填到「与本地目录对比」里,逐文件比一遍 sha256。" + ) + + # ------------------------------------------------------------------ + # 迁移 + # ------------------------------------------------------------------ + + def _pick_migrate_source(self): + path, _filter = QFileDialog.getOpenFileName( + self, "选择要迁移的旧格式包", self.migrate_source.text(), "SVFS 包 (*.svfs *.db)" + ) + if path: + self.migrate_source.setText(path) + self._detect_version() + + def _pick_migrate_output(self): + path, _filter = QFileDialog.getSaveFileName( + self, "输出到", self.migrate_output.text() or "migrated.svfs", "SVFS 包 (*.svfs)" + ) + if path: + self.migrate_output.setText(path) + + def focus_migrate(self, path): + """从旧格式横幅点「去迁移」时落到这一页。""" + self.op_list.setCurrentRow(2) + self.migrate_source.setText(path) + self._detect_version() + + def _detect_version(self): + path = self.migrate_source.text().strip() + if not path: + self.migrate_detect.setText("先选一个文件。") + return + self.window.run( + "detect_version", {"path": path}, on_done=self._on_version, label="检测版本" + ) + + def _on_version(self, payload): + version = payload.get("version") + if version is None: + self.migrate_detect.setText( + "这个文件不是本工具的库(没有 files 表),迁移没有意义。" + ) + elif version == 3: + self.migrate_detect.setText("已经是 schema v3,不需要迁移。") + else: + self.migrate_detect.setText( + f"检测到 schema v{version}:迁移会按 sha256 重建内容寻址存储," + "顺带把重复内容合并成一份。" + ) + + def _run_migrate(self): + path = self.migrate_source.text().strip() + if not path: + self.result_badge.set_state("err", "请先选源文件") + return + if not self.migrate_output.text().strip() and self.migrate_backup.isChecked(): + pass # 原地迁移 + 备份是默认的安全组合 + elif not self.migrate_output.text().strip(): + answer = QMessageBox.question( + self, + "不备份就原地迁移?", + "你关掉了备份、也没有指定输出文件,迁移会直接替换原文件。\n" + "如果中途失败,原来的库就没了。确定继续吗?", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + self._start( + "migrate", + { + "path": path, + "backup": self.migrate_backup.isChecked(), + "output": self.migrate_output.text().strip() or None, + "snapshot_name": self.migrate_name.text().strip() or None, + }, + on_done=self._on_migrate_done, + label="迁移", + cancellable=True, + ) + + def _on_migrate_done(self, payload): + self.result_badge.set_state("ok", "迁移完成") + self.result_info.clear() + self.result_info.add( + "版本", + f"schema v{payload.get('from_version')} → v{payload.get('to_version')}", + ) + self.result_info.add("新文件", payload.get("opened"), mono=True) + self.result_info.add( + "迁移方式", "原地替换" if payload.get("in_place") else "写到新文件" + ) + if payload.get("backup"): + self.result_info.add("备份", payload["backup"], mono=True, copyable=True) + self.result_info.add("迁移条目", human_count(payload.get("files"))) + self.result_info.add("唯一内容", f"{human_count(payload.get('blobs'))} 份") + if payload.get("dedup_saved"): + self.result_info.add("去重省下", human_size(payload.get("dedup_saved"))) + self.result_info.add("耗时", f"{(payload.get('elapsed') or 0):.2f} 秒") + self.result_note.setVisible(True) + self.result_note.setText("已自动打开迁移后的包;原来的库如果保留了备份,就在上面那行路径。") + self.window.open_package(payload.get("opened")) + + # ------------------------------------------------------------------ + # 回收 / 重算 + # ------------------------------------------------------------------ + + def focus_gc(self): + self.op_list.setCurrentRow(3) + self.window.goto_tab("ops") + + def _run_rename(self): + name = self.name_edit.text().strip() + if not name: + self.result_badge.set_state("err", "包名不能为空") + return + self._start( + "rename_package", {"name": name}, on_done=self._on_rename_done, label="改名" + ) + + def _on_rename_done(self, payload): + self.result_badge.set_state("ok", f"包名已改为「{payload['stats']['name']}」") + self.result_info.clear() + self.result_info.add("包名", payload["stats"]["name"]) + self.result_info.add("路径", payload["path"], mono=True) + self.window.apply_payload(payload) + + def _run_gc(self): + orphans = ((self.payload or {}).get("orphans")) or {} + answer = QMessageBox.question( + self, + "回收未引用的内容", + f"将删除 {orphans.get('count', 0)} 份没有任何快照引用的内容" + f"(库内占用 {human_size(orphans.get('stored_size'))})。\n\n" + "它不会影响任何现存快照——被引用的内容一份都不会动。\n" + "删除后库文件不会立刻变小(空闲页要 VACUUM 才会归还),但内容确实没了。\n\n确定继续吗?", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + self._start("gc", {}, on_done=self._on_gc_done, label="回收") + + def _on_gc_done(self, payload): + before = payload.get("before") or {} + after = payload.get("after") or {} + removed = payload.get("removed") or 0 + self.result_badge.set_state("ok" if removed else "muted", f"回收了 {removed} 份内容") + self.result_info.clear() + self.result_info.add("回收前未引用", f"{before.get('count', 0)} 份 / {human_size(before.get('stored_size'))}") + self.result_info.add("回收后未引用", f"{after.get('count', 0)} 份 / {human_size(after.get('stored_size'))}") + self.result_info.add("耗时", f"{(payload.get('elapsed') or 0):.2f} 秒") + self.window.refresh() + + def _run_recount(self): + answer = QMessageBox.question( + self, + "重算统计与摘要", + "会按库内实际内容重算文件数与总大小,并刷新快照摘要。\n\n" + "注意:摘要一变,之前针对旧摘要做的签名就验不过了(这是设计如此)。\n\n确定继续吗?", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + self._start("recount", {}, on_done=self._on_recount_done, label="重算") + + def _on_recount_done(self, payload): + recount = payload.get("recount") or {} + self.result_badge.set_state("ok", "统计已更新") + self.result_info.clear() + self.result_info.add("文件数", human_count(recount.get("files"))) + self.result_info.add("总大小", human_size(recount.get("size"))) + self.window.apply_payload(payload) + + # ------------------------------------------------------------------ + # 发布 + # ------------------------------------------------------------------ + + def _pick_publish_dir(self): + path = QFileDialog.getExistingDirectory(self, "发布到", self.publish_dir.text()) + if path: + self.publish_dir.setText(path) + + def _run_publish(self): + out_dir = self.publish_dir.text().strip() + if not out_dir: + self.result_badge.set_state("err", "请先填输出目录") + return + self._start( + "publish", + { + "out_dir": out_dir, + "snapshot_id": self.publish_snapshot.currentData(), + "shard": self.publish_shard.value(), + "overwrite": self.publish_overwrite.isChecked(), + "objects": self.publish_objects.isChecked(), + }, + on_done=self._on_publish_done, + label="发布", + cancellable=True, + ) + + def _on_publish_done(self, payload): + self.result_badge.set_state("ok", f"已导出 {payload.get('objects', 0)} 个对象") + self.result_info.clear() + self.result_info.add("输出目录", payload.get("out_dir"), mono=True) + self.result_info.add("清单", payload.get("manifest"), mono=True) + self.result_info.add("清单大小", human_size(payload.get("manifest_bytes"))) + self.result_info.add("对象", f"{payload.get('objects', 0)} 个 / {human_size(payload.get('objects_bytes'))}") + self.result_info.add("快照", str(payload.get("snapshot_id"))) + self.result_info.add("摘要", short_hash(payload.get("digest"), 16), mono=True) + entries = payload.get("entries") or [] + rows = [] + for item in entries[:500]: + rows.append( + [ + TableItem(item["path"], mono=bool(item.get("hash"))), + TableItem(short_hash(item.get("hash"), 12) if item.get("hash") else "—", mono=True), + TableItem(human_size(item.get("size")), align_right=True), + TableItem("目录" if item.get("dir") else (item.get("mime") or "")), + ] + ) + self.result_table.set_rows(rows) + self.result_table.setVisible(bool(rows)) + self.result_title.set_subtitle( + f"清单里有 {len(entries)} 个条目;对象名就是内容哈希,上传后可以直接长缓存" + ) + self.copy_button.setVisible(True) + self.copy_button.setText("复制上传命令") + self._copy_text = ( + f'aws s3 sync "{payload.get("out_dir")}" s3://你的桶/ ' + f'--cache-control "public, max-age=31536000, immutable"' + ) + self.result_note.setVisible(True) + self.result_note.setText( + "manifest.json 是公开信息(路径、大小、哈希都在里面),别往里放敏感路径。" + ) + + # ------------------------------------------------------------------ + # 拉取 + # ------------------------------------------------------------------ + + def _pick_fetch_manifest(self): + path, _filter = QFileDialog.getOpenFileName( + self, "选择 manifest.json", self.fetch_manifest.text(), "清单 (*.json)" + ) + if path: + self.fetch_manifest.setText(path) + self.window.run( + "manifest_entries", + {"manifest": path}, + on_done=self._on_manifest_preview, + on_fail=lambda title, detail: self.result_note.setText(f"{title}:{detail}"), + label="读取清单", + ) + + def _on_manifest_preview(self, payload): + manifest = payload.get("manifest") or {} + snapshot = manifest.get("snapshot") or {} + stats = manifest.get("stats") or {} + self.result_badge.set_state("info", "清单已读取") + self.result_info.clear() + self.result_info.add("格式", f"{manifest.get('format')} v{manifest.get('version')}") + self.result_info.add("包名", manifest.get("name") or "-") + self.result_info.add("快照", f"{snapshot.get('name')}(id={snapshot.get('id')})") + self.result_info.add("条目", f"{len(payload.get('entries') or [])} 个") + self.result_info.add("文件", human_count(stats.get("files"))) + self.result_info.add("总大小", human_size(stats.get("total_size"))) + + def _pick_fetch_output(self): + path = QFileDialog.getExistingDirectory(self, "拉取到", self.fetch_output.text()) + if path: + self.fetch_output.setText(path) + + def _run_fetch(self): + manifest = self.fetch_manifest.text().strip() + output = self.fetch_output.text().strip() + if not manifest or not output: + self.result_badge.set_state("err", "清单与目标目录都要填") + return + self._start( + "fetch", + { + "manifest": manifest, + "out_dir": output, + "workers": self.fetch_workers.value(), + "verify": self.fetch_verify.isChecked(), + "link": self.fetch_link.isChecked(), + }, + on_done=self._on_fetch_done, + label="拉取", + cancellable=True, + ) + + def _on_fetch_done(self, payload): + self.result_badge.set_state("ok", f"取回 {payload.get('files', 0)} 个文件") + self.result_info.clear() + for key, label, formatter in ( + ("files", "文件", human_count), + ("directories", "目录", human_count), + ("objects_downloaded", "下载对象", human_count), + ("objects_from_cache", "缓存命中", human_count), + ("bytes_downloaded", "下载字节", human_size), + ("verified", "校验通过", human_count), + ("skipped", "跳过", human_count), + ): + if payload.get(key) is not None: + self.result_info.add(label, formatter(payload.get(key))) + self.result_info.add("目标目录", payload.get("out_dir"), mono=True) + self.result_info.add("耗时", f"{(payload.get('elapsed') or 0):.2f} 秒") + self.result_note.setVisible(True) + self.result_note.setText( + "缓存目录里的对象是跨项目复用的——同一份内容在别的包里下载过就不会再下一次。" + ) + + # ------------------------------------------------------------------ + # 本地对比 + # ------------------------------------------------------------------ + + def _pick_compare_dir(self): + path = QFileDialog.getExistingDirectory(self, "选择本地目录", self.compare_dir.text()) + if path: + self.compare_dir.setText(path) + + def _run_compare(self): + local_root = self.compare_dir.text().strip() + if not local_root: + self.result_badge.set_state("err", "请先选本地目录") + return + if not self.payload: + self.result_badge.set_state("err", "先打开一个包,才有东西可比") + return + self._start( + "compare_local", + {"local_root": local_root}, + on_done=self._on_compare_done, + label="对比", + cancellable=True, + ) + + def _on_compare_done(self, payload): + result = payload.get("result") or {} + counts = result.get("counts") or {} + self.compare_tree.clear() + for kind in analysis.LOCAL_KINDS: + items = result.get(kind) or [] + group = QTreeWidgetItem( + [f"{analysis.LOCAL_LABELS[kind]}({len(items)})", "", ""] + ) + group.setForeground(0, theme.qcolor(theme.LOCAL_COLORS.get(kind, theme.TEXT_MUTED))) + font = group.font(0) + font.setBold(True) + group.setFont(0, font) + group.setExpanded(kind in ("content", "only_local", "only_pack") and bool(items)) + self.compare_tree.addTopLevelItem(group) + for item in items[:500]: + local = item.get("local") or {} + pack = item.get("pack") or {} + child = QTreeWidgetItem( + [ + item["path"], + human_size(local.get("size")) if local else "不存在", + human_size(pack.get("file_size")) if pack else "不存在", + ] + ) + child.setData(0, Qt.ItemDataRole.UserRole, item["path"]) + group.addChild(child) + if len(items) > 500: + group.addChild(QTreeWidgetItem([f"…另外 {len(items) - 500} 项", "", ""])) + + ok = result.get("ok") + self.result_badge.set_state( + "ok" if ok else "warn", + "完全一致" if ok else f"{sum(counts.values())} 处差异", + ) + self.result_info.clear() + for kind in analysis.LOCAL_KINDS: + self.result_info.add( + analysis.LOCAL_LABELS[kind], f"{counts.get(kind, 0)} 个" + ) + self.result_info.add("本地文件", human_count(result.get("total_local"))) + self.result_info.add("包内文件", human_count(result.get("total_pack"))) + self.result_info.add("耗时", f"{(payload.get('elapsed') or 0):.2f} 秒") + self.result_note.setVisible(True) + self.result_note.setText( + "「仅元数据不同」是内容一致但 mtime 或权限位不一样——" + "解包时没恢复时间戳、或者从别的文件系统拷过来都会这样。双击一行可以跳到包内那个文件。" + ) + + def _open_compare_item(self, item, _column): + path = item.data(0, Qt.ItemDataRole.UserRole) + if path: + self.window.show_path(path) + + # ------------------------------------------------------------------ + + def _copy_result(self): + if self._copy_text: + QApplication.clipboard().setText(self._copy_text) + self.window.notify("已复制到剪贴板") + + # ------------------------------------------------------------------ + + def update_view(self, payload): + self._refresh_forms() + + def on_empty(self, payload): + self._refresh_forms() + + def _refresh_forms(self): + """表单里跟"当前包"有关的部分(快照下拉、未引用统计)。""" + payload = self.payload or {} + snapshots = payload.get("snapshots") or [] + current = self.publish_snapshot.currentData() + self.publish_snapshot.blockSignals(True) + self.publish_snapshot.clear() + self.publish_snapshot.addItem("最新快照", None) + for snapshot in snapshots: + self.publish_snapshot.addItem( + f"{snapshot['id']} · {snapshot.get('name')}", snapshot["id"] + ) + index = self.publish_snapshot.findData(current) + self.publish_snapshot.setCurrentIndex(max(index, 0)) + self.publish_snapshot.blockSignals(False) + + orphans = payload.get("orphans") or {} + self.gc_info.clear() + self.gc_info.add( + "份数", + f"{orphans.get('count', 0)} 份", + color=theme.WARN if orphans.get("count") else theme.OK, + ) + self.gc_info.add("原始字节", human_size(orphans.get("size"))) + self.gc_info.add("库内占用", human_size(orphans.get("stored_size"))) + self.gc_button.setEnabled(bool(orphans.get("count"))) + self.recount_button.setEnabled(bool(payload)) + + stats = payload.get("stats") or {} + # 正在输入时不要用库里的旧值盖掉用户的输入(刷新可能在任意时刻发生) + if not self.name_edit.hasFocus(): + self.name_edit.setText(stats.get("name") or "") + self.name_edit.setPlaceholderText( + "整包的名字(filesystem_metadata.name)" if payload else "先打开一个包" + ) + self.rename_button.setEnabled(bool(payload)) + + if payload.get("path") and not self.migrate_source.text().strip(): + self.migrate_source.setText(payload["path"]) + + +def build_ops(window): + return OpsView(window) diff --git a/src/svfs_studio/views/overview.py b/src/svfs_studio/views/overview.py new file mode 100644 index 0000000..f2e189e --- /dev/null +++ b/src/svfs_studio/views/overview.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""概览页:一张图回答"这个包里有什么、空间去哪了"。""" + +from PySide6.QtWidgets import QWidget + +from .. import theme +from ..format import human_size, human_time, percent_text, ratio_text +from ..widgets.cards import Card, InfoGrid, SectionTitle, StatCard, hbox, vbox +from ..widgets.charts import DonutChart, MeterRow, SavingsBar +from .base import BaseView + + +class OverviewView(BaseView): + PLACEHOLDER_TITLE = "还没有打开包" + PLACEHOLDER_DETAIL = "打开一个包之后,这里会显示它的规模、空间去向与内容分布。" + + def build_content(self): + page = QWidget() + layout = vbox(page, 0, 8) + + # ---- 四张数字卡 ---- + cards = hbox(spacing=8) + self.card_files = StatCard("文件", accent=theme.ACCENT) + self.card_logical = StatCard("逻辑大小", accent=theme.TEXT_FAINT) + self.card_unique = StatCard("唯一内容", accent=theme.VIOLET) + self.card_stored = StatCard("库文件", accent=theme.OK) + for card in (self.card_files, self.card_logical, self.card_unique, self.card_stored): + cards.addWidget(card, 1) + layout.addLayout(cards) + + # ---- 空间去哪了 ---- + space_card = Card(padding=theme.PAD, spacing=6) + self.space_title = SectionTitle( + "空间去哪了", + "逻辑大小被切成「库内实际占用 + 压缩省下 + 去重省下」,三段相加等于逻辑大小", + ) + space_card.add(self.space_title) + self.savings = SavingsBar() + space_card.add(self.savings) + self.space_note = hbox(spacing=18) + space_card.add_layout(self.space_note) + layout.addWidget(space_card) + + # ---- 左:算法分布;右:元信息 ---- + columns = hbox(spacing=10) + + algo_card = Card(padding=theme.PAD, spacing=6) + algo_card.add(SectionTitle("内容是怎么存的", "按库内实际占用分组的压缩算法")) + self.donut = DonutChart() + algo_card.add(self.donut, 1) + self.meter_compress = MeterRow(label="压缩率(库内 / 唯一内容)", color=theme.WARN) + self.meter_dedup = MeterRow(label="去重率(省下的部分 / 逻辑大小)", color=theme.VIOLET) + algo_card.add(self.meter_compress) + algo_card.add(self.meter_dedup) + columns.addWidget(algo_card, 3) + + info_card = Card(padding=theme.PAD, spacing=6) + info_card.add(SectionTitle("包信息", "元数据来自 filesystem_metadata 与 snapshots 表")) + self.info = InfoGrid(columns=2) + info_card.add(self.info) + columns.addWidget(info_card, 2) + layout.addLayout(columns, 1) + + return page + + def on_empty(self, payload): + """关包、换包失败时把上一次的数字清干净,别让旧数据留在界面上。""" + self.info.clear() + for card in (self.card_files, self.card_logical, self.card_unique, self.card_stored): + card.set_value("—", "") + self.savings.set_segments([]) + self.donut.set_segments([], "", "") + self.meter_compress.set_value(0, "-") + self.meter_dedup.set_value(0, "-") + _clear_layout(self.space_note) + self.space_title.set_subtitle("还没有打开包") + + def update_view(self, payload): + stats = payload.get("stats") or {} + summary = payload.get("summary") or {} + snapshot_stats = payload.get("snapshot_stats") or {} + + logical = summary.get("logical_size") or 0 + unique = summary.get("unique_bytes") or 0 + stored = summary.get("stored_bytes") or 0 + db_size = summary.get("db_file_size") or 0 + dedup_saved = summary.get("dedup_saved") or 0 + compression_saved = summary.get("compression_saved") or 0 + + self.card_files.set_value( + f"{summary.get('total_files', 0)}", + f"{summary.get('total_dirs', 0)} 个目录 · {summary.get('total_entries', 0)} 个条目", + ) + self.card_logical.set_value(human_size(logical), "所有文件大小之和") + self.card_unique.set_value( + f"{summary.get('blob_count', 0)} 份", + f"{human_size(unique)} · 去重省下 {human_size(dedup_saved)}", + color=theme.VIOLET if dedup_saved else None, + ) + self.card_stored.set_value( + human_size(db_size), + f"库内内容 {human_size(stored)} · 页开销 {human_size(summary.get('db_overhead'))}", + ) + + self.savings.set_segments( + [ + ("库内实际占用", stored, theme.STAGE_COLORS[0]), + ("压缩省下", compression_saved, theme.STAGE_COLORS[2]), + ("去重省下", dedup_saved, theme.VIOLET), + ] + ) + self.space_title.set_subtitle( + f"逻辑 {human_size(logical)} → 去重后 {human_size(unique)} → " + f"库内 {human_size(stored)}(合计省下 {percent_text(dedup_saved + compression_saved, logical)})" + ) + _clear_layout(self.space_note) + for label, value in ( + ("逻辑总大小", logical), + ("去重后唯一内容", unique), + ("库内内容字节", stored), + ("库文件大小", db_size), + ("页开销/空闲页", summary.get("db_overhead")), + ): + self.space_note.addWidget(self._metric(label, human_size(value))) + self.space_note.addStretch(1) + + algo = payload.get("algo") or [] + labels = { + "zlib": "zlib(推荐)", + "huffman": "huffman(旧算法)", + "none": "未压缩", + } + self.donut.set_segments( + [ + (labels.get(item["algo"], item["algo"]), item["stored_size"], theme.algo_color(item["algo"])) + for item in algo + ], + center_title=human_size(stored), + center_sub="库内内容", + ) + compression_ratio = stored / unique if unique else 0 + self.meter_compress.set_value( + 1 - compression_ratio if unique else 0, + f"{ratio_text(stored, unique)}(越小越省)" if unique else "-", + ) + self.meter_dedup.set_value( + (dedup_saved / logical) if logical else 0, + f"{percent_text(dedup_saved, logical)} · {human_size(dedup_saved)}", + ) + + self._fill_info(payload, stats, summary, snapshot_stats) + + def _metric(self, label, value): + box = vbox(spacing=0) + from PySide6.QtWidgets import QLabel + + title = QLabel(label) + theme.role(title, "faint") + number = QLabel(value) + number.setStyleSheet("font-weight: 600;") + box.addWidget(title) + box.addWidget(number) + holder = QWidget() + holder.setLayout(box) + return holder + + def _fill_info(self, payload, stats, summary, snapshot_stats): + self.info.clear() + snapshots = payload.get("snapshots") or [] + signatures = payload.get("signatures") or [] + orphans = payload.get("orphans") or {} + digest = stats.get("digest") + + digests = [item for item in snapshots if item.get("digest")] + self.info.add("包名", stats.get("name") or "-") + self.info.add("路径", payload.get("path") or "-", mono=True, copyable=True) + self.info.add("schema 版本", f"v{stats.get('schema_version') or 3}") + self.info.add("创建时间", human_time(stats.get("created_time"))) + self.info.add( + "当前快照", + f"{payload.get('snapshot_name') or '-'}(id={payload.get('snapshot_id')})", + ) + self.info.add("快照数量", f"{len(snapshots)}({len(digests)} 个已计算摘要)") + self.info.add( + "当前摘要", + digest or "(未计算,可在完整性页计算)", + mono=True, + copyable=bool(digest), + tooltip="按 path 排序后对 (路径/类型/大小/修改时间/权限/内容哈希) 求 sha256", + ) + self.info.add( + "签名", + "无" if not signatures else f"{len(signatures)} 条", + color=theme.OK if signatures else None, + ) + self.info.add( + "未引用的内容", + "无" + if not orphans.get("count") + else f"{orphans['count']} 份 / {human_size(orphans.get('stored_size'))}" + "(可回收)", + color=theme.WARN if orphans.get("count") else None, + ) + shared = snapshot_stats.get("shared_blobs") or 0 + if shared: + self.info.add( + "跨快照共享", + f"{shared} 份 / {human_size(snapshot_stats.get('shared_bytes'))}(未重复存储)", + color=theme.ACCENT, + ) + if not snapshots: + self.info.add("提示", "库里还没有快照,写入时会自动建一个", color=theme.WARN) + + +def _clear_layout(layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.hide() + widget.deleteLater() + + +def build_overview(window): + return OverviewView(window) diff --git a/src/svfs_studio/views/snapshots.py b/src/svfs_studio/views/snapshots.py new file mode 100644 index 0000000..fb53d19 --- /dev/null +++ b/src/svfs_studio/views/snapshots.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +"""快照页:时间线 + 任意两版之间的差异。 + +差异是按 ``all_entries()`` 的元数据比的(path / 类型 / 大小 / 修改时间 / 权限 / 内容哈希), +所以很便宜——不用读内容。这也带来一个必须说清楚的边界:**它比的是库里记录的元数据**, +"同大小不同内容"要靠内容哈希才能发现,而同哈希不同字节是不可能的(sha256 保证)。 +""" + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QComboBox, + QLabel, + QMessageBox, + QPushButton, + QSplitter, + QTreeWidget, + QTreeWidgetItem, + QWidget, +) + +from .. import analysis, theme +from ..format import human_size, human_time, short_hash +from ..widgets.cards import Badge, SectionTitle, hbox, vbox +from .base import BaseView + +#: 差异分组在树里的显示顺序与配色 +GROUP_ORDER = ("added", "removed", "content", "type", "meta") + + +class SnapshotView(BaseView): + PLACEHOLDER_TITLE = "还没有打开包" + PLACEHOLDER_DETAIL = "打开一个包后,这里会列出它的版本历史,并能比较任意两版的差异。" + + def build_content(self): + # 这是唯一一个"两栏都塞进一个可滚动页"会显得挤的页面,所以用上下分栏 + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(self._build_list_side()) + splitter.addWidget(self._build_diff_side()) + splitter.setStretchFactor(0, 2) + splitter.setStretchFactor(1, 3) + splitter.setSizes([300, 420]) + self._entries_cache = {} + self._diff = None + return splitter + + # ------------------------------------------------------------------ + + def _build_list_side(self): + panel = QWidget() + layout = vbox(panel, 0, 6) + + header = hbox(spacing=6) + title = SectionTitle("版本历史", "双击一行把它设为当前快照") + header.addWidget(title, 1) + self.create_button = QPushButton("新建快照") + self.create_button.setObjectName("Primary") + self.create_button.clicked.connect(self._create_snapshot) + self.switch_button = QPushButton("切换为当前") + self.switch_button.clicked.connect(self._switch_to_selected) + self.delete_button = QPushButton("删除") + self.delete_button.setObjectName("Danger") + self.delete_button.clicked.connect(self._delete_selected) + self.clear_button = QPushButton("清空内容") + self.clear_button.setObjectName("Danger") + self.clear_button.setToolTip("保留快照本身,但删掉它引用的全部条目") + self.clear_button.clicked.connect(self._clear_selected) + for button in (self.create_button, self.switch_button, self.delete_button, self.clear_button): + header.addWidget(button) + layout.addLayout(header) + + self.tree = QTreeWidget() + self.tree.setColumnCount(7) + self.tree.setHeaderLabels( + ["id", "名称", "父", "创建时间", "文件数", "大小", "摘要 / 签名"] + ) + self.tree.setRootIsDecorated(False) + self.tree.setAlternatingRowColors(True) + self.tree.setUniformRowHeights(True) + self.tree.itemDoubleClicked.connect(lambda item, _col: self._switch_to(item)) + self.tree.itemSelectionChanged.connect(self._on_snapshot_selected) + header_view = self.tree.header() + header_view.resizeSection(0, 40) + header_view.resizeSection(1, 150) + header_view.resizeSection(2, 50) + header_view.resizeSection(3, 140) + header_view.resizeSection(4, 80) + header_view.resizeSection(5, 90) + layout.addWidget(self.tree, 1) + return panel + + def _build_diff_side(self): + panel = QWidget() + layout = vbox(panel, 0, 6) + + controls = hbox(spacing=6) + controls.addWidget(QLabel("比较")) + self.combo_a = QComboBox() + self.combo_b = QComboBox() + for combo in (self.combo_a, self.combo_b): + combo.setMinimumWidth(150) + self.combo_a.currentIndexChanged.connect(lambda: self._schedule_diff()) + self.combo_b.currentIndexChanged.connect(lambda: self._schedule_diff()) + controls.addWidget(self.combo_a) + controls.addWidget(QLabel("→")) + controls.addWidget(self.combo_b) + self.swap_button = QPushButton("交换") + self.swap_button.setObjectName("Ghost") + self.swap_button.clicked.connect(self._swap) + controls.addWidget(self.swap_button) + controls.addStretch(1) + self.diff_badge = Badge("未比较", "muted") + controls.addWidget(self.diff_badge) + layout.addLayout(controls) + + badges = hbox(spacing=6) + self.group_badges = {} + for kind in GROUP_ORDER: + badge = Badge(f"{analysis.DIFF_LABELS[kind]} 0", "muted") + self.group_badges[kind] = badge + badges.addWidget(badge) + self.unchanged_badge = Badge("未变 0", "muted") + badges.addWidget(self.unchanged_badge) + badges.addStretch(1) + layout.addLayout(badges) + + self.diff_tree = QTreeWidget() + self.diff_tree.setColumnCount(4) + self.diff_tree.setHeaderLabels(["路径 / 变化", "旧", "新", "差值"]) + self.diff_tree.setAlternatingRowColors(True) + self.diff_tree.itemDoubleClicked.connect(self._open_in_browser) + header_view = self.diff_tree.header() + header_view.resizeSection(0, 420) + header_view.resizeSection(1, 130) + header_view.resizeSection(2, 130) + header_view.resizeSection(3, 110) + layout.addWidget(self.diff_tree, 1) + + self.diff_note = QLabel("") + theme.role(self.diff_note, "faint") + self.diff_note.setWordWrap(True) + layout.addWidget(self.diff_note) + return panel + + # ------------------------------------------------------------------ + + def update_view(self, payload): + self._entries_cache = {payload.get("snapshot_id"): payload.get("entries") or []} + snapshots = payload.get("snapshots") or [] + active = payload.get("snapshot_id") + + self.tree.clear() + for snapshot in snapshots: + item = QTreeWidgetItem( + [ + str(snapshot["id"]), + snapshot.get("name") or "-", + str(snapshot.get("parent_id") or "-"), + human_time(snapshot.get("created_time")), + str(snapshot.get("total_files") or 0), + human_size(snapshot.get("total_size")), + self._digest_text(snapshot, payload), + ] + ) + item.setData(0, Qt.ItemDataRole.UserRole, snapshot["id"]) + item.setToolTip(6, snapshot.get("digest") or "(未计算摘要)") + if snapshot["id"] == active: + font = item.font(1) + font.setBold(True) + item.setFont(1, font) + item.setText(1, f"{snapshot.get('name')} ← 当前") + self.tree.addTopLevelItem(item) + + self._fill_combos(snapshots, active) + self.create_button.setEnabled(True) + self._update_buttons() + self._schedule_diff() + + def on_empty(self, payload): + self.tree.clear() + self.combo_a.clear() + self.combo_b.clear() + self.diff_tree.clear() + self.diff_note.setText("") + + def _digest_text(self, snapshot, payload): + digest = snapshot.get("digest") + signatures = [ + item for item in (payload.get("signatures") or []) + if item.get("snapshot_id") == snapshot["id"] + ] + if not digest: + return "无摘要" + text = short_hash(digest, 12) + if signatures: + text += f" ✓{len(signatures)} 个签名" + return text + + def _fill_combos(self, snapshots, active): + for combo, fallback in ((self.combo_a, -2), (self.combo_b, -1)): + combo.blockSignals(True) + combo.clear() + for snapshot in snapshots: + combo.addItem( + f"{snapshot['id']} · {snapshot.get('name')}", snapshot["id"] + ) + if snapshots: + index = max(len(snapshots) + fallback, 0) + combo.setCurrentIndex(index) + combo.blockSignals(False) + + def _update_buttons(self): + item = self.tree.currentItem() + snapshots = (self.payload or {}).get("snapshots") or [] + has_selection = item is not None + self.switch_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection and len(snapshots) > 1) + self.clear_button.setEnabled(has_selection) + + def _on_snapshot_selected(self): + self._update_buttons() + item = self.tree.currentItem() + if item is not None: + # 选中一行就把它设成比较的"旧版",新版保持最新——这是最常用的比较方向 + snapshot_id = item.data(0, Qt.ItemDataRole.UserRole) + index = self.combo_a.findData(snapshot_id) + if index >= 0 and self.combo_a.currentIndex() != index: + self.combo_a.blockSignals(True) + self.combo_a.setCurrentIndex(index) + self.combo_a.blockSignals(False) + self._schedule_diff() + + def _swap(self): + index_a = self.combo_a.currentIndex() + index_b = self.combo_b.currentIndex() + self.combo_a.blockSignals(True) + self.combo_b.blockSignals(True) + self.combo_a.setCurrentIndex(index_b) + self.combo_b.setCurrentIndex(index_a) + self.combo_a.blockSignals(False) + self.combo_b.blockSignals(False) + self._schedule_diff() + + # ------------------------------------------------------------------ + + def _schedule_diff(self): + id_a = self.combo_a.currentData() + id_b = self.combo_b.currentData() + if id_a is None or id_b is None: + return + if id_a == id_b: + self.diff_tree.clear() + self.diff_note.setText("选了两个相同的快照,没有差异可比。") + for kind in GROUP_ORDER: + self.group_badges[kind].set_state("muted", f"{analysis.DIFF_LABELS[kind]} 0") + self.unchanged_badge.set_state("muted", "未变 0") + return + + missing = [sid for sid in (id_a, id_b) if sid not in self._entries_cache] + if missing: + # 只缓存当前快照的条目,另一版要单独取一次(短查询,不占"忙"状态) + self.window.run( + "entries", + {"snapshot_id": missing[0]}, + on_done=lambda payload: self._on_entries(payload), + on_fail=lambda title, detail: self.diff_note.setText(f"{title}:{detail}"), + ) + return + self._render_diff(id_a, id_b) + + def _on_entries(self, payload): + self._entries_cache[payload["snapshot_id"]] = payload.get("entries") or [] + self._schedule_diff() + + def _render_diff(self, id_a, id_b): + diff = analysis.snapshot_diff( + self._entries_cache.get(id_a) or [], self._entries_cache.get(id_b) or [] + ) + self._diff = diff + names = {combo.currentData(): combo.currentText() for combo in (self.combo_a, self.combo_b)} + + for kind in GROUP_ORDER: + count = diff["counts"][kind] + state = { + "added": "ok", + "removed": "err", + "content": "warn", + "type": "info", + "meta": "info", + }[kind] + self.group_badges[kind].set_state( + state if count else "muted", f"{analysis.DIFF_LABELS[kind]} {count}" + ) + self.unchanged_badge.set_state("muted", f"未变 {diff['unchanged']}") + + total = diff["total_changed"] + self.diff_badge.set_state( + "warn" if total else "ok", + f"{total} 处变化" if total else "完全一致", + ) + + self.diff_tree.clear() + for kind in GROUP_ORDER: + items = diff[kind] + if not items: + continue + group = QTreeWidgetItem([f"{analysis.DIFF_LABELS[kind]}({len(items)})", "", "", ""]) + group.setForeground(0, theme.qcolor(_group_color(kind))) + font = group.font(0) + font.setBold(True) + group.setFont(0, font) + group.setExpanded(kind in ("added", "removed", "content")) + self.diff_tree.addTopLevelItem(group) + for entry in items[:500]: + group.addChild(self._diff_item(kind, entry)) + if len(items) > 500: + more = QTreeWidgetItem([f"…另外 {len(items) - 500} 项", "", "", ""]) + more.setForeground(0, theme.qcolor(theme.TEXT_FAINT)) + group.addChild(more) + + added_bytes = sum((item["new"] or {}).get("file_size") or 0 for item in diff["added"]) + added_bytes += sum( + max(item.get("size_delta") or 0, 0) for item in diff["content"] + ) + removed_bytes = sum((item["old"] or {}).get("file_size") or 0 for item in diff["removed"]) + removed_bytes += sum( + abs(min(item.get("size_delta") or 0, 0)) for item in diff["content"] + ) + self.diff_note.setText( + f"{names.get(id_a)} → {names.get(id_b)}:" + f"新增/变大 {human_size(added_bytes)},删除/变小 {human_size(removed_bytes)}," + f"其余 {diff['unchanged']} 项元数据完全一致。" + f"(比较的是库里记录的元数据,不读内容;双击一行可以跳到文件页。)" + ) + + def _diff_item(self, kind, entry): + old = entry.get("old") or {} + new = entry.get("new") or {} + path_item = QTreeWidgetItem( + [ + entry["path"], + _describe(old), + _describe(new), + _delta_text(entry.get("size_delta")), + ] + ) + path_item.setData(0, Qt.ItemDataRole.UserRole, entry["path"]) + if kind == "content": + path_item.setToolTip( + 0, + f"旧哈希 {short_hash(old.get('content_hash'), 12)} → " + f"新哈希 {short_hash(new.get('content_hash'), 12)}", + ) + path_item.setForeground(3, theme.qcolor(theme.WARN)) + elif kind == "removed": + path_item.setForeground(3, theme.qcolor(theme.ERR)) + elif kind == "added": + path_item.setForeground(3, theme.qcolor(theme.OK)) + return path_item + + def _open_in_browser(self, item, _column): + path = item.data(0, Qt.ItemDataRole.UserRole) + if path: + self.window.show_path(path) + + # ------------------------------------------------------------------ + + def _create_snapshot(self): + self.window._create_snapshot() + + def _switch_to_selected(self): + item = self.tree.currentItem() + if item is not None: + self._switch_to(item) + + def _switch_to(self, item): + snapshot_id = item.data(0, Qt.ItemDataRole.UserRole) + self.window.select_snapshot(snapshot_id) + + def _delete_selected(self): + item = self.tree.currentItem() + if item is None: + return + snapshot_id = item.data(0, Qt.ItemDataRole.UserRole) + name = item.text(1) + total = self.tree.topLevelItemCount() + detail = "" + if total <= 1: + detail = "\n这是最后一个快照,删除后库里会没有任何版本。" + answer = QMessageBox.question( + self, + "删除快照", + f"确定删除快照 {snapshot_id}「{name}」?\n\n" + "它引用的条目会一并删除;如果某份内容删完后再也没有任何快照引用," + "那份内容也会被回收(gc_blobs)。" + f"{detail}", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + self.window.run( + "delete_snapshot", + {"snapshot_id": snapshot_id, "gc": True}, + on_done=self._on_mutation, + label=f"删除快照 {snapshot_id}", + ) + + def _clear_selected(self): + item = self.tree.currentItem() + if item is None: + return + snapshot_id = item.data(0, Qt.ItemDataRole.UserRole) + answer = QMessageBox.question( + self, + "清空快照内容", + f"确定清空快照 {snapshot_id} 的全部条目?\n" + "快照本身会保留(名字、创建时间、父快照都在),只是不再指向任何文件。", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + self.window.run( + "clear_snapshot", + {"snapshot_id": snapshot_id}, + on_done=self._on_mutation, + label=f"清空快照 {snapshot_id}", + ) + + def _on_mutation(self, payload): + self._entries_cache = {} + self.window.apply_payload(payload) + + +def _describe(row): + if not row: + return "" + if row.get("is_directory"): + return "目录" + return f"{human_size(row.get('file_size'))} · {short_hash(row.get('content_hash'), 8)}" + + +def _delta_text(delta): + if delta is None: + return "" + if delta == 0: + return "0" + sign = "+" if delta > 0 else "-" + return f"{sign}{human_size(abs(delta))}" + + +def _group_color(kind): + return theme.DIFF_COLORS.get(kind, theme.TEXT_MUTED) + + +def build_snapshots(window): + return SnapshotView(window) diff --git a/src/svfs_studio/views/storage.py b/src/svfs_studio/views/storage.py new file mode 100644 index 0000000..5852c0b --- /dev/null +++ b/src/svfs_studio/views/storage.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""存储页:内容寻址的去重与压缩到底省了多少、省在哪里。 + +这一页是"内容寻址存储"最直白的体现:左边是库内所有 blob,右边是"哪些内容被多个路径 +共用"和"最大的那些文件",顶部给出四个口径的总账。 +""" + +from PySide6.QtWidgets import ( + QComboBox, + QLabel, + QLineEdit, + QPushButton, + QSplitter, + QWidget, +) + +from .. import theme +from ..format import human_size, percent_text, ratio_text, short_hash +from ..widgets.cards import ( + Badge, + Card, + SectionTitle, + StatCard, + TableItem, + Grid, + hbox, + vbox, +) +from ..widgets.charts import BarList +from .base import BaseView + +#: 表格一次最多铺多少行(几万个 blob 全铺出来只会卡界面) +ROW_LIMIT = 2000 + + +class StorageView(BaseView): + PLACEHOLDER_TITLE = "还没有打开包" + PLACEHOLDER_DETAIL = "打开一个包后,这里会展示内容去重、压缩与 Block 级别的占用。" + + def build_content(self): + page = QWidget() + layout = vbox(page, 0, 8) + + cards = hbox(spacing=10) + self.card_unique = StatCard("唯一内容") + self.card_stored = StatCard("库内占用") + self.card_dedup = StatCard("去重省下") + self.card_compress = StatCard("压缩省下") + for card in (self.card_unique, self.card_stored, self.card_dedup, self.card_compress): + cards.addWidget(card, 1) + layout.addLayout(cards) + + splitter = QSplitter() + + # ---- 左:blob 表 ---- + left = QWidget() + left_layout = vbox(left, 0, 6) + header = hbox(spacing=6) + header.addWidget(SectionTitle("内容块(blob)", "按 sha256 寻址,相同内容只存一份")) + header.addStretch(1) + self.filter_box = QLineEdit() + self.filter_box.setPlaceholderText("过滤哈希前缀") + self.filter_box.setClearButtonEnabled(True) + self.filter_box.setMaximumWidth(180) + self.filter_box.textChanged.connect(lambda _text: self._fill_table()) + self.algo_box = QComboBox() + self.algo_box.addItem("全部算法", "all") + self.algo_box.currentIndexChanged.connect(lambda _index: self._fill_table()) + header.addWidget(self.filter_box) + header.addWidget(self.algo_box) + left_layout.addLayout(header) + + self.table = Grid(["哈希", "原始", "库内", "比率", "算法", "引用"]) + self.table.table.itemSelectionChanged.connect(self._on_blob_selected) + self.table.table.setMouseTracking(True) + left_layout.addWidget(self.table, 1) + self.table_note = QLabel("") + theme.role(self.table_note, "faint") + left_layout.addWidget(self.table_note) + splitter.addWidget(left) + + # ---- 右:重复组 + Top-N ---- + right = QWidget() + right_layout = vbox(right, 0, 6) + duplicate_card = Card(padding=theme.PAD, spacing=6) + self.duplicate_title = SectionTitle("同内容的多个路径", "") + duplicate_card.add(self.duplicate_title) + self.duplicate_chart = BarList(row_height=theme.ROW_HEIGHT) + self.duplicate_chart.clicked.connect(self._jump_to_blob) + duplicate_card.add(self.duplicate_chart) + right_layout.addWidget(duplicate_card, 1) + + top_card = Card(padding=theme.PAD, spacing=6) + self.top_title = SectionTitle("最大的文件", "") + top_card.add(self.top_title) + self.top_chart = BarList(row_height=theme.ROW_HEIGHT) + self.top_chart.clicked.connect(self.window.show_path) + top_card.add(self.top_chart) + right_layout.addWidget(top_card, 1) + splitter.addWidget(right) + splitter.setSizes([640, 460]) + layout.addWidget(splitter, 1) + + self.orphan_row = hbox(spacing=6) + self.orphan_badge = Badge("未引用 0", "muted") + self.orphan_note = QLabel("") + theme.role(self.orphan_note, "muted") + self.gc_button = QPushButton("回收未引用的内容") + self.gc_button.setObjectName("Primary") + self.gc_button.clicked.connect(self._goto_gc) + self.orphan_row.addWidget(self.orphan_badge) + self.orphan_row.addWidget(self.orphan_note, 1) + self.orphan_row.addWidget(self.gc_button) + layout.addLayout(self.orphan_row) + return page + + # ------------------------------------------------------------------ + + def update_view(self, payload): + summary = payload.get("summary") or {} + blobs = payload.get("blobs") or [] + refcounts = payload.get("refcounts") or {} + + self._blobs = blobs + self._refcounts = refcounts + self._blobs_by_hash = {row["hash"]: row for row in blobs} + + unique = summary.get("unique_bytes") or 0 + stored = summary.get("stored_bytes") or 0 + logical = summary.get("logical_size") or 0 + self.card_unique.set_value( + human_size(unique), f"{summary.get('blob_count', 0)} 份内容" + ) + self.card_stored.set_value( + human_size(stored), + f"对唯一内容压缩到 {ratio_text(stored, unique)}", + ) + self.card_dedup.set_value( + human_size(summary.get("dedup_saved")), + f"占逻辑大小 {percent_text(summary.get('dedup_saved'), logical)}", + color=theme.VIOLET if summary.get("dedup_saved") else None, + ) + self.card_compress.set_value( + human_size(summary.get("compression_saved")), + f"对唯一内容省下 {percent_text(summary.get('compression_saved'), unique)}", + color=theme.WARN if summary.get("compression_saved") else None, + ) + + algorithms = sorted({row["compress_algo"] or "none" for row in blobs}) + current = self.algo_box.currentData() + self.algo_box.blockSignals(True) + self.algo_box.clear() + self.algo_box.addItem("全部算法", "all") + for algo in algorithms: + self.algo_box.addItem(algo, algo) + index = self.algo_box.findData(current) + self.algo_box.setCurrentIndex(max(index, 0)) + self.algo_box.blockSignals(False) + self._fill_table() + + duplicates = payload.get("duplicates") or [] + self.duplicate_title.set_subtitle( + f"{len(duplicates)} 组(同一份内容被多个路径共用,点击跳到第一个路径)" + if duplicates + else "没有重复内容:每个文件的字节都只存了一份" + ) + self.duplicate_chart.set_rows( + [ + ( + item["paths"][0], + f"{short_hash(item['hash'], 8)} × {item['refcount']}", + item["wasted_bytes"], + f"省 {human_size(item['wasted_bytes'])}", + theme.VIOLET, + ) + for item in duplicates[:12] + ] + ) + + top = payload.get("top") or [] + self.top_title.set_subtitle("点击跳到文件页看详情") + self.top_chart.set_rows( + [ + ( + item["path"], + item["path"], + item["size"], + f"{human_size(item['size'])} · {item['algo']}", + theme.algo_color(item["algo"]), + ) + for item in top + ] + ) + + orphans = payload.get("orphans") or {} + count = orphans.get("count") or 0 + self.orphan_badge.set_state("warn" if count else "ok", f"未引用 {count} 份") + self.orphan_note.setText( + f"{human_size(orphans.get('stored_size'))} 的库内占用没有任何快照在引用" + "(比如删过文件、或旧版本被删掉之后)——回收不会影响任何现存快照。" + if count + else "没有未引用的内容,库里没有可回收的垃圾。" + ) + self.gc_button.setEnabled(bool(count)) + + def _fill_table(self): + blobs = getattr(self, "_blobs", None) or [] + needle = (self.filter_box.text() or "").strip().lower() + algo = self.algo_box.currentData() or "all" + rows = [ + row + for row in blobs + if (algo == "all" or (row["compress_algo"] or "none") == algo) + and (not needle or row["hash"].startswith(needle)) + ] + rows.sort(key=lambda row: row.get("stored_size") or 0, reverse=True) + shown = rows[:ROW_LIMIT] + + refcounts = getattr(self, "_refcounts", None) or {} + table_rows = [] + for row in shown: + ratio = (row.get("stored_size") or 0) / (row.get("size") or 1) + table_rows.append( + [ + TableItem( + short_hash(row["hash"], 14), + sort_value=row["hash"], + mono=True, + tooltip=row["hash"], + data=row["hash"], + ), + TableItem(human_size(row.get("size")), sort_value=row.get("size") or 0, + align_right=True), + TableItem(human_size(row.get("stored_size")), + sort_value=row.get("stored_size") or 0, align_right=True), + TableItem( + ratio_text(row.get("stored_size"), row.get("size")), + sort_value=ratio, + align_right=True, + color=theme.WARN if ratio >= 0.9 else theme.OK, + ), + TableItem( + row.get("compress_algo") or "none", + color=theme.algo_color(row.get("compress_algo")), + ), + TableItem( + refcounts.get(row["hash"], 0), + sort_value=refcounts.get(row["hash"], 0), + align_right=True, + color=theme.ACCENT if refcounts.get(row["hash"], 0) > 1 else None, + ), + ] + ) + self.table.set_rows(table_rows, sort_column=2, descending=True) + note = f"显示 {len(shown)} / {len(rows)} 份" + if len(rows) > ROW_LIMIT: + note += f"(超过 {ROW_LIMIT} 份时只铺前 {ROW_LIMIT} 份,用上面的框筛一下)" + self.table_note.setText(note) + + def _on_blob_selected(self): + content_hash = self.table.selected_row_data(0) + if not content_hash: + return + # 选中一行 blob 就把它对应的第一个路径选出来(最直接的"这是什么文件") + for path, count in self._paths_of(content_hash)[:1]: + self.window.notify( + f"{short_hash(content_hash, 12)} 被 {count} 个路径引用,第一个是 {path}" + ) + + def _paths_of(self, content_hash): + entries = (self.payload or {}).get("entries") or [] + paths = [row["path"] for row in entries if row.get("content_hash") == content_hash] + return [(path, len(paths)) for path in paths] + + def _jump_to_blob(self, key): + """重复组里点一行:跳到这一组内容对应的第一个路径。""" + self.window.show_path(key) + + def _goto_gc(self): + self.window.goto_tab("ops") + ops = self.window._views.get("ops") + if ops is not None: + ops.focus_gc() + + def on_empty(self, payload): + self.table.clear() + self.table_note.setText("") + self.duplicate_chart.set_rows([]) + self.top_chart.set_rows([]) + + +def build_storage(window): + return StorageView(window) diff --git a/src/svfs_studio/widgets/cards.py b/src/svfs_studio/widgets/cards.py new file mode 100644 index 0000000..d536887 --- /dev/null +++ b/src/svfs_studio/widgets/cards.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +"""基础部件:卡片、徽章、横幅、信息栅格、空状态。 + +界面里反复出现的这几块抽出来,视图代码就只剩下"填数据"这一件事。 +""" + +from PySide6.QtCore import QEvent, Qt, QTimer, Signal +from PySide6.QtWidgets import ( + QApplication, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from .. import theme + +#: 状态 → (前景, 背景, 边框) +STATES = { + "ok": (theme.OK, "#16281f", "#2b5f45"), + "warn": (theme.WARN, "#2a2418", "#6b552a"), + "err": (theme.ERR, "#2b1a1c", "#6b3235"), + "info": (theme.INFO, "#16262b", "#2b5b66"), + "muted": (theme.TEXT_MUTED, theme.ELEVATED, theme.BORDER_STRONG), + "accent": (theme.ACCENT, "#1b2739", "#33507d"), +} + + +def vbox(widget=None, margin=0, spacing=8): + layout = QVBoxLayout(widget) if widget is not None else QVBoxLayout() + layout.setContentsMargins(margin, margin, margin, margin) + layout.setSpacing(spacing) + return layout + + +def hbox(widget=None, margin=0, spacing=8): + layout = QHBoxLayout(widget) if widget is not None else QHBoxLayout() + layout.setContentsMargins(margin, margin, margin, margin) + layout.setSpacing(spacing) + return layout + + +class Card(QFrame): + """带描边和圆角的面板。""" + + def __init__(self, parent=None, padding=theme.PAD, spacing=8): + super().__init__(parent) + self.setObjectName("Card") + self.body = vbox(self, padding, spacing) + + def add(self, widget, stretch=0): + self.body.addWidget(widget, stretch) + return widget + + def add_layout(self, layout, stretch=0): + self.body.addLayout(layout, stretch) + return layout + + +class ElidedLabel(QLabel): + """宽度不够时自动加省略号的标签。 + + 界面里很多地方是"一个不定宽的文本塞进一个定宽的格子"(侧栏的值、小节副标题、 + 状态栏……)。QLabel 默认会把超出部分直接切掉——切在汉字中间什么都读不出来, + 所以这里统一改成按宽度省略,并把完整内容放进 tooltip。 + """ + + def __init__(self, text="", mode=Qt.TextElideMode.ElideRight, parent=None): + super().__init__("", parent) + self._mode = mode + self._full = "" + self.setToolTip("") + self.set_full_text(text) + + def set_full_text(self, text): + self._full = "" if text is None else str(text) + self.setToolTip(self._full) + self._relayout() + # 再排一次:布局稳定之前拿到的宽度可能还是旧的(或者干脆是 0), + # 那就省略得过头了。等这一轮事件跑完再按最终宽度算一次。 + QTimer.singleShot(0, self._relayout) + + def full_text(self): + return self._full + + def setText(self, text): # noqa: N802 - Qt 命名 + # 直接 setText 也走同一条路,避免有的地方写 setText 就把省略逻辑绕过去了 + self.set_full_text(text) + + def resizeEvent(self, event): # noqa: N802 + super().resizeEvent(event) + self._relayout() + + def changeEvent(self, event): # noqa: N802 + super().changeEvent(event) + if event.type() == QEvent.Type.FontChange: + self._relayout() + + def minimumSizeHint(self): # noqa: N802 + # QLabel 的 minimumSizeHint 默认是"完整文本的宽度",那会让布局根本不缩它, + # 于是我们永远等不到 resize,文本还是被切掉。这里把最小宽度放开到 0。 + from PySide6.QtCore import QSize + + height = super().minimumSizeHint().height() + return QSize(0, height) + + def sizeHint(self): # noqa: N802 + # 必须是"完整文本"的宽度,不能是当前(已省略)文本的宽度: + # 否则一旦被省略,sizeHint 也跟着变小,布局就再也不给它加宽——自己把自己锁死在窄宽度上。 + from PySide6.QtCore import QSize + from PySide6.QtGui import QFontMetrics + + metrics = QFontMetrics(self.font()) + width = metrics.horizontalAdvance(self._full) + return QSize(width + 2, super().sizeHint().height()) + + def _relayout(self): + from PySide6.QtGui import QFontMetrics + + metrics = QFontMetrics(self.font()) + available = max(self.width(), 0) + if not self._full: + super().setText("") + return + if available <= 0: + # 还没布局过:先按 sizeHint 显示原始文本,等 resize 时再省 + super().setText(self._full) + return + super().setText(metrics.elidedText(self._full, self._mode, available)) + + +class StatCard(Card): + """数字卡片:一道彩色短线 + 标题 / 大数字 / 一行说明。 + + 短线是唯一的装饰,颜色跟着数值的含义走(省下的是绿、需要注意的偏琥珀), + 扫一眼就知道每张卡在说什么,不必先读标题。 + """ + + def __init__(self, title, parent=None, accent=None): + super().__init__(parent, padding=theme.PAD, spacing=4) + self.setMinimumWidth(140) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + head = hbox(spacing=6) + self._accent = QFrame() + self._accent.setObjectName("CardAccent") + self._accent.setFixedSize(3, 12) + head.addWidget(self._accent) + self._title = QLabel(title) + theme.role(self._title, "cardTitle") + head.addWidget(self._title) + head.addStretch(1) + self.add_layout(head) + + self._value = QLabel("-") + theme.role(self._value, "cardValue") + self.add(self._value) + self._sub = ElidedLabel("") + theme.role(self._sub, "muted") + self.add(self._sub) + self.set_accent(accent or theme.TEXT_FAINT) + + def set_accent(self, color): + self._accent.setStyleSheet(f"#CardAccent {{ background: {color}; }}") + + def set_value(self, value, sub="", color=None): + self._value.setText(str(value)) + self._value.setStyleSheet(f"color: {color};" if color else "") + self._sub.set_full_text(sub) + + +class Badge(QLabel): + """小圆角标签,用颜色表达状态。""" + + def __init__(self, text="", state="muted", parent=None): + super().__init__(text, parent) + self.setObjectName("Badge") + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) + self.set_state(state, text) + + def set_state(self, state, text=None): + if text is not None: + self.setText(text) + foreground, background, border = STATES.get(state, STATES["muted"]) + self.setStyleSheet( + f"#Badge {{ color: {foreground}; background: {background};" + f" border: 1px solid {border}; border-radius: 9px; padding: 1px 8px;" + f" font-size: 11px; font-weight: 600; }}" + ) + + +class CopyLabel(ElidedLabel): + """点一下就复制的等宽标签(哈希、路径用)。 + + 继承 ElidedLabel:这类值往往很长(64 位哈希、深层路径),宽度不够时从中间省略—— + 首尾都看得见才知道是哪个;完整值始终在 tooltip 与剪贴板里。 + """ + + copied = Signal(str) + + def __init__(self, text="", parent=None): + super().__init__(text, mode=Qt.TextElideMode.ElideMiddle, parent=parent) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setToolTip("点击复制") + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.timeout.connect(self._restore) + self.setStyleSheet(f"color: {theme.TEXT_MUTED};") + + def mousePressEvent(self, event): # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton and self.full_text(): + QApplication.clipboard().setText(self.full_text()) + self.setStyleSheet(f"color: {theme.OK};") + self.setToolTip("已复制") + self._timer.start(900) + self.copied.emit(self.full_text()) + + def _restore(self): + self.setStyleSheet(f"color: {theme.TEXT_MUTED};") + self.setToolTip("点击复制") + + +class Banner(QFrame): + """整页级别的提示条:出错、旧格式提示、操作结果。带一个可选的动作按钮。""" + + action = Signal() + closed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("Banner") + self.setVisible(False) + layout = hbox(self, 12, 8) + mark = QLabel("●") + mark.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self._mark = mark + text_box = vbox(spacing=2) + self._title = QLabel("") + self._title.setStyleSheet("font-weight: 600;") + self._detail = QLabel("") + theme.role(self._detail, "muted") + self._detail.setWordWrap(True) + self._detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + text_box.addWidget(self._title) + text_box.addWidget(self._detail) + self._button = QPushButton("") + self._button.setObjectName("Primary") + self._button.setVisible(False) + self._button.clicked.connect(lambda: self.action.emit()) + layout.addWidget(mark) + layout.addLayout(text_box, 1) + layout.addWidget(self._button) + self._close = QPushButton("×") + self._close.setObjectName("Ghost") + self._close.setFixedWidth(28) + self._close.clicked.connect(self._close_now) + layout.addWidget(self._close) + + def _close_now(self): + self.hide() + self.closed.emit() + + def show_message(self, state, title, detail="", action_text=None): + foreground = STATES.get(state, STATES["muted"])[0] + self.setObjectName( + { + "warn": "BannerWarn", + "err": "BannerErr", + "ok": "BannerOk", + }.get(state, "Banner") + ) + theme.repolish(self) + self._mark.setStyleSheet(f"color: {foreground};") + self._title.setText(title) + self._detail.setText(detail) + self._detail.setVisible(bool(detail)) + self._button.setText(action_text or "") + self._button.setVisible(bool(action_text)) + self.setVisible(True) + + +class InfoGrid(Card): + """标签-值栅格:元信息、文件详情用。""" + + def __init__(self, columns=2, parent=None): + super().__init__(parent, padding=theme.PAD, spacing=6) + self._grid = QGridLayout() + self._grid.setHorizontalSpacing(14) + self._grid.setVerticalSpacing(6) + self._grid.setColumnStretch(1, 1) + self.add_layout(self._grid) + self._columns = columns + self._row = 0 + + def add(self, label, value, mono=False, color=None, copyable=False, tooltip=None): # type: ignore + column = self._columns + index = self._row + label_widget = QLabel(label) + theme.role(label_widget, "muted") + if copyable: + value_widget = CopyLabel(str(value) if value is not None else "-") + else: + value_widget = QLabel(str(value) if value is not None else "-") + value_widget.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + value_widget.setWordWrap(True) + if color: + value_widget.setStyleSheet(f"color: {color};") + if mono: + font = theme.font_mono(theme.SIZE_TINY) + value_widget.setFont(font) + if tooltip: + value_widget.setToolTip(tooltip) + self._grid.addWidget(label_widget, index // column, (index % column) * 2) + self._grid.addWidget(value_widget, index // column, (index % column) * 2 + 1) + self._row += 1 + return value_widget + + def clear(self): + while self._grid.count(): + item = self._grid.takeAt(0) + widget = item.widget() # type: ignore + if widget is not None: + widget.hide() # 先移出视线,再交给事件循环回收 + widget.deleteLater() + self._row = 0 + + +class SectionTitle(QWidget): + """小节标题:标题 + 一句说明 + 右侧可选控件。""" + + def __init__(self, title, subtitle="", right=None, parent=None): + super().__init__(parent) + layout = hbox(self, 0, 6) + self._title = QLabel(title) + theme.role(self._title, "h2") + layout.addWidget(self._title) + self._subtitle = ElidedLabel(subtitle, mode=Qt.TextElideMode.ElideRight) + theme.role(self._subtitle, "faint") + layout.addWidget(self._subtitle, 1) + self._right = right + if right is not None: + layout.addWidget(right) + + def set_subtitle(self, text): + self._subtitle.set_full_text(text) + + +class EmptyState(QWidget): + """空状态:一句话 + 说明 + 可选的按钮。 + + 内容装在一张虚线框的卡片里——整页留白中央放一块方正的提示,比一串浮在 + 半空的文字更像是"设计过的状态",而不是"没渲染出来"。 + """ + + def __init__(self, title, detail="", button_text=None, parent=None): + super().__init__(parent) + outer = vbox(self, 24, 8) + outer.addStretch(1) + + card = Card(padding=20, spacing=10) + card.setObjectName("EmptyCard") + card.setMaximumWidth(560) + theme.repolish(card) + + self._title = QLabel(title) + self._title.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._title.setWordWrap(True) + self._title.setStyleSheet( + f"color: {theme.TEXT}; font-size: {theme.SIZE_TITLE}px; font-weight: 600;" + ) + self._detail = QLabel(detail) + theme.role(self._detail, "muted") + self._detail.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._detail.setWordWrap(True) + card.add(self._title) + card.add(self._detail) + self._detail.setVisible(bool(detail)) + + self.button = None + if button_text: + row = hbox(spacing=6) + row.addStretch(1) + self.button = QPushButton(button_text) + self.button.setObjectName("Primary") + row.addWidget(self.button) + row.addStretch(1) + card.add_layout(row) + + center = hbox(spacing=0) + center.addStretch(1) + center.addWidget(card) + center.addStretch(1) + outer.addLayout(center) + outer.addStretch(2) + + def set_text(self, title=None, detail=None): + if title is not None: + self._title.setText(title) + if detail is not None: + self._detail.setText(detail) + self._detail.setVisible(bool(detail)) + + +def divider(): + line = QFrame() + line.setObjectName("Divider") + line.setFrameShape(QFrame.Shape.HLine) + line.setFixedHeight(1) + return line + + + + +class Grid(QWidget): + """带表头的通用表格:用 QTableWidget 会让排序/样式代码到处重复。""" + + def __init__(self, headers, parent=None): + super().__init__(parent) + from PySide6.QtWidgets import QHeaderView, QTableWidget + + layout = vbox(self, 0, 0) + self.table = QTableWidget(0, len(headers)) + self.table.setHorizontalHeaderLabels(headers) + self.table.verticalHeader().setVisible(False) + self.table.setAlternatingRowColors(True) + self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.table.setShowGrid(False) + self.table.setWordWrap(False) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Interactive + ) + self.table.verticalHeader().setDefaultSectionSize(theme.ROW_HEIGHT) + layout.addWidget(self.table) + + def clear(self): + self.table.setSortingEnabled(False) + self.table.setRowCount(0) + + def set_rows(self, rows, sort_column=None, descending=False): + self.table.setSortingEnabled(False) + self.table.setRowCount(len(rows)) + for row_index, row in enumerate(rows): + for column_index, value in enumerate(row): + item = value if isinstance(value, TableItem) else TableItem(value) + self.table.setItem(row_index, column_index, item) + if sort_column is not None: + self.table.sortItems( + sort_column, + Qt.SortOrder.DescendingOrder if descending else Qt.SortOrder.AscendingOrder, + ) + self.table.setSortingEnabled(True) + return self.table + + def selected_row_data(self, column=0): + rows = self.table.selectionModel().selectedRows() if self.table.selectionModel() else [] + if not rows: + return None + item = self.table.item(rows[0].row(), column) + return item.data(Qt.ItemDataRole.UserRole) if item is not None else None + + +class TableItem(QTableWidgetItem): + """表格单元:支持"显示文本"与"排序键"分离。 + + 地址/哈希这类字符串按文本排序没问题,但大小、时间、引用计数必须按数值排—— + 否则 "9 KB" 会排在 "10 KB" 后面。顺便支持等宽字体、颜色、右对齐与附带数据。 + """ + + def __init__(self, text="", sort_value=None, color=None, mono=False, tooltip=None, + align_right=False, data=None): + super().__init__("" if text is None else str(text)) + self._sort_value = text if sort_value is None else sort_value + if mono: + self.setFont(theme.font_mono(theme.SIZE_TINY)) + if color: + self.setForeground(theme.qcolor(color)) + if tooltip: + self.setToolTip(str(tooltip)) + if align_right: + self.setTextAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + if data is not None: + self.setData(Qt.ItemDataRole.UserRole, data) + + def __lt__(self, other): + mine = self._sort_value + theirs = getattr(other, "_sort_value", None) + if theirs is None: + return super().__lt__(other) + try: + return mine < theirs + except TypeError: + return str(mine) < str(theirs) diff --git a/src/svfs_studio/widgets/charts.py b/src/svfs_studio/widgets/charts.py new file mode 100644 index 0000000..6614b1a --- /dev/null +++ b/src/svfs_studio/widgets/charts.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""自绘图表:不引入 matplotlib/pyqtgraph,全部用 QPainter 画。 + +这样做的理由很实际:图表只有四种(占比条、环图、条形列表、堆叠段),第三方库为了 +这点需求要塞进来几十兆依赖,而暗色主题下的配色还得反过来迁就它们的默认样式。 +""" + +from PySide6.QtCore import QRectF, Qt, Signal +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen +from PySide6.QtWidgets import QSizePolicy, QWidget + +from .. import theme +from ..theme import ROW_HEIGHT + + +def elide(text, metrics, width): + """按像素宽度截断文本,中间加省略号。""" + return metrics.elidedText(str(text), Qt.TextElideMode.ElideMiddle, int(width)) + + +class ChartBase(QWidget): + """图表基类:统一留白、空数据占位文案、字体。""" + + def __init__(self, parent=None): + super().__init__(parent) + self._empty_text = "暂无数据" + self.setMinimumHeight(24) + + def set_empty_text(self, text): + self._empty_text = text + self.update() + + def has_data(self): + return True + + def paintEvent(self, event): # noqa: N802 - Qt 命名 + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setFont(theme.font_ui(theme.SIZE_SMALL)) + if not self.has_data(): + painter.setPen(QColor(theme.TEXT_FAINT)) + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._empty_text) + painter.end() + return + self.paint_chart(painter, QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)) + painter.end() + + def paint_chart(self, painter, rect): # pragma: no cover - 由子类实现 + raise NotImplementedError + + +class SavingsBar(ChartBase): + """「空间去哪了」:一条等于逻辑总大小的横条,切成 库内占用 / 压缩省下 / 去重省下。 + + 这三段是相加等于逻辑大小的(不像"逻辑→唯一→库内"那样层层相减),所以用堆叠条 + 是对的:一眼能看出省下来的部分占多大比例、分别是谁省的。 + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._segments = [] + self.setMinimumHeight(56) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def set_segments(self, segments): + """segments: [(文本, 数值, 颜色)],按显示顺序给。""" + self._segments = [(str(label), float(value or 0), color) for label, value, color in segments] + self.update() + + def has_data(self): # type: ignore + return sum(value for _label, value, _color in self._segments) > 0 + + def paint_chart(self, painter, rect): + total = sum(value for _label, value, _color in self._segments) + metrics = QFontMetrics(theme.font_ui(theme.SIZE_TINY)) + legend_height = 34 + bar_height = max(rect.height() - legend_height, 12) + bar = QRectF(rect.left(), rect.top(), rect.width(), bar_height) + + painter.setPen(Qt.PenStyle.NoPen) + # 先铺一层轨道色,避免总宽度很小时右半边空得像渲染失败 + painter.setBrush(QColor(theme.SUNKEN)) + painter.drawRoundedRect(bar, theme.RADIUS, theme.RADIUS) + + x = bar.left() + for index, (label, value, color) in enumerate(self._segments): + if value <= 0: + continue + width = bar.width() * (value / total) + # 最后一段补齐到右边界,避免累计浮点误差留下 1px 缝 + if index == len(self._segments) - 1: + width = bar.right() - x + segment = QRectF(x, bar.top(), max(width, 1.0), bar.height()) + painter.setBrush(QColor(color)) + painter.drawRect(segment) + x += width + + # 两端圆角:整条用一个圆角矩形裁剪,段与段之间就是直角,视觉上仍是一条整体 + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_DestinationIn) + painter.setBrush(QColor(0, 0, 0, 255)) + painter.drawRoundedRect(bar, theme.RADIUS, theme.RADIUS) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) + + # 图例 + legend_y = bar.bottom() + 8 + x = rect.left() + for label, value, color in self._segments: + text = f"{label} {_bytes(value)}" + width = metrics.horizontalAdvance(text) + 22 + if x + width > rect.right(): + break + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(color)) + painter.drawRoundedRect(QRectF(x, legend_y + 3, 10, 10), 2, 2) + painter.setPen(QColor(theme.TEXT_MUTED)) + painter.drawText( + QRectF(x + 16, legend_y - 2, width - 16, 20), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + text, + ) + x += width + + +class DonutChart(ChartBase): + """环图:按算法看库内占用(或数量)。中间显示总量或选中占比。""" + + def __init__(self, parent=None): + super().__init__(parent) + self._segments = [] + self._center_title = "" + self._center_sub = "" + self.setMinimumHeight(132) + self.setMinimumWidth(220) + + def set_segments(self, segments, center_title="", center_sub=""): + self._segments = [(str(label), float(value or 0), color) for label, value, color in segments] + self._center_title = center_title + self._center_sub = center_sub + self.update() + + def has_data(self): # type: ignore + return sum(value for _label, value, _color in self._segments) > 0 + + def paint_chart(self, painter, rect): + total = sum(value for _label, value, _color in self._segments) + size = min(rect.height(), rect.width() * 0.45) + size = max(size, 40) + donut = QRectF(rect.left(), rect.top() + (rect.height() - size) / 2, size, size) + thickness = max(size * 0.22, 8) + + start = 90 * 16 # 从 12 点方向开始,顺时针 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(theme.SUNKEN)) + painter.drawEllipse(donut.adjusted(thickness / 2, thickness / 2, -thickness / 2, -thickness / 2)) + + for _label, value, color in self._segments: + if value <= 0: + continue + span = -int(round(360 * 16 * (value / total))) + pen = QPen(QColor(color), thickness) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawArc( + donut.adjusted(thickness / 2, thickness / 2, -thickness / 2, -thickness / 2), + start, + span, + ) + start += span + + # 中间文字 + painter.setPen(QColor(theme.TEXT)) + painter.setFont(theme.font_ui(theme.SIZE_HEADING, QFont.Weight.DemiBold)) + painter.drawText(donut, Qt.AlignmentFlag.AlignCenter, self._center_title) + if self._center_sub: + inner = donut.adjusted(0, size * 0.26, 0, 0) + painter.setPen(QColor(theme.TEXT_FAINT)) + painter.setFont(theme.font_ui(theme.SIZE_MICRO)) + painter.drawText(inner, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, self._center_sub) + + # 右侧图例 + legend = QRectF(donut.right() + 16, rect.top() + 6, rect.right() - donut.right() - 16, rect.height() - 12) + metrics = QFontMetrics(theme.font_ui(theme.SIZE_TINY)) + y = legend.top() + for label, value, color in self._segments: + if y + 20 > legend.bottom(): + break + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(color)) + painter.drawRoundedRect(QRectF(legend.left(), y + 5, 9, 9), 2, 2) + painter.setPen(QColor(theme.TEXT_MUTED)) + percent = value / total * 100 if total else 0 + text = f"{label} · {percent:.0f}% · {_bytes(value)}" + painter.drawText( + QRectF(legend.left() + 15, y, legend.width() - 15, 18), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + elide(text, metrics, legend.width() - 15), + ) + y += 20 + + +class BarList(ChartBase): + """条形列表:Top-N 文件 / 重复内容组。点一下发出 clicked(key)。""" + + clicked = Signal(str) + + def __init__(self, parent=None, row_height=ROW_HEIGHT, show_ratio=False): + super().__init__(parent) + self._rows = [] + self._row_height = row_height + self._show_ratio = show_ratio + self._hover = -1 + self.setMouseTracking(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + def set_rows(self, rows): + """rows: [(key, label, value, text, color)]""" + self._rows = list(rows) + self._hover = -1 + self.updateGeometry() + self.update() + + def has_data(self): # type: ignore + return bool(self._rows) + + def sizeHint(self): # noqa: N802 + return super().sizeHint().expandedTo(self.minimumSizeHint()) + + def minimumSizeHint(self): # noqa: N802 + from PySide6.QtCore import QSize + + return QSize(160, max(len(self._rows), 1) * self._row_height + 4) + + def paint_chart(self, painter, rect): + metrics = QFontMetrics(theme.font_ui(theme.SIZE_TINY)) + max_value = max((value for _key, _label, value, _text, _color in self._rows), default=0) or 1 + label_width = rect.width() * 0.46 + value_width = 96.0 + bar_left = rect.left() + label_width + 8 + bar_right = rect.right() - value_width + bar_width = max(bar_right - bar_left, 10) + + for index, (key, label, value, text, color) in enumerate(self._rows): + top = rect.top() + index * self._row_height + if top > rect.bottom(): + break + row = QRectF(rect.left(), top, rect.width(), self._row_height - 4) + if index == self._hover: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(theme.HOVER)) + painter.drawRoundedRect(row, 4, 4) + + painter.setPen(QColor(theme.TEXT if index == self._hover else theme.TEXT_MUTED)) + painter.setFont(theme.font_ui(theme.SIZE_TINY)) + painter.drawText( + QRectF(row.left() + 6, row.top(), label_width - 10, row.height()), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + elide(label, metrics, label_width - 10), + ) + + track = QRectF(bar_left, row.center().y() - 5, bar_width, 10) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(theme.SUNKEN)) + painter.drawRoundedRect(track, 5, 5) + filled = QRectF(track.left(), track.top(), max(track.width() * (value / max_value), 2), track.height()) + painter.setBrush(QColor(color)) + painter.drawRoundedRect(filled, 5, 5) + + painter.setPen(QColor(theme.TEXT)) + painter.drawText( + QRectF(row.right() - value_width + 6, row.top(), value_width - 10, row.height()), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + elide(text, metrics, value_width - 12), + ) + self.setMinimumHeight(max(len(self._rows), 1) * self._row_height + 4) + + def _row_at(self, pos): + index = int((pos.y() - 1) // self._row_height) + if 0 <= index < len(self._rows): + return index + return -1 + + def mouseMoveEvent(self, event): # noqa: N802 + index = self._row_at(event.position()) + if index != self._hover: + self._hover = index + self.setToolTip(self._rows[index][1] if index >= 0 else "") + self.update() + + def leaveEvent(self, event): # noqa: N802 + if self._hover != -1: + self._hover = -1 + self.update() + + def mousePressEvent(self, event): # noqa: N802 + if event.button() == Qt.MouseButton.LeftButton: + index = self._row_at(event.position()) + if index >= 0: + self.clicked.emit(self._rows[index][0]) + + +class MeterRow(ChartBase): + """一行百分比计量条(用于"重复内容占比""压缩率"这类单值展示)。""" + + def __init__(self, parent=None, label="", color=theme.ACCENT): + super().__init__(parent) + self._value = 0.0 + self._text = "" + self._label = label + self._color = color + self.setMinimumHeight(30) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def set_value(self, ratio, text, label=None): + self._value = max(0.0, min(float(ratio or 0), 1.0)) + self._text = text + if label is not None: + self._label = label + self.update() + + def has_data(self): + return True + + def paint_chart(self, painter, rect): + metrics = QFontMetrics(theme.font_ui(theme.SIZE_TINY)) + painter.setPen(QColor(theme.TEXT_MUTED)) + painter.drawText( + QRectF(rect.left(), rect.top(), rect.width() - 90, 18), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + elide(self._label, metrics, rect.width() - 90), + ) + painter.setPen(QColor(theme.TEXT)) + painter.drawText( + QRectF(rect.right() - 90, rect.top(), 90, 18), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + self._text, + ) + track = QRectF(rect.left(), rect.top() + 21, rect.width(), 8) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(theme.SUNKEN)) + painter.drawRoundedRect(track, 4, 4) + if self._value > 0: + filled = QRectF(track.left(), track.top(), max(track.width() * self._value, 3), track.height()) + painter.setBrush(QColor(self._color)) + painter.drawRoundedRect(filled, 4, 4) + + +def _bytes(value): + """图表内联的字节格式化(与 format.human_size 同口径,避免循环 import)。""" + from ..format import human_size + + return human_size(value, precision=1) diff --git a/src/svfs_studio/widgets/shell.py b/src/svfs_studio/widgets/shell.py new file mode 100644 index 0000000..dcd4728 --- /dev/null +++ b/src/svfs_studio/widgets/shell.py @@ -0,0 +1,941 @@ +#!/usr/bin/env python3 +"""主窗口:顶栏 + 侧栏(包信息 / 快照切换)+ 标签页 + 状态栏 + 任务日志。 + +界面里的数据流只有一条:**所有视图都从同一份 payload 渲染**。 +后台服务每次返回完整的包状态(条目、blob、统计、快照、签名),主窗口把它发给每个 +视图,各视图自己决定显示什么。这样不会出现"概览是新的、存储分析还是旧的"这种分裂, +代价只是每次刷新多读一遍元数据——对桌面工具完全承受得起。 +""" + +import os + +from PySide6.QtCore import QSettings, Qt, QThread +from PySide6.QtGui import QAction, QKeySequence,QFont +from PySide6.QtWidgets import ( + QApplication, + QDockWidget, + QFileDialog, + QLabel, + QListWidget, + QListWidgetItem, + QMainWindow, + QMenu, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QSplitter, + QStackedWidget, + QTabWidget, + QWidget, +) + +from .. import APP_NAME, __version__, theme +from ..format import human_size, human_time, short_hash +from ..worker import StudioClient +from .cards import Badge, Banner, Card, ElidedLabel, divider, hbox, vbox + + +def _file_time_text(timestamp): + """顶栏右上角那句"文件最后改动时间"。库变没变,看这个比看创建时间有用。""" + if not timestamp: + return "" + import datetime + + try: + moment = datetime.datetime.fromtimestamp(timestamp) + except (OverflowError, OSError, ValueError): + return "" + return f"最后改动 {human_time(moment, '%m-%d %H:%M')}" + + +def _dot_icon(color, size=9): + """列表项前面的小圆点(当前快照 / 普通快照)。现画一个,不引图标资源。""" + from PySide6.QtCore import QRectF + from PySide6.QtGui import QIcon, QPainter, QPixmap + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(theme.qcolor(color)) + painter.drawEllipse(QRectF(1, 1, size - 2, size - 2)) + painter.end() + return QIcon(pixmap) + + +class MainWindow(QMainWindow): + #: 标签页顺序与名字 + TABS = ( + ("overview", "概览"), + ("browser", "文件"), + ("snapshots", "快照"), + ("storage", "存储"), + ("integrity", "完整性"), + ("internals", "内部"), + ) + + def __init__(self, client=None): + super().__init__() + self.settings = QSettings("svfs-studio", "svfs-studio") + self.client = client or StudioClient(self) + self.payload = None + self._views = {} + self._current_path = None + + self.setWindowTitle(f"{APP_NAME} {__version__}") + self.resize(1320, 860) + self.setMinimumSize(1020, 640) + + self._thread = QThread(self) + self._thread.setObjectName("svfs-worker") + self.client.attach_to(self._thread) + self.client.progress.connect(self._on_progress) + self.client.log.connect(self._append_log) + self.client.busy_changed.connect(self._on_busy) + + self._build_ui() + self._build_actions() + self._restore_state() + self._show_empty() + + # ------------------------------------------------------------------ + # 搭建 + # ------------------------------------------------------------------ + + def _build_ui(self): + central = QWidget() + outer = vbox(central, 0, 0) + outer.addWidget(self._build_header()) + + self.banner = Banner() + banner_wrap = QWidget() + banner_layout = vbox(banner_wrap, 12, 0) + banner_layout.addWidget(self.banner) + banner_wrap.setVisible(False) + self._banner_wrap = banner_wrap + self.banner.closed.connect(lambda: banner_wrap.setVisible(False)) + outer.addWidget(banner_wrap) + + self.pages = QStackedWidget() + self.pages.addWidget(self._build_empty_page()) + self.pages.addWidget(self._build_workspace()) + outer.addWidget(self.pages, 1) + self.setCentralWidget(central) + + self._build_statusbar() + self._build_log_dock() + + def _build_header(self): + header = QWidget() + header.setObjectName("Header") + header.setFixedHeight(theme.HEADER_HEIGHT) + layout = hbox(header, 12, 6) + + title = QLabel(APP_NAME) + title.setStyleSheet(f"font-size: {theme.SIZE_TITLE}px; font-weight: 600;") + layout.addWidget(title) + subtitle = QLabel("sqlite_vfs 可视化工具") + theme.role(subtitle, "faint") + layout.addWidget(subtitle) + layout.addSpacing(8) + + font = QFont() + font.setPointSize(7) # 字号(磅) + font.setBold(True) # 可选:加粗 + + self.open_button = QPushButton("打开包") + self.open_button.setObjectName("Primary") + self.open_button.setToolTip("打开一个 .svfs 包(Ctrl+O)") + self.open_button.clicked.connect(self.open_dialog) + self.open_button.setFont(font) + layout.addWidget(self.open_button) + + self.new_button = QPushButton("新建空包") + self.new_button.setToolTip("新建一个只有表结构的空包,再用增量打包往里加文件") + self.new_button.clicked.connect(self.new_package_dialog) + self.new_button.setFont(font) + layout.addWidget(self.new_button) + + self.pack_button = QPushButton("打包文件夹") + self.pack_button.setToolTip("把一个文件夹打包成 .svfs(在工具箱里完成)") + self.pack_button.clicked.connect(self.pack_dialog) + self.pack_button.setFont(font) + layout.addWidget(self.pack_button) + + self.recent_button = QPushButton("最近") + self.recent_button.setToolTip("最近打开过的包") + self.recent_menu = QMenu(self.recent_button) + self.recent_menu.aboutToShow.connect(self._fill_recent_menu) + self.recent_button.setMenu(self.recent_menu) + self.recent_button.setFont(font) + layout.addWidget(self.recent_button) + + layout.addStretch(1) + + # 右半边是"当前这个包":刷新 + 名字 + 文件改动时间。刷新放这儿而不是跟 + # 打开类操作挤在左边,是因为它作用的对象就是旁边那个名字。 + self.refresh_button = QPushButton("刷新") + self.refresh_button.setObjectName("Ghost") + self.refresh_button.setToolTip("重新读取当前包(Ctrl+R)") + self.refresh_button.clicked.connect(self.refresh) + self.refresh_button.setEnabled(False) + layout.addWidget(self.refresh_button) + + self.path_label = ElidedLabel("未打开任何包") + theme.role(self.path_label, "faint") + layout.addWidget(self.path_label, 1) + # 时间用普通 QLabel:它的最小宽度就是文本宽度,不会被布局压成"…"; + # 需要让位时由左边那个可省略的路径标签来让。 + self.mtime_label = QLabel("") + theme.role(self.mtime_label, "faint") + layout.addWidget(self.mtime_label) + return header + + def _build_empty_page(self): + page = QWidget() + layout = vbox(page, 0, 0) + layout.addStretch(1) + card = Card(padding=16, spacing=8) + card.setMaximumWidth(560) + title = QLabel("打开一个包,或者把一个文件夹打包进来") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + title.setStyleSheet("font-size: 16px; font-weight: 600;") + card.add(title) + detail = QLabel( + "一个 .svfs 就是一个普通的 SQLite 数据库:里面的文件、目录、" + "版本快照、内容去重与压缩都能被逐层摊开来看。\n\n" + "没有现成的包?用「打包文件夹…」随手打一个——" + "同一个文件出现两次时,你会看到它只被存了一份。" + ) + theme.role(detail, "muted") + detail.setAlignment(Qt.AlignmentFlag.AlignCenter) + detail.setWordWrap(True) + card.add(detail) + row = hbox(spacing=6) + row.addStretch(1) + open_button = QPushButton("打开包") + open_button.setObjectName("Primary") + open_button.clicked.connect(self.open_dialog) + new_button = QPushButton("新建空包") + new_button.setToolTip("先建一个空包,再用「打包文件夹…」勾选增量把文件打进去") + new_button.clicked.connect(self.new_package_dialog) + pack_button = QPushButton("打包文件夹") + pack_button.clicked.connect(self.pack_dialog) + demo_button = QPushButton("打个示例包看看") + demo_button.setToolTip("在临时目录里造一棵小目录树并打包,直接打开给你看") + demo_button.clicked.connect(self.build_demo_package) + row.addWidget(open_button) + row.addWidget(new_button) + row.addWidget(pack_button) + row.addWidget(demo_button) + row.addStretch(1) + card.add_layout(row) + + sample = QLabel("") + theme.role(sample, "faint") + sample.setAlignment(Qt.AlignmentFlag.AlignCenter) + sample.setWordWrap(True) + card.add(sample) + self._sample_hint = sample + self._refresh_sample_hint() + + wrapper = hbox(spacing=0) + wrapper.addStretch(1) + wrapper.addWidget(card) + wrapper.addStretch(1) + layout.addLayout(wrapper) + layout.addStretch(2) + return page + + def _refresh_sample_hint(self): + """仓库里自带的 appcation.svfs 是 schema v1,正好拿来演示"旧格式 → 迁移"。""" + from ..sample import legacy_sample_path + + path = legacy_sample_path() + if path: + self._sample_hint.setText( + f"仓库里自带一个旧格式示例(schema v1):{os.path.basename(path)}\n" + f"打开它会看到「旧格式」提示,可以直接一键迁移到 v3。" + ) + else: + self._sample_hint.setText("") + + def _build_workspace(self): + splitter = QSplitter(Qt.Orientation.Horizontal) + + sidebar = QWidget() + sidebar.setObjectName("Sidebar") + sidebar.setMinimumWidth(232) + sidebar.setMaximumWidth(400) + side_layout = vbox(sidebar, 12, 8) + side_layout.addWidget(self._build_package_card()) + side_layout.addWidget(divider()) + side_layout.addWidget(self._build_snapshot_panel(), 1) + splitter.addWidget(sidebar) + + self.tabs = QTabWidget() + self.tabs.setDocumentMode(True) + # 标签左对齐、各自只占内容宽度:7 个标签铺满一整行反而更难扫 + self.tabs.tabBar().setExpanding(False) + self._tab_index = {} + for key, label in self.TABS: + view = self._make_view(key) + # slug 是给截图用的 ASCII 文件名(中文文件名在中文 Windows 上会写成本地代码页) + view.setProperty("slug", key) + self._views[key] = view + self._tab_index[key] = self.tabs.addTab(view, label) + # 工具箱是"附加页":不在 TABS 里(它不针对某个快照),但同样参与数据分发 + ops = self._make_view("ops") + ops.setProperty("slug", "toolbox") + self._views["ops"] = ops + self._tab_index["ops"] = self.tabs.addTab(ops, "工具箱") + splitter.addWidget(self.tabs) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([236, 1084]) + self.splitter = splitter + return splitter + + def _build_package_card(self): + card = Card(padding=theme.PAD, spacing=4) + self.pkg_name = QLabel("—") + self.pkg_name.setStyleSheet("font-size: 14px; font-weight: 600;") + self.pkg_name.setWordWrap(True) + card.add(self.pkg_name) + + badges = hbox(spacing=6) + self.schema_badge = Badge("v3", "ok") + self.snapshot_badge = Badge("快照 —", "muted") + badges.addWidget(self.schema_badge) + badges.addWidget(self.snapshot_badge) + badges.addStretch(1) + card.add_layout(badges) + + # 侧栏窄,长文本统一用 ElidedLabel:宁可显示省略号,也不要在汉字中间被切掉 + self.pkg_files = ElidedLabel("—") + theme.role(self.pkg_files, "muted") + self.pkg_size = ElidedLabel("—") + theme.role(self.pkg_size, "muted") + self.pkg_saved = ElidedLabel("—") + theme.role(self.pkg_saved, "ok") + self.pkg_saved_detail = ElidedLabel("") + theme.role(self.pkg_saved_detail, "faint") + self.pkg_time = ElidedLabel("—") + theme.role(self.pkg_time, "faint") + for widget in ( + self.pkg_files, + self.pkg_size, + self.pkg_saved, + self.pkg_saved_detail, + self.pkg_time, + ): + card.add(widget) + return card + + def _build_snapshot_panel(self): + wrapper = QWidget() + layout = vbox(wrapper, 0, 6) + header = hbox(spacing=6) + title = QLabel("快照") + theme.role(title, "h2") + header.addWidget(title) + header.addStretch(1) + self.snapshot_add = QPushButton("新建") + self.snapshot_add.setObjectName("Ghost") + self.snapshot_add.setToolTip("基于当前快照新建一版(只复制元数据,内容共享)") + self.snapshot_add.clicked.connect(self._create_snapshot) + header.addWidget(self.snapshot_add) + layout.addLayout(header) + + self.snapshot_list = QListWidget() + self.snapshot_list.setToolTip("双击切换当前快照") + self.snapshot_list.itemDoubleClicked.connect(self._on_snapshot_double_clicked) + self.snapshot_list.currentItemChanged.connect(self._on_snapshot_highlight) + layout.addWidget(self.snapshot_list, 1) + self.snapshot_hint = QLabel("") + theme.role(self.snapshot_hint, "faint") + self.snapshot_hint.setWordWrap(True) + layout.addWidget(self.snapshot_hint) + return wrapper + + def _build_statusbar(self): + bar = self.statusBar() + self.status_label = ElidedLabel("就绪") + theme.role(self.status_label, "muted") + bar.addWidget(self.status_label, 1) + + self.progress = QProgressBar() + self.progress.setFixedWidth(180) + self.progress.setVisible(False) + bar.addPermanentWidget(self.progress) + + self.progress_label = QLabel("") + theme.role(self.progress_label, "faint") + self.progress_label.setVisible(False) + bar.addPermanentWidget(self.progress_label) + + self.log_button = QPushButton("日志") + self.log_button.setObjectName("Ghost") + self.log_button.setCheckable(True) + self.log_button.setToolTip("显示/隐藏后台任务日志(Ctrl+L)") + self.log_button.toggled.connect(self._toggle_log) + bar.addPermanentWidget(self.log_button) + + def _build_log_dock(self): + self.log_dock = QDockWidget("后台任务日志", self) + self.log_dock.setAllowedAreas( + Qt.DockWidgetArea.BottomDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea + ) + inner = QWidget() + layout = vbox(inner, 8, 6) + self.log_view = QPlainTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setMaximumBlockCount(4000) + self.log_view.setFont(theme.font_mono(theme.SIZE_TINY)) + self.log_view.setPlaceholderText("打包、校验、发布等操作的输出会出现在这里。") + layout.addWidget(self.log_view) + row = hbox(spacing=6) + row.addStretch(1) + clear = QPushButton("清空") + clear.setObjectName("Ghost") + clear.clicked.connect(self.log_view.clear) + copy = QPushButton("复制全部") + copy.setObjectName("Ghost") + copy.clicked.connect( + lambda: QApplication.clipboard().setText(self.log_view.toPlainText()) + ) + row.addWidget(clear) + row.addWidget(copy) + layout.addLayout(row) + self.log_dock.setWidget(inner) + self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.log_dock) + self.log_dock.setVisible(False) + + def _build_actions(self): + def add(text, shortcut, slot): + action = QAction(text, self) + if shortcut: + action.setShortcut(QKeySequence(shortcut)) + action.triggered.connect(slot) + self.addAction(action) + return action + + add("打开包", "Ctrl+O", self.open_dialog) + add("刷新", "Ctrl+R", self.refresh) + add("关闭包", "Ctrl+W", self.close_package) + add("退出", "Ctrl+Q", self.close) + add("日志", "Ctrl+L", lambda: self.log_button.toggle()) + add("概览", "Ctrl+1", lambda: self.goto_tab("overview")) + add("文件", "Ctrl+2", lambda: self.goto_tab("browser")) + add("快照", "Ctrl+3", lambda: self.goto_tab("snapshots")) + add("存储", "Ctrl+4", lambda: self.goto_tab("storage")) + add("完整性", "Ctrl+5", lambda: self.goto_tab("integrity")) + add("内部", "Ctrl+6", lambda: self.goto_tab("internals")) + add("关于", "F1", self._about) + + def _restore_state(self): + geometry = self.settings.value("geometry") + if geometry: + self.restoreGeometry(geometry) + log_visible = self.settings.value("log_visible", False, type=bool) + self.log_button.setChecked(bool(log_visible)) + self.log_dock.setVisible(bool(log_visible)) + tab_index = self.settings.value("tab_index", 0, type=int) + if 0 <= tab_index < self.tabs.count(): # type: ignore + self.tabs.setCurrentIndex(tab_index) # type: ignore + sizes = self.settings.value("splitter_sizes") + if isinstance(sizes, (list, tuple)) and len(sizes) >= 2: + try: + self.splitter.setSizes([int(size) for size in sizes]) + except (TypeError, ValueError): + pass + + def closeEvent(self, event): # noqa: N802 + self.settings.setValue("geometry", self.saveGeometry()) + self.settings.setValue("log_visible", self.log_dock.isVisible()) + self.settings.setValue("tab_index", self.tabs.currentIndex()) + self.settings.setValue("splitter_sizes", self.splitter.sizes()) + self.close_package(quiet=True) + self._thread.quit() + self._thread.wait(3000) + super().closeEvent(event) + + # ------------------------------------------------------------------ + # 最近打开 + # ------------------------------------------------------------------ + + #: 最多记多少个最近打开的文件 + RECENT_LIMIT = 6 + + def _recent_paths(self): + stored = self.settings.value("recent", []) + if isinstance(stored, str): + stored = [stored] + return [path for path in (stored or []) if path] # type: ignore + + def _remember_recent(self, path): + path = os.path.abspath(path) + paths = [item for item in self._recent_paths() if item != path] + paths.insert(0, path) + self.settings.setValue("recent", paths[: self.RECENT_LIMIT]) + + def _fill_recent_menu(self): + self.recent_menu.clear() + paths = self._recent_paths() + if not paths: + action = self.recent_menu.addAction("还没有打开过任何包") + action.setEnabled(False) + return + for path in paths: + exists = os.path.exists(path) + action = self.recent_menu.addAction( + (" " if exists else "(已不存在)") + os.path.basename(path) + ) + action.setToolTip(path) + action.setEnabled(exists) + action.triggered.connect(lambda _checked=False, target=path: self.open_package(target)) + self.recent_menu.addSeparator() + self.recent_menu.addAction( + "清除记录", lambda: self.settings.remove("recent") + ) + + # ------------------------------------------------------------------ + # 视图注册 / 数据分发 + # ------------------------------------------------------------------ + + def _make_view(self, key): + from .. import views + + builder = getattr(views, f"build_{key}") + return builder(self) + + def goto_tab(self, key): + index = self._tab_index.get(key) + if index is None: + raise KeyError(key) + self.pages.setCurrentIndex(1) + self.tabs.setCurrentIndex(index) + + def show_path(self, path): + """从别的视图跳到一个文件:切到文件页并选中它。""" + self.goto_tab("browser") + browser = self._views.get("browser") + if browser is not None and hasattr(browser, "select_path"): + browser.select_path(path) + + # ------------------------------------------------------------------ + + def open_dialog(self): + start = self.settings.value("last_dir", "", type=str) or "" + path, _filter = QFileDialog.getOpenFileName( + self, + "打开 SVFS 包", + start, # type: ignore + "SVFS 包 (*.svfs *.db *.sqlite *.sqlite3);;所有文件 (*)", + ) + if path: + self.settings.setValue("last_dir", os.path.dirname(path)) + self.open_package(path) + + def pack_dialog(self): + start = self.settings.value("last_pack_dir", "", type=str) or "" + source = QFileDialog.getExistingDirectory(self, "选择要打包的文件夹", start) # type: ignore + if not source: + return + self.settings.setValue("last_pack_dir", source) + target, _filter = QFileDialog.getSaveFileName( + self, + "保存为", + os.path.join(os.path.dirname(source), os.path.basename(source) + ".svfs"), + "SVFS 包 (*.svfs)", + ) + if not target: + return + ops = self._views.get("ops") + if ops is not None: + ops.quick_pack(source, target) + + # ------------------------------------------------------------------ + # 新建空包 + # ------------------------------------------------------------------ + + def new_package_dialog(self): + start = self.settings.value("last_dir", "", type=str) or "" + target, _filter = QFileDialog.getSaveFileName( + self, "新建空的 SVFS 包", os.path.join(start, "new.svfs"), "SVFS 包 (*.svfs)" # type: ignore + ) + if not target: + return + self.settings.setValue("last_dir", os.path.dirname(target)) + self.new_package(target) + + def new_package(self, path, name=None): + """建一个空包并打开它(对话框之外的逻辑单独成函数,测试直接调这个)。""" + self.client.submit( + "new_package", + {"path": path, "name": name}, + on_done=self._on_package_created, + on_fail=self._on_open_failed, + label="新建空包", + ) + + def _on_package_created(self, payload): + self._current_path = payload["path"] + self._remember_recent(payload["path"]) + self._apply_payload(payload) + self._show_banner( + "ok", + f"空包已建好(包名「{payload.get('created_name')}」,schema v3)", + "它现在有表结构、元数据和一个默认快照,但还没有任何文件。" + "往里加文件的方式是「工具箱 → 打包文件夹」,把输出指向这个文件并勾选" + "「增量打包」——这样每加一轮就是新的一版历史。", + "去打包进来", + ) + self.banner.action.connect(lambda: self._focus_pack_into(payload["path"])) + + def _focus_pack_into(self, path): + try: + self.banner.action.disconnect() + except (RuntimeError, TypeError): + pass + self._hide_banner() + self.goto_tab("ops") + ops = self._views.get("ops") + if ops is not None: + ops.focus_pack_into(path) + + def build_demo_package(self): + """在临时目录里打一个演示包并打开它——没有现成包时的最短路径。""" + import tempfile + + from ..sample import make_demo_package + + target = os.path.join(tempfile.mkdtemp(prefix="svfs_studio_"), "demo.svfs") + try: + make_demo_package(target) + except Exception as exc: # 示例都打不出来,那是真出问题了,得说出来 + self._show_error("生成示例包失败", str(exc)) + return + self.notify("已在临时目录里打好示例包") + self.open_package(target) + + def open_package(self, path, snapshot_id=None): + if not path: + return + self._current_path = path + self._remember_recent(path) + self.client.submit( + "open", + {"path": path, "snapshot_id": snapshot_id}, + on_done=self._apply_payload, + on_fail=self._on_open_failed, + label="打开包", + ) + + def refresh(self): + if not self._current_path: + return + self.client.submit( + "refresh", {}, on_done=self._apply_payload, label="刷新", on_fail=self._show_error + ) + + def close_package(self, quiet=False): + if self._current_path is None: + return + self._current_path = None + # 不占用"忙"状态:关包只是释放句柄,没有必要让按钮全灰一下 + self.client.submit_silent("close", {}) + self.payload = None + self._show_empty() + if not quiet: + self.notify("已关闭当前包") + + # ------------------------------------------------------------------ + + def apply_payload(self, payload): + """把一份新的包状态铺到界面上(视图改完库之后自己调一次就能整体刷新)。""" + self._apply_payload(payload) + + def _apply_payload(self, payload): + self.payload = payload + if payload.get("legacy") is not None or payload.get("error"): + self._apply_legacy(payload) + return + self.pages.setCurrentIndex(1) + self.refresh_button.setEnabled(True) + self._update_sidebar(payload) + self._update_header_labels(payload) + for view in self._views.values(): + setter = getattr(view, "set_data", None) + if setter is not None: + setter(payload) + self._hide_banner() + summary = payload.get("summary") or {} + self.notify( + f"已打开 {os.path.basename(payload['path'])}:" + f"{summary.get('total_files', 0)} 个文件," + f"{human_size(summary.get('logical_size'))}" + ) + + def _update_header_labels(self, payload): + """顶栏右侧那两段:当前包的名字与库文件的改动时间。""" + path = payload.get("path") or "" + self._current_path = path or self._current_path + self.path_label.set_full_text(os.path.basename(path) if path else "未打开任何包") + self.mtime_label.setText(_file_time_text(payload.get("db_mtime"))) + + def _hide_banner(self): + self.banner.setVisible(False) + self._banner_wrap.setVisible(False) + + def _show_banner(self, state, title, detail="", action_text=None): + self.banner.show_message(state, title, detail, action_text) + self._banner_wrap.setVisible(True) + + def _apply_legacy(self, payload): + """旧格式、非 SVFS、或坏文件:切到"内部"页(SQL 与内部结构还能看),并给出下一步。""" + self.pages.setCurrentIndex(1) + self.refresh_button.setEnabled(True) + for view in self._views.values(): + setter = getattr(view, "set_data", None) + if setter is not None: + setter(payload) + self.goto_tab("internals") + version = payload.get("legacy") + if version: + self._show_banner( + "warn", + f"这是 schema v{version} 的旧格式包,当前版本读不了它的内容", + "文件树、快照、存储分析需要 v3 的 blobs + snapshots 结构。" + "点右边按钮可以把它迁移到 v3(默认先留一份 .bak 备份)," + "用的是库自带的同一份迁移代码。", + "去迁移", + ) + self.banner.action.connect(self._open_migrate_for_current) + else: + self._show_banner( + "warn", + "这不是一个 SVFS 包", + payload.get("error") + or "这个文件里没有 SVFS 的表结构。" + "它作为一个 SQLite 数据库的 SQL 与内部结构还能看(切到「内部」页)。", + ) + + def _open_migrate_for_current(self): + try: + self.banner.action.disconnect() + except (RuntimeError, TypeError): + pass + self._hide_banner() + self.goto_tab("ops") + ops = self._views.get("ops") + if ops is not None and self._current_path: + ops.focus_migrate(self._current_path) + + def _on_open_failed(self, title, detail): + # 打开失败时把各视图也清掉:留着上一个包的数据、配上"打开失败"的横幅, + # 只会让人以为数据是坏的(其实就是没换成)。 + for view in self._views.values(): + setter = getattr(view, "set_data", None) + if setter is not None: + setter(None) + self.payload = None + self._show_empty() + self._show_banner("err", title, detail) + self.notify(f"{title}:{detail}") + + def _show_error(self, title, detail): + self._show_banner("err", title, detail) + self.notify(f"{title}:{detail}") + + def _show_empty(self): + self.pages.setCurrentIndex(0) + self.refresh_button.setEnabled(False) + self._hide_banner() + + def _update_sidebar(self, payload): + stats = payload.get("stats") or {} + summary = payload.get("summary") or {} + self.pkg_name.setText(stats.get("name") or "-") + self.pkg_name.setToolTip(payload.get("path", "")) + version = stats.get("schema_version") or 3 + self.schema_badge.set_state("ok", f"v{version}") + self.schema_badge.setToolTip( + f"schema v{version}:blobs(内容寻址)+ snapshots(版本历史)结构" + ) + + logical = summary.get("logical_size") or 0 + dedup_saved = summary.get("dedup_saved") or 0 + compression_saved = summary.get("compression_saved") or 0 + saved = dedup_saved + compression_saved + self.pkg_files.set_full_text( + f"{summary.get('total_files', 0)} 文件 · {summary.get('total_dirs', 0)} 目录" + ) + self.pkg_size.set_full_text( + f"逻辑 {human_size(logical)} → 库内 {human_size(summary.get('stored_bytes'))}" + ) + if logical: + self.pkg_saved.set_full_text( + f"省下 {human_size(saved)}({saved / logical * 100:.0f}%)" + ) + self.pkg_saved_detail.set_full_text( + f"去重 {human_size(dedup_saved)} · 压缩 {human_size(compression_saved)}" + ) + else: + self.pkg_saved.set_full_text("省下 —") + self.pkg_saved_detail.set_full_text("") + created = stats.get("created_time") + self.pkg_time.set_full_text(f"创建于 {human_time(created)}" if created else "") + + snapshot_id = payload.get("snapshot_id") + self.snapshot_badge.set_state( + "accent" if snapshot_id else "muted", + f"快照 {payload.get('snapshot_name') or snapshot_id or '—'}", + ) + + self._fill_snapshots(payload) + + def _fill_snapshots(self, payload): + snapshots = payload.get("snapshots") or [] + active = payload.get("snapshot_id") + self.snapshot_list.blockSignals(True) + self.snapshot_list.clear() + for snapshot in snapshots: + is_current = snapshot["id"] == active + label = snapshot.get("name") or f"snapshot-{snapshot['id']}" + files = snapshot.get("total_files") or 0 + size = human_size(snapshot.get("total_size")) + digest = short_hash(snapshot.get("digest"), 8) + item = QListWidgetItem(f"{label}\n{files} 文件 · {size} · {digest}") + # 圆点是"哪个是当前版本"最省事的标记,比再写两个字有效 + item.setIcon(_dot_icon(theme.ACCENT if is_current else theme.BORDER_STRONG)) + item.setData(Qt.ItemDataRole.UserRole, snapshot["id"]) + item.setToolTip( + f"id={snapshot['id']} 父快照={snapshot.get('parent_id')}\n" + f"创建于 {human_time(snapshot.get('created_time'))}\n" + f"摘要 {snapshot.get('digest') or '(未计算)'}\n" + + ("这是当前版本" if is_current else "双击切换为当前快照") + ) + if is_current: + font = item.font() + font.setBold(True) + item.setFont(font) + self.snapshot_list.addItem(item) + self.snapshot_list.blockSignals(False) + self.snapshot_hint.setText( + "库里还没有快照,写入时会自动建一个。" + if not snapshots + else "双击切换版本;内容在各版本之间共享,所以新版几乎不占空间。" + ) + + # ------------------------------------------------------------------ + # 交互 + # ------------------------------------------------------------------ + + def select_snapshot(self, snapshot_id): + if snapshot_id is None or snapshot_id == (self.payload or {}).get("snapshot_id"): + return + self.client.submit( + "select_snapshot", + {"snapshot_id": snapshot_id}, + on_done=self._apply_payload, + on_fail=self._show_error, + label=f"切换快照 {snapshot_id}", + ) + + def _on_snapshot_double_clicked(self, item): + self.select_snapshot(item.data(Qt.ItemDataRole.UserRole)) + + def _on_snapshot_highlight(self, current, _previous): + """单击只预览摘要,不切换(切换会重读整个包,得是明确的动作)。""" + if current is None: + return + snapshot_id = current.data(Qt.ItemDataRole.UserRole) + self.notify(f"快照 {snapshot_id}:双击切换为当前快照") + + def _create_snapshot(self): + if not self.payload: + return + from PySide6.QtWidgets import QInputDialog + + name, ok = QInputDialog.getText( + self, "新建快照", "快照名:", text=f"v{len(self.payload.get('snapshots') or []) + 1}" + ) + if not ok or not name.strip(): + return + answer = QMessageBox.question( + self, + "新建快照", + f"将基于当前快照新建「{name.strip()}」,并把当前的目录结构整份复制过去。\n\n" + "只复制元数据行,内容 blob 是共享的,所以几乎不占空间;" + "新建后当前快照会切到新版。", + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + if answer != QMessageBox.StandardButton.Ok: + return + self.client.submit( + "create_snapshot", + { + "name": name.strip(), + "parent_id": self.payload.get("snapshot_id"), + "copy_entries": True, + }, + on_done=self._apply_payload, + on_fail=self._show_error, + label="新建快照", + ) + + # ------------------------------------------------------------------ + # 后台任务反馈 + # ------------------------------------------------------------------ + + def run(self, op, payload, on_done=None, on_fail=None, label=None): + self.client.submit(op, payload, on_done=on_done, on_fail=on_fail or self._show_error, label=label) + + def _on_progress(self, job, done, total, label): + self.progress.setVisible(True) + self.progress_label.setVisible(True) + if total: + self.progress.setRange(0, int(total)) + self.progress.setValue(int(done)) + self.progress_label.setText(f"{job} {done}/{total}") + else: + self.progress.setRange(0, 0) + self.progress_label.setText(f"{job}…") + if label: + self.status_label.setText(f"{job}:{label}") + + def _on_busy(self, busy, op): + self.progress.setVisible(bool(busy)) + self.progress_label.setVisible(bool(busy)) + if busy: + self.progress.setRange(0, 0) + self.progress_label.setText(f"{op}…") + self.status_label.setText(f"{op} 进行中…") + else: + self.status_label.setText("就绪") + self.progress_label.setText("") + self.open_button.setEnabled(not busy) + self.refresh_button.setEnabled(not busy and self._current_path is not None) + + def notify(self, text): + self.status_label.setText(text) + + def _append_log(self, line): + self.log_view.appendPlainText(line) + + def _toggle_log(self, checked): + self.log_dock.setVisible(bool(checked)) + + def _about(self): + QMessageBox.about( + self, + f"关于 {APP_NAME}", + f"{APP_NAME} {__version__}

" + "sqlite_vfs 的可视化工具:把一个 .svfs 包摊开来看——
" + "文件树、版本快照与差异、内容去重与压缩的实际收益、完整性校验与签名," + "以及它作为一个 SQLite 数据库的内部结构。

" + "所有读写都调用 sqlite_vfs 自己的公开 API,不复制一份实现。", + ) diff --git a/src/svfs_studio/worker.py b/src/svfs_studio/worker.py new file mode 100644 index 0000000..c37629e --- /dev/null +++ b/src/svfs_studio/worker.py @@ -0,0 +1,1203 @@ +#!/usr/bin/env python3 +"""后台任务层:所有 SQLiteVFS 句柄都只在工作线程里创建和使用。 + +为什么要这么绕:``SQLiteVFS`` 打开的是真 sqlite3 连接,而 sqlite3 连接不能跨线程用 +(``check_same_thread`` 默认 True,库里也没有内置锁)。所以这里把**所有**库操作都塞进 +一个专用线程,主线程只发请求、收结果。 + +顺带解决三件事: + +* 耗时操作(打包、逐文件校验、发布)不会卡住界面; +* 库里的 ``print`` 输出被接进日志面板——顺带避免 ``pythonw`` 下 ``sys.stdout`` + 为 None 时 ``print`` 直接抛错; +* 异常统一翻译成中文标题 + 明细,界面只负责显示,不做字符串嗅探。 +""" + +import contextlib +import io +import json +import os +import pathlib +import sqlite3 +import sys +import time + +from PySide6.QtCore import QObject, Qt, Signal, Slot + +from sqlite_vfs import migration, publish +from sqlite_vfs.core import SQLiteVFS, VFSFormatError, VFSIntegrityError +from sqlite_vfs.folder_packer import FolderPacker +from sqlite_vfs.folder_unpacker import FolderUnpacker +from sqlite_vfs.mime import guess_mime + +from . import analysis + +#: 日志面板最多收多少行(打包几万个文件时不能把内存吃光) +LOG_LINE_LIMIT = 4000 + +#: 预览读取的上限,避免手滑点开一个 2 GB 的文件就把内存占了 +PREVIEW_LIMIT = 1024 * 1024 + +#: 文本编辑的大小上限。编辑框只有一页的内容,超过它的文件不能进编辑模式—— +#: 否则保存会把没显示的部分截掉。 +EDIT_SIZE_LIMIT = 1024 * 1024 + + +class TaskCancelled(Exception): + """用户点了中断。""" + + +def readonly_uri(db_path): + """只读 URI。与 ``sqlite_vfs.core._readonly_uri`` 同一口径,只是交给 pathlib 转义。""" + return pathlib.Path(db_path).resolve().as_uri() + "?mode=ro" + + +def _join(prefix, relative): + """把相对路径拼到目录前缀下,统一用 / 分隔(库里的路径约定)。""" + relative = str(relative).replace(os.sep, "/").strip("/") + prefix = (prefix or "").strip("/") + return f"{prefix}/{relative}" if prefix else relative + + +def _exists_in_snapshot(vfs, path): + """当前快照里这个路径在不在(加文件前用它判断会不会撞路径)。""" + return ( + vfs.conn.execute( + "SELECT 1 FROM files WHERE snapshot_id = ? AND path = ?", + (vfs._active_snapshot(), path), + ).fetchone() + is not None + ) + + +def _ensure_dir(vfs, path, created): + """把目录连同缺的中间层级建出来,已存在的层级跳过。 + + ``add_file`` / ``mk_dir`` 都要求父目录已经在库里,所以"往 incoming/drop/ 里加文件" + 必须先有 incoming 与 incoming/drop 这两行。新建的层级会记进 ``created`` 列表。 + 返回是否成功。 + """ + path = (path or "").strip("/") + if not path: + return True + parts = path.split("/") + try: + for index in range(1, len(parts) + 1): + piece = "/".join(parts[:index]) + if _exists_in_snapshot(vfs, piece): + continue + vfs.mk_dir(piece) + created.append(piece) + except Exception: + return False + return True + + +def user_message(exc): + """异常 → (标题, 明细)。标题要能直接放在横幅上。""" + if isinstance(exc, VFSFormatError): + return "无法打开这个包", str(exc) + if isinstance(exc, VFSIntegrityError): + return "完整性校验失败", str(exc) + if isinstance(exc, PermissionError): + return "权限不足", str(exc) + if isinstance(exc, FileNotFoundError): + return "文件不存在", str(exc) + if isinstance(exc, IsADirectoryError): + return "这是一个目录", str(exc) + if isinstance(exc, NotADirectoryError): + return "路径不是目录", str(exc) + if isinstance(exc, FileExistsError): + return "目标已存在", str(exc) + if isinstance(exc, sqlite3.Error): + return "SQLite 报错", f"{type(exc).__name__}: {exc}" + if isinstance(exc, OSError): + return "文件系统报错", f"{type(exc).__name__}: {exc}" + return type(exc).__name__, str(exc) + + +class LogSink(io.TextIOBase): + """把库里的 print 输出按行转成信号。 + + ``limit`` 行之后只提示一次"已省略",不再逐行发信号——打包十万个文件时 + ``add_file`` 会逐条 print,全部转成信号会把界面拖死。 + """ + + def __init__(self, emit, limit=LOG_LINE_LIMIT, prefix=""): + self._emit = emit + self._limit = limit + self._prefix = prefix + self._buffer = "" + self._lines = 0 + + def writable(self): + return True + + def write(self, text): + if not isinstance(text, str): + text = str(text) + self._buffer += text + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + self._emit_line(line) + return len(text) + + def flush(self): + if self._buffer: + line, self._buffer = self._buffer, "" + self._emit_line(line) + + def _emit_line(self, line): + if self._lines >= self._limit: + if self._lines == self._limit: + self._lines += 1 + self._emit(f"…(输出过多,已省略后续 {self._limit} 行之后的日志)") + return + self._lines += 1 + self._emit(self._prefix + line) + + +@contextlib.contextmanager +def capture_output(emit): + """把库里的 stdout/stderr 接到日志上(两个都接,异常栈也别丢)。""" + out = LogSink(emit) + err = LogSink(emit, prefix="[stderr] ") + previous = (sys.stdout, sys.stderr) + try: + sys.stdout = out + sys.stderr = err + yield + finally: + try: + out.flush() + err.flush() + finally: + sys.stdout, sys.stderr = previous + + +class VfsService(QObject): + """跑在工作线程里的服务对象:所有 ``_op_*`` 都在这个线程执行。""" + + #: 进度:任务名、已完成、总数(0 表示不确定)、当前项 + progress = Signal(str, int, int, str) + #: 库自己 print 出来的一行 + log = Signal(str) + #: 请求完成:req_id、操作名、结果 + finished = Signal(int, str, object) + #: 请求失败:req_id、操作名、标题、明细 + failed = Signal(int, str, str, str) + + def __init__(self, parent=None): + super().__init__(parent) + self._cancel = False + #: 当前打开的只读句柄(浏览用) + self.vfs = None + self.db_path = None + self.snapshot_id = None + #: 旧格式时没有 SQLiteVFS,但还能用只读 sqlite3 连接看 SQL 与内部结构 + self._raw_conn = None + self.legacy_version = None + self._open_error = None + + # ------------------------------------------------------------------ + # 调度 + # ------------------------------------------------------------------ + + @Slot(int, str, dict) + def dispatch(self, req_id, op, payload): + handler = getattr(self, f"_op_{op}", None) + if handler is None: + self.failed.emit(req_id, op, "内部错误", f"没有实现的操作: {op}") + return + self._cancel = False + started = time.monotonic() + try: + with capture_output(self.log.emit): + result = handler(**payload) + except TaskCancelled: + self.failed.emit(req_id, op, "已中断", "任务在下一个进度点被中断。") + except Exception as exc: # 界面不该看到栈,只看到人话 + title, detail = user_message(exc) + self.failed.emit(req_id, op, title, detail) + else: + result = dict(result or {}) + result.setdefault("elapsed", time.monotonic() - started) + self.finished.emit(req_id, op, result) + finally: + self._cancel = False + + def cancel(self): + """请求中断。只在下一个进度回调处生效——不打断已经进库的事务。""" + self._cancel = True + + def _tick(self, job, done, total, label=""): + """进度回调:既要汇报进度,也负责在用户点了中断时把循环打断。""" + if self._cancel: + raise TaskCancelled() + self.progress.emit(job, done, total, label) + + def _progress_callback(self, job): + def callback(*args): + if len(args) >= 2: + done, total = args[0], args[1] + label = args[2] if len(args) > 2 else "" + else: # pragma: no cover - 防御性分支 + done, total, label = 0, 0, "" + self._tick(job, done, total, str(label)) + + return callback + + # ------------------------------------------------------------------ + # 打开 / 读取 + # ------------------------------------------------------------------ + + def _op_open(self, path, snapshot_id=None): + self._close_handles() + self.db_path = os.path.abspath(path) + self.legacy_version = None + self._open_error = None + try: + self.vfs = SQLiteVFS(self.db_path, readonly=True, snapshot_id=snapshot_id) + self.snapshot_id = self.vfs.snapshot_id + except (VFSFormatError, sqlite3.DatabaseError) as exc: + self._open_degraded(exc) + return { + "path": self.db_path, + "legacy": self.legacy_version, + "error": self._open_error, + "snapshot": None, + } + return self._snapshot_payload() + + def _open_degraded(self, exc): + """打不开成 SVFS 时的降级处理。三种情况要分开: + + * **旧格式的 SVFS**(v1/v2):内容看不了,但表和行还在,SQL 与内部结构可用; + * **是 SQLite 库但不是 SVFS**:同样给 SQL 与内部结构——至少能看清这是什么东西; + * **根本不是 SQLite 文件**(比如随手点开一个 txt):没有可降级的东西,给一句人话。 + """ + try: + version = migration.detect_schema_version(self.db_path) + except Exception: + version = None + + if version is None: + # 连接是惰性的:建得起来不代表它真是数据库,得真查一次才知道 + try: + probe = sqlite3.connect(readonly_uri(self.db_path), uri=True) + try: + probe.execute("SELECT count(*) FROM sqlite_master").fetchone() + finally: + probe.close() + except sqlite3.Error as probe_error: + raise VFSFormatError( + f"{self.db_path} 既不是 SVFS 包,也不是能读的 SQLite 数据库" + f"({probe_error})。" + ) from exc + self._open_error = ( + "这是一个 SQLite 数据库,但没有 SVFS 的 files / blobs 表," + "所以它不是本工具的包。它的 SQL 与内部结构仍然可以看。" + ) + else: + self.legacy_version = version + self._open_error = str(exc) + + self._raw_conn = sqlite3.connect(readonly_uri(self.db_path), uri=True) + self._raw_conn.row_factory = sqlite3.Row + + def _op_new_package(self, path, name=None): + """新建一个空的包(只有表结构 + 元数据 + 一个默认快照)。 + + 走的是 ``SQLiteVFS(path)`` 的建表路径:库里没有 files 表时会自动建。 + 空包不是"没用"的东西——它是增量打包的起点:把文件一轮轮增量打进去, + 每加一轮就多一版历史。 + """ + target = os.path.abspath(path) + if os.path.exists(target) and os.path.getsize(target) > 0: + # 覆盖一个有内容的文件(尤其是个真包)太容易变成数据丢失,直接拒绝 + raise FileExistsError( + f"{target} 已经存在,新建空包不会覆盖已有文件;" + f"请换个名字,或者先把它删掉/移走。" + ) + parent = os.path.dirname(target) + if parent: + os.makedirs(parent, exist_ok=True) + package_name = (name or "").strip() or os.path.splitext(os.path.basename(target))[0] + package_name = package_name or "packed_filesystem" + + vfs = SQLiteVFS(target) + try: + vfs.add_vfs_metadata(package_name) + # 空库也要有摘要:这样完整性页立刻能校验,不会一直挂着"未计算摘要" + vfs.recount() + finally: + vfs.close() + + # 用只读句柄重新打开,返回的 payload 与"打开一个包"完全同构 + self._close_handles() + self.db_path = target + self.vfs = SQLiteVFS(target, readonly=True) + payload = self._snapshot_payload() + payload["created"] = True + payload["created_name"] = package_name + return payload + + def _op_rename_package(self, name): + """改整包的名字(filesystem_metadata.name)。""" + name = (name or "").strip() + if not name: + raise ValueError("包名不能为空") + with self._writable() as vfs: + stats = vfs.get_stats() + # 这个 API 会连带写当前快照的统计,所以把库里的现值原样传回去,避免被清零 + vfs.update_vfs_metadata( + name, stats.get("total_files") or 0, stats.get("total_size") or 0 + ) + return self._snapshot_payload() + + def _op_close(self): + self._close_handles() + self.db_path = None + return {} + + def _close_handles(self): + if self.vfs is not None: + try: + self.vfs.close() + finally: + self.vfs = None + if self._raw_conn is not None: + try: + self._raw_conn.close() + finally: + self._raw_conn = None + self.snapshot_id = None + + def _op_refresh(self): + return self._snapshot_payload() + + def _op_entries(self, snapshot_id=None): + """单独取某个快照的条目(快照差异要比较的两版未必是当前那版)。""" + vfs = self._require_vfs() + return { + "snapshot_id": snapshot_id if snapshot_id is not None else self.snapshot_id, + "entries": vfs.all_entries(snapshot_id), + } + + def _op_select_snapshot(self, snapshot_id): + self._require_vfs() + self.vfs.use_snapshot(snapshot_id) # type: ignore + self.snapshot_id = self.vfs.snapshot_id # type: ignore + return self._snapshot_payload() + + def _snapshot_payload(self): + """界面所有视图共用的一份数据快照。 + + 一次取全(条目、blob、统计、快照、签名、引用计数),这样每个视图都从同一份 + 一致的数据渲染,不会出现"概览是新的、存储分析还是旧的"这种分裂。 + """ + vfs = self._require_vfs() + entries = vfs.all_entries() + blobs = vfs.list_blobs() + blobs_by_hash = {row["hash"]: row for row in blobs} + # get_stats() 内部会解析"当前快照"(None 表示最新),解析完 vfs.snapshot_id 才是 + # 真实值——不把它同步回来,后面按 snapshot_id 过滤的查询都会得到空结果。 + stats = vfs.get_stats() + self.snapshot_id = vfs.snapshot_id + signatures = self._signatures() + + summary = analysis.storage_summary( + entries, blobs, db_path=self.db_path + ) + try: + db_mtime = os.path.getmtime(self.db_path) # type: ignore + except OSError: + db_mtime = None + return { + "path": self.db_path, + "legacy": None, + "error": None, + "db_mtime": db_mtime, + "stats": stats, + "snapshot_id": self.snapshot_id, + "snapshot_name": stats.get("snapshot_name"), + "snapshots": vfs.list_snapshots(), + "entries": entries, + "blobs": blobs, + "blobs_by_hash": blobs_by_hash, + "summary": summary, + "algo": analysis.algo_distribution(blobs), + "top": analysis.top_files(entries, blobs_by_hash), + "duplicates": analysis.duplicate_groups(entries, blobs_by_hash, limit=200), + "refcounts": analysis.refcounts(vfs.conn), + "orphans": analysis.orphan_blobs(vfs.conn), + "signatures": signatures, + "db_file_size": summary["db_file_size"], + "snapshot_stats": analysis.snapshot_stats(vfs, self.snapshot_id), + } + + def _op_read_file(self, path, offset=0, size=None): + vfs = self._require_vfs() + if size is None: + size = PREVIEW_LIMIT + data = vfs.read_file(path, offset=offset, size=size) + return {"path": path, "offset": offset, "data": data, "size": size} + + def _op_file_detail(self, path): + vfs = self._require_vfs() + entry = vfs.get_file_info(path) + if entry is None: + raise FileNotFoundError(f"包内没有这个路径: {path}") + blob = vfs.get_blob_info(path) + content_hash = entry.get("content_hash") + other_paths = [] + refcount = 0 + if content_hash: + rows = vfs.conn.execute( # type: ignore + "SELECT path FROM files WHERE snapshot_id = ? AND content_hash = ? " + "ORDER BY path", + (self.snapshot_id, content_hash), + ).fetchall() + other_paths = [row["path"] for row in rows] + refcount = vfs.conn.execute( # type: ignore + "SELECT COUNT(*) FROM files WHERE content_hash = ?", (content_hash,) + ).fetchone()[0] + return { + "entry": entry, + "blob": blob, + "refcount": refcount, + "other_paths": other_paths, + "mime": guess_mime(path), + "cross_snapshot": refcount - len(other_paths), + } + + def _op_export_path(self, vfs_path, local_path): + vfs = self._require_vfs() + if vfs.is_dir(vfs_path): + vfs.export_directory(vfs_path, local_path) + return {"kind": "directory", "target": local_path} + vfs.export_file(vfs_path, local_path) + return {"kind": "file", "target": local_path} + + # ------------------------------------------------------------------ + # 包内编辑 + # ------------------------------------------------------------------ + # 这几个都是"直接改当前快照",走的是库的 mv_file / mv_dir / mk_dir / remove / add_file。 + # 引用计数是隐式的:删路径不等于删内容,只有没有任何快照再引用时才回收。 + + def _op_mkdir(self, path): + self._require_vfs() + target = (path or "").strip().strip("/") + if not target: + raise ValueError("目录名不能为空") + with self._writable() as vfs: + vfs.mk_dir(target) + vfs.recount() + payload = self._snapshot_payload() + payload["created_dir"] = target + return payload + + def _op_save_text(self, path, text, encoding="utf-8"): + """把编辑器里的文本写回包内文件。 + + 库没有"直接写字节"的 API(``add_file`` 从磁盘读),所以这里把文本按原编码 + 编回字节、写进临时文件,再走 ``add_file(replace=True)``——压缩、去重、sha256 + 的语义与打包完全一致,不用为 GUI 加第二套写入路径。 + """ + vfs = self._require_vfs() + target = (path or "").strip().strip("/") + if not target: + raise ValueError("没有指定要保存的路径") + entry = vfs.get_file_info(target) + if entry is None: + raise FileNotFoundError(f"包里没有这个文件: {target}") + if entry.get("is_directory"): + raise IsADirectoryError(f"{target} 是目录,不能当文本编辑") + if (entry.get("file_size") or 0) > EDIT_SIZE_LIMIT: + raise ValueError( + f"{target} 有 {entry['file_size']} 字节,超过文本编辑上限" + f"({EDIT_SIZE_LIMIT} 字节);请导出后用编辑器改,再用「添加文件」替换回来。" + ) + + try: + data = str(text or "").encode(encoding) + except UnicodeEncodeError as exc: + raise ValueError( + f"按 {encoding} 编码保存失败:{exc}。" + f"文件里有超出该编码的字符,可导出后转存为 UTF-8 再「添加文件」替换。" + ) from exc + except LookupError as exc: + raise ValueError(f"未知编码: {encoding}") from exc + + import hashlib + import tempfile + + handle_fd, temp = tempfile.mkstemp(prefix="svfs_edit_", suffix=".tmp") + try: + with os.fdopen(handle_fd, "wb") as handle: + handle.write(data) + with self._writable() as writable: + ok = writable.add_file(temp, target, replace=True) + writable.recount() + finally: + with contextlib.suppress(OSError): + os.remove(temp) + if not ok: + raise RuntimeError(f"保存 {target} 失败,具体原因看日志里的输出。") + + payload = self._snapshot_payload() + payload["saved"] = { + "path": target, + "size": len(data), + "hash": hashlib.sha256(data).hexdigest(), + "encoding": encoding, + } + return payload + + def _op_rename_path(self, source, dest): + self._require_vfs() + source = (source or "").strip().strip("/") + dest = (dest or "").strip().strip("/") + if not source or not dest: + raise ValueError("源路径与目标名都不能为空") + if source == dest: + return self._snapshot_payload() + with self._writable() as vfs: + if vfs.is_dir(source): + vfs.mv_dir(source, dest) + else: + vfs.mv_file(source, dest) + # 快照上的统计与摘要跟着刷新:否则改完之后校验会报「摘要不一致」 + vfs.recount() + payload = self._snapshot_payload() + payload["renamed"] = {"from": source, "to": dest} + return payload + + def _op_remove_paths(self, paths): + self._require_vfs() + targets = [item for item in (paths or []) if (item or "").strip().strip("/")] + if not targets: + raise ValueError("没有指定要删除的路径") + # 由深到浅:先删子项,免得删了父目录再删子路径时报"不存在" + targets.sort(key=lambda item: item.count("/"), reverse=True) + removed = [] + with self._writable() as vfs: + for path in targets: + vfs.remove(path) + removed.append(path) + vfs.recount() + payload = self._snapshot_payload() + payload["removed"] = removed + return payload + + def _op_add_paths(self, local_paths, dest_dir="", replace_existing=False): + """把本地文件/文件夹加进当前快照。 + + 目录会整棵加进去(与打包时同一套逻辑:目录建行、文件按内容寻址入 blobs), + 所以"往包里补一个新版本的前端产物"这种操作可以直接在这里做。 + """ + self._require_vfs() + sources = [item for item in (local_paths or []) if item] + if not sources: + raise ValueError("没有选择任何文件") + dest_dir = (dest_dir or "").strip().strip("/") + + added_files = [] + added_dirs = [] + failed = [] + conflicts = [] + + with self._writable() as vfs: + # 目标目录(以及它上面的层级)得先存在:add_file / mk_dir 都要求父目录在库里 + if dest_dir: + _ensure_dir(vfs, dest_dir, added_dirs) + + for local in sources: + local = os.path.abspath(local) + if not os.path.exists(local): + failed.append(local) + continue + + if os.path.isdir(local): + source_root = os.path.dirname(local) + for root, dirs, names in os.walk(local): + for name in dirs: + abs_path = os.path.join(root, name) + rel = _join(dest_dir, os.path.relpath(abs_path, source_root)) + if not _ensure_dir(vfs, rel, added_dirs): + failed.append(rel) + for name in names: + abs_path = os.path.join(root, name) + rel = _join(dest_dir, os.path.relpath(abs_path, source_root)) + if _exists_in_snapshot(vfs, rel): + conflicts.append(rel) + if not replace_existing: + continue + if vfs.add_file(abs_path, rel, replace=replace_existing): + added_files.append(rel) + else: + failed.append(rel) + else: + rel = _join(dest_dir, os.path.basename(local)) + if _exists_in_snapshot(vfs, rel): + conflicts.append(rel) + if not replace_existing: + continue + if vfs.add_file(local, rel, replace=replace_existing): + added_files.append(rel) + else: + failed.append(rel) + + vfs.recount() + + payload = self._snapshot_payload() + payload.update( + { + "added_files": added_files, + "added_dirs": added_dirs, + "failed": failed, + "conflicts": sorted(set(conflicts)), + "dest_dir": dest_dir, + "replaced": bool(replace_existing), + } + ) + return payload + + # ------------------------------------------------------------------ + # 完整性 / 签名 + # ------------------------------------------------------------------ + + def _op_verify(self, check_content=False): + vfs = self._require_vfs() + job = "校验内容" if check_content else "校验元数据" + self._tick(job, 0, 0, "读取库内数据") + report = vfs.verify(check_content=check_content) + return {"report": report, "check_content": check_content} + + def _op_sign(self, key, key_id=None, snapshot_id=None): + if not key: + raise ValueError("请填写签名密钥") + with self._writable() as vfs: + signature = vfs.sign(key, key_id=key_id or None, snapshot_id=snapshot_id) + return {"signature": signature, "key_id": key_id} + + def _op_verify_signature(self, key, snapshot_id=None, key_id=None): + vfs = self._require_vfs() + if not key: + raise ValueError("请填写用于验签的密钥") + return {"report": vfs.verify_signature(key, snapshot_id=snapshot_id, key_id=key_id)} + + def _signatures(self): + vfs = self._require_vfs() + return vfs.list_signatures() + + # ------------------------------------------------------------------ + # 快照维护 + # ------------------------------------------------------------------ + + def _op_create_snapshot(self, name=None, parent_id=None, copy_entries=False): + with self._writable() as vfs: + new_id = vfs.create_snapshot( + name=name or None, parent_id=parent_id, copy_entries=copy_entries + ) + self.vfs.use_snapshot(new_id) # type: ignore + self.snapshot_id = new_id + payload = self._snapshot_payload() + payload["created_snapshot"] = new_id + return payload + + def _op_delete_snapshot(self, snapshot_id, gc=True): + with self._writable() as vfs: # type: ignore + vfs.delete_snapshot(snapshot_id, gc=gc) + # 删掉的正好是当前快照,就退回到剩下的最新那个 + row = self.vfs.conn.execute( # type: ignore + "SELECT id FROM snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is not None: + self.vfs.use_snapshot(row["id"]) # type: ignore + else: + self.vfs.snapshot_id = None # type: ignore + self.snapshot_id = self.vfs.snapshot_id # type: ignore + return self._snapshot_payload() + + def _op_clear_snapshot(self, snapshot_id=None): + with self._writable() as vfs: + vfs.clear_snapshot(snapshot_id) + return self._snapshot_payload() + + def _op_gc(self): + vfs = self._require_vfs() + before = analysis.orphan_blobs(vfs.conn) + with self._writable() as writable: + writable.gc_blobs() + after = analysis.orphan_blobs(self.vfs.conn) # type: ignore + return { + "before": before, + "after": after, + "removed": before["count"] - after["count"], + } + + def _op_recount(self): + with self._writable() as vfs: + total_files, total_size = vfs.recount() + payload = self._snapshot_payload() + payload["recount"] = {"files": total_files, "size": total_size} + return payload + + # ------------------------------------------------------------------ + # 打包 / 解包 / 迁移 + # ------------------------------------------------------------------ + + def _op_pack( + self, + source, + target, + name="packed_filesystem", + compress=False, + algorithm="zlib", + exclude=None, + incremental=False, + rehash=False, + snapshot_name=None, + ): + if not os.path.isdir(source): + raise NotADirectoryError(f"源目录不存在: {source}") + os.makedirs(os.path.dirname(os.path.abspath(target)) or ".", exist_ok=True) + + existed = os.path.exists(target) + if snapshot_name and existed and not incremental: + # 全量打包第一步就是 clean()(清空整库),"在新版本里累加"这个诉求只有增量 + # 能满足;直接拒绝,别让人以为历史被保留着。 + raise ValueError( + "全量打包会清空整库,无法把结果写成一个新快照;" + "想保留历史请勾选「增量打包」。" + ) + + vfs = SQLiteVFS(target, compress=compress, compress_algorithm=algorithm) + try: + if snapshot_name and existed: + vfs.create_snapshot(snapshot_name, copy_entries=True) + packer = FolderPacker( + sqlite_vfs=vfs, exclude_patterns=exclude or [], fs_name=name + ) + ok = packer.pack_folder( + source, + incremental=incremental, + rehash=rehash, + progress=self._progress_callback("打包"), + ) + stats = packer.last_stats + excluded = packer.excluded_count + total_files = packer.total_files + total_size = packer.total_size + finally: + vfs.close() + + return { + "ok": ok, + "target": os.path.abspath(target), + "source": os.path.abspath(source), + "existed": existed, + "stats": stats, + "excluded": excluded, + "total_files": total_files, + "total_size": total_size, + "target_size": os.path.getsize(target) if os.path.exists(target) else None, + "incremental": incremental, + } + + def _op_unpack(self, output, vfs_path=None, ignore_timestamps=False): + vfs = self._require_vfs() + self._tick("解包", 0, 0, "准备导出") + unpacker = FolderUnpacker(vfs) + ok, message = unpacker.check_database() + if not ok: + raise VFSFormatError(message) + if vfs_path: + if vfs.is_dir(vfs_path): + count = unpacker.unpack_directory( + vfs_path, output, ignore_timestamps=ignore_timestamps + ) + kind = "directory" + else: + unpacker.unpack_file(vfs_path, output) + count = 1 + kind = "file" + else: + count = unpacker.unpack_filesystem( + output, ignore_timestamps=ignore_timestamps + ) + kind = "filesystem" + + # 导出路径上的异常会被库吞掉并返回 0(它把错误 print 出来就返回了), + # 所以把"0 个条目"当可疑结果报出来,而不是显示一个假的成功。 + expected = vfs.get_stats().get("total_files") or 0 + if kind != "file" and not count and expected: + raise RuntimeError( + f"导出到 {output} 时产出 0 个条目,但当前快照里有 {expected} 个文件——" + f"很可能中途出错了,具体原因看日志里的输出。" + ) + return { + "kind": kind, + "output": os.path.abspath(output), + "vfs_path": vfs_path, + "count": count, + } + + def _op_migrate(self, path, backup=True, output=None, snapshot_name=None): + source = os.path.abspath(path) + version = migration.detect_schema_version(source) + self._tick("迁移", 0, 0, f"读取 schema v{version}") + result = migration.migrate( + source, + backup=None if backup else False, + snapshot_name=snapshot_name or None, + progress=self._progress_callback("迁移"), + output=output or None, + ) + payload = dict(result or {}) + payload["source"] = source + payload["source_version"] = version + payload["opened"] = output or source + return payload + + def _op_detect_version(self, path): + return { + "path": path, + "version": migration.detect_schema_version(path), + "exists": os.path.exists(path), + } + + # ------------------------------------------------------------------ + # 发布 / 拉取 + # ------------------------------------------------------------------ + + def _op_publish(self, out_dir, snapshot_id=None, shard=2, overwrite=False, objects=True): + vfs = self._require_vfs() + if objects: + result = publish.export_objects( + self.db_path, + out_dir, + snapshot_id=snapshot_id, + shard=shard, + overwrite=overwrite, + progress=self._progress_callback("发布"), + ) + else: + # 只更新清单:与 CLI 的 --no-objects 同一路径(对象已经在存储上了) + manifest = publish.build_manifest(vfs, snapshot_id=snapshot_id, shard=shard) + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, publish.MANIFEST_NAME) + text = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + result = { + "out_dir": os.path.abspath(out_dir), + "manifest": path, + "manifest_bytes": len(text.encode("utf-8")), + "objects": 0, + "objects_bytes": 0, + "files": manifest["stats"]["files"], + "directories": manifest["stats"]["directories"], + "snapshot_id": manifest["snapshot"]["id"], + "digest": manifest["snapshot"]["digest"], + } + + manifest_path = result.get("manifest") + data = None + entries = [] + if manifest_path and os.path.exists(manifest_path): + data = publish.load_manifest(manifest_path) + for path, info in sorted(data.get("entries", {}).items()): + entries.append( + { + "path": path, + "hash": info.get("hash"), + "size": info.get("size"), + "dir": bool(info.get("dir")), + "mime": info.get("mime"), + } + ) + payload = dict(result) + payload["entries"] = entries + payload["manifest_data"] = data + payload["out_dir"] = os.path.abspath(out_dir) + return payload + + def _op_fetch(self, manifest, out_dir, workers=8, verify=True, link=False, cache_dir=None, paths=None): + data = publish.load_manifest(manifest) + # 清单是本地文件时,对象默认就在它旁边(发布时 blobs/ 与 manifest.json 同目录); + # 是 http(s) 清单则用它的 URL 前缀当 base_url。与 CLI 的 vfsfetch 同一套推导。 + base_url = None + local_root = None + if "://" in manifest: + base_url = manifest.rsplit("/", 1)[0] + else: + local_root = os.path.dirname(os.path.abspath(manifest)) + fetcher = publish.ObjectFetcher( + data, + base_url=base_url, + local_root=local_root, + cache_dir=cache_dir or None, + workers=int(workers or 8), + verify=bool(verify), + link=bool(link), + progress=self._progress_callback("拉取"), + ) + self._tick("拉取", 0, 0, "开始下载对象") + stats = fetcher.fetch(out_dir, paths=paths or None) + payload = { + "out_dir": os.path.abspath(out_dir), + "manifest": manifest, + "base_url": base_url, + "local_root": local_root, + } + payload.update(stats.as_dict()) + return payload + + # ------------------------------------------------------------------ + # 本地对比 / SQL / 内部结构 + # ------------------------------------------------------------------ + + def _op_compare_local(self, local_root, skip_dirs=None): + vfs = self._require_vfs() + entries = vfs.all_entries() + job = "对比本地目录" + self._tick(job, 0, 0, "扫描本地文件") + return { + "result": analysis.local_vs_snapshot_diff( + local_root, + entries, + read_content=self._read_for_compare, + progress=self._progress_callback(job), + ), + "local_root": os.path.abspath(local_root), + } + + def _read_for_compare(self, path): + """对比时按需读包内内容;读不出来(比如库被裁剪)交给上层归类为"内容不同"。""" + try: + return self.vfs.read_file(path) # type: ignore + except Exception: + return None + + def _op_run_sql(self, sql): + conn = self._sql_conn() + ok, reason = analysis.check_readonly_sql(sql) + if not ok: + raise ValueError(reason) + started = time.monotonic() + columns, rows, truncated = analysis.run_readonly_query(conn, sql) + return { + "columns": columns, + "rows": rows, + "truncated": truncated, + "elapsed": time.monotonic() - started, + } + + def _op_internals(self): + conn = self._sql_conn() + info = analysis.sqlite_internals(conn, self.db_path) + header = info.get("header") or {} + if not header.get("valid"): + info["ok"] = False + info["error"] = header.get("error", "无法解析文件头") + return info + + def _sql_conn(self): + """给 SQL 控制台与内部结构用的只读连接。 + + 正常包用 SQLiteVFS 的连接(已经校验过 schema),旧格式/异常文件退化成裸连接, + 这样"打不开的包"至少还能看内部结构。 + """ + if self.vfs is not None: + return self.vfs.conn + if self._raw_conn is not None: + return self._raw_conn + raise RuntimeError("还没有打开任何包") + + def _op_manifest_entries(self, manifest): + data = publish.load_manifest(manifest) + entries = [ + { + "path": path, + "hash": info.get("hash"), + "size": info.get("size"), + "dir": bool(info.get("dir")), + "mime": info.get("mime"), + } + for path, info in sorted(data.get("entries", {}).items()) + ] + return {"manifest": data, "entries": entries} + + # ------------------------------------------------------------------ + + @contextlib.contextmanager + def _writable(self): + """临时开一个可写句柄:写完就关,不长期占着写锁。""" + self._require_vfs() + vfs = SQLiteVFS(self.db_path, snapshot_id=self.snapshot_id) + try: + yield vfs + finally: + vfs.close() + + def _require_vfs(self): + if self.vfs is None: + if self.legacy_version is not None: + raise VFSFormatError( + f"这是个 schema v{self.legacy_version} 的旧格式包,当前版本只能" + f"以只读方式查看它的 SQL 与内部结构。想看内容或维护它," + f"请先在「工具箱 → 迁移」里把它升到 v3。" + ) + raise FileNotFoundError("还没有打开任何包") + return self.vfs + + +#: 会改动会话状态或耗时较长的操作:同一时刻只允许一个。 +#: 读操作不在此列——"正在打包"的时候照样能翻文件树,两者用的是不同连接,本来就安全。 +EXCLUSIVE_OPS = frozenset( + { + "open", + "close", + "refresh", + "select_snapshot", + "new_package", + "rename_package", + "pack", + "unpack", + "migrate", + "publish", + "fetch", + "compare_local", + "verify", + "gc", + "recount", + "sign", + "verify_signature", + "create_snapshot", + "delete_snapshot", + "clear_snapshot", + "export_path", + # 包内编辑 + "mkdir", + "rename_path", + "remove_paths", + "add_paths", + "save_text", + } +) + + +class StudioClient(QObject): + """主线程侧的门面:提交请求、把结果路由回回调。 + + 两条车道: + + * **独占车道**(``EXCLUSIVE_OPS``):写操作与会替换会话状态的操作,同一时刻只跑一个, + 避免两个写操作同时开可写句柄把库锁在一起; + * **读车道**:文件详情、预览、SQL、内部结构这些短查询随时可发,用的是只读句柄, + 所以"正在打包"的时候照样能翻文件树。 + """ + + _dispatch = Signal(int, str, dict) + + #: 转发 worker 的信号,方便界面直接连 + progress = Signal(str, int, int, str) + log = Signal(str) + + #: 有独占任务在跑/跑完了:是否忙、当前任务名 + busy_changed = Signal(bool, str) + + def __init__(self, parent=None): + super().__init__(parent) + self._counter = 0 + self._pending = {} + self._exclusive_id = None + self._busy_label = "" + self.service = VfsService() + + def attach_to(self, thread): + """把服务对象挪进工作线程,并把请求信号接上。""" + self.service.moveToThread(thread) + self._dispatch.connect(self.service.dispatch, Qt.ConnectionType.QueuedConnection) + self.service.progress.connect(self.progress) + self.service.log.connect(self.log) + self.service.finished.connect(self._on_finished) + self.service.failed.connect(self._on_failed) + thread.start() + + # ------------------------------------------------------------------ + + @property + def busy(self): + return self._exclusive_id is not None + + @property + def busy_op(self): + return self._busy_label + + def submit(self, op, payload=None, on_done=None, on_fail=None, label=None): + """提交一个操作。返回 req_id(拿不到就返回 None,并在 on_fail 里说明原因)。""" + exclusive = op in EXCLUSIVE_OPS + if exclusive and self._exclusive_id is not None: + # 不排队:直接拒绝,避免攒出一串看不懂的后续动作 + if on_fail is not None: + on_fail("正忙", f"当前还在执行「{self._busy_label}」,请等它结束。") + return None + self._counter += 1 + req_id = self._counter + self._pending[req_id] = (op, on_done, on_fail, exclusive) + if exclusive: + self._exclusive_id = req_id + self._busy_label = label or op + self.busy_changed.emit(True, self._busy_label) + self._dispatch.emit(req_id, op, dict(payload or {})) + return req_id + + def cancel(self): + """请求中断当前任务(只在有进度回调的任务上生效)。""" + if self.busy: + self.service.cancel() + + def submit_silent(self, op, payload=None): + """不需要结果、也不该影响"忙"状态的操作(例如关包时释放句柄)。""" + self._counter += 1 + self._pending[self._counter] = (op, None, None, False) + self._dispatch.emit(self._counter, op, dict(payload or {})) + return self._counter + + def call_blocking(self, op, payload=None): + """在工作线程之外同步取一个结果——**仅供测试**使用。 + + 正常路径永远走 submit();测试里需要确定性的顺序,所以这里直接调服务的方法。 + """ + handler = getattr(self.service, f"_op_{op}") + with capture_output(lambda _line: None): + return handler(**dict(payload or {})) + + # ------------------------------------------------------------------ + + def _on_finished(self, req_id, op, payload): + entry = self._pending.pop(req_id, None) + self._release(req_id) + if entry is None: + return + _op, on_done, _on_fail, _exclusive = entry + if on_done is not None: + on_done(payload) + + def _on_failed(self, req_id, op, title, detail): + entry = self._pending.pop(req_id, None) + self._release(req_id) + if entry is None: + return + _op, _on_done, on_fail, _exclusive = entry + if on_fail is not None: + on_fail(title, detail) + + def _release(self, req_id): + if req_id == self._exclusive_id: + self._exclusive_id = None + self._busy_label = "" + self.busy_changed.emit(False, "") diff --git a/svfs-plugins-root/com.example.vue-demo/blobs/12/1252ac1b43e4d94d0a608eed0c76449112bee5f79613b66e6ec8c21b981a4dae b/svfs-plugins-root/com.example.vue-demo/blobs/12/1252ac1b43e4d94d0a608eed0c76449112bee5f79613b66e6ec8c21b981a4dae new file mode 100644 index 0000000..3318757 --- /dev/null +++ b/svfs-plugins-root/com.example.vue-demo/blobs/12/1252ac1b43e4d94d0a608eed0c76449112bee5f79613b66e6ec8c21b981a4dae @@ -0,0 +1 @@ +dl[data-v-d22dfe76]{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:0}dt[data-v-d22dfe76]{color:var(--muted)}dd[data-v-d22dfe76]{margin:0;font-family:Consolas,monospace;font-size:12px}:root{--bg: #16181d;--panel: #1c1f26;--border: #2b3038;--text: #e4e7ec;--muted: #9aa3b2;--accent: #5b9dff}*{box-sizing:border-box}body{margin:0;padding:24px;background:var(--bg);color:var(--text);font-family:Segoe UI,Microsoft YaHei,system-ui,sans-serif}main{max-width:680px;margin:0 auto}h1{font-size:20px;margin:0 0 14px}nav{display:flex;gap:8px;margin-bottom:16px}nav button{background:var(--panel);color:var(--muted);border:1px solid var(--border);border-radius:6px;padding:6px 14px;cursor:pointer}nav button.active{color:var(--accent);border-color:var(--accent)}.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:12px}.panel h2{font-size:13px;color:var(--muted);margin:0 0 10px}button.act{background:#22262f;color:var(--text);border:1px solid #3a4150;border-radius:6px;padding:6px 14px;cursor:pointer;margin-right:8px}button.act:hover{border-color:var(--accent);color:var(--accent)}input,textarea,select{background:#14171c;color:var(--text);border:1px solid #3a4150;border-radius:6px;padding:6px 8px;font-size:13px}pre{background:#14171c;border:1px solid var(--border);border-radius:6px;padding:8px 10px;font-size:12px;white-space:pre-wrap;word-break:break-all;min-height:18px}.muted{color:var(--muted);font-size:12px} diff --git a/svfs-plugins-root/com.example.vue-demo/blobs/43/43fa3c1fed2269b7afa1872274a002ef52e9e4c97eb6c44eecb575928d8e7710 b/svfs-plugins-root/com.example.vue-demo/blobs/43/43fa3c1fed2269b7afa1872274a002ef52e9e4c97eb6c44eecb575928d8e7710 new file mode 100644 index 0000000..161f6ed --- /dev/null +++ b/svfs-plugins-root/com.example.vue-demo/blobs/43/43fa3c1fed2269b7afa1872274a002ef52e9e4c97eb6c44eecb575928d8e7710 @@ -0,0 +1,9 @@ +{ + "id": "com.example.vue-demo", + "name": "Vue 模板插件", + "version": "0.1.0", + "api_version": 4, + "entry": "index.html", + "permissions": ["storage", "notify", "title", "net", "dialog"], + "description": "用 Vue 3 + Vite 开发的 SVFS 插件模板,演示全部桥能力" +} diff --git a/svfs-plugins-root/com.example.vue-demo/blobs/77/770e86ec29471f0553cb3bef36063ebcc6fcf2fc7902ccd5e5d16665a91425ad b/svfs-plugins-root/com.example.vue-demo/blobs/77/770e86ec29471f0553cb3bef36063ebcc6fcf2fc7902ccd5e5d16665a91425ad new file mode 100644 index 0000000..86c6371 --- /dev/null +++ b/svfs-plugins-root/com.example.vue-demo/blobs/77/770e86ec29471f0553cb3bef36063ebcc6fcf2fc7902ccd5e5d16665a91425ad @@ -0,0 +1,22 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function s(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(r){if(r.ep)return;r.ep=!0;const i=s(r);fetch(r.href,i)}})();/** +* @vue/shared v3.5.42 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Gs(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const K={},ut=[],Ie=()=>{},zn=()=>!1,cs=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),us=e=>e.startsWith("onUpdate:"),te=Object.assign,zs=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},li=Object.prototype.hasOwnProperty,N=(e,t)=>li.call(e,t),A=Array.isArray,Je=e=>Nt(e)==="[object Map]",it=e=>Nt(e)==="[object Set]",yn=e=>Nt(e)==="[object Date]",R=e=>typeof e=="function",k=e=>typeof e=="string",Re=e=>typeof e=="symbol",L=e=>e!==null&&typeof e=="object",Yn=e=>(L(e)||R(e))&&R(e.then)&&R(e.catch),Xn=Object.prototype.toString,Nt=e=>Xn.call(e),fi=e=>Nt(e).slice(8,-1),Qn=e=>Nt(e)==="[object Object]",Ys=e=>k(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,wt=Gs(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),as=e=>{const t=Object.create(null);return s=>t[s]||(t[s]=e(s))},ci=/-\w/g,ge=as(e=>e.replace(ci,t=>t.slice(1).toUpperCase())),ui=/\B([A-Z])/g,lt=as(e=>e.replace(ui,"-$1").toLowerCase()),Zn=as(e=>e.charAt(0).toUpperCase()+e.slice(1)),Cs=as(e=>e?`on${Zn(e)}`:""),Pe=(e,t)=>!Object.is(e,t),Yt=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},ds=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let bn;const hs=()=>bn||(bn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Xs(e){if(A(e)){const t={};for(let s=0;s{if(s){const n=s.split(di);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function ps(e){let t="";if(k(e))t=e;else if(A(e))for(let s=0;ske(s,t))}const sr=e=>!!(e&&e.__v_isRef===!0),Ve=e=>k(e)?e:e==null?"":A(e)||L(e)&&(e.toString===Xn||!R(e.toString))?sr(e)?Ve(e.value):JSON.stringify(e,nr,2):String(e),nr=(e,t)=>sr(t)?nr(e,t.value):Je(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,r],i)=>(s[Ts(n,i)+" =>"]=r,s),{})}:it(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ts(s))}:Re(t)?Ts(t):L(t)&&!A(t)&&!Qn(t)?String(t):t,Ts=(e,t="")=>{var s;return Re(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/** +* @vue/reactivity v3.5.42 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Q;class bi{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Q&&(Q.active?(this.parent=Q,this.index=(Q.scopes||(Q.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes){const n=this.scopes.slice();for(t=0,s=n.length;t0&&--this._on===0){if(Q===this)Q=this.prevScope;else{let t=Q;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s0)return;if(Tt){let t=Tt;for(Tt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ct;){let t=Ct;for(Ct=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function lr(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function fr(e){let t,s=e.depsTail,n=s;for(;n;){const r=n.prevDep;n.version===-1?(n===s&&(s=r),en(n),xi(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=r}e.deps=t,e.depsTail=s}function Hs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(cr(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function cr(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Mt)||(e.globalVersion=Mt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Hs(e))))return;e.flags|=2;const t=e.dep,s=W,n=me;W=e,me=!0;try{lr(e);const r=e.fn(e._value);(t.version===0||Pe(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{W=s,me=n,fr(e),e.flags&=-3}}function en(e,t=!1){const{dep:s,prevSub:n,nextSub:r}=e;if(n&&(n.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)en(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function xi(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let me=!0;const ur=[];function je(){ur.push(me),me=!1}function Le(){const e=ur.pop();me=e===void 0?!0:e}function xn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=W;W=void 0;try{t()}finally{W=s}}}let Mt=0;class Si{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class tn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!W||!me||W===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==W)s=this.activeLink=new Si(W,this),W.deps?(s.prevDep=W.depsTail,W.depsTail.nextDep=s,W.depsTail=s):W.deps=W.depsTail=s,ar(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=W.depsTail,s.nextDep=void 0,W.depsTail.nextDep=s,W.depsTail=s,W.deps===s&&(W.deps=n)}return s}trigger(t){this.version++,Mt++,this.notify(t)}notify(t){Qs();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Zs()}}}function ar(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)ar(n)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const Vs=new WeakMap,st=Symbol(""),js=Symbol(""),It=Symbol("");function Z(e,t,s){if(me&&W){let n=Vs.get(e);n||Vs.set(e,n=new Map);let r=n.get(s);r||(n.set(s,r=new tn),r.map=n,r.key=s),r.track()}}function Ne(e,t,s,n,r,i){const o=Vs.get(e);if(!o){Mt++;return}const l=f=>{f&&f.trigger()};if(Qs(),t==="clear")o.forEach(l);else{const f=A(e),d=f&&Ys(s);if(f&&s==="length"){const a=Number(n);o.forEach((p,w)=>{(w==="length"||w===It||!Re(w)&&w>=a)&&l(p)})}else switch((s!==void 0||o.has(void 0))&&l(o.get(s)),d&&l(o.get(It)),t){case"add":f?d&&l(o.get("length")):(l(o.get(st)),Je(e)&&l(o.get(js)));break;case"delete":f||(l(o.get(st)),Je(e)&&l(o.get(js)));break;case"set":Je(e)&&l(o.get(st));break}}Zs()}function ft(e){const t=D(e);return t===e?t:(Z(t,"iterate",It),he(e)?t:t.map(_e))}function gs(e){return Z(e=D(e),"iterate",It),e}function Oe(e,t){return Ue(e)?ht(nt(e)?_e(t):t):_e(t)}const wi={__proto__:null,[Symbol.iterator](){return Os(this,Symbol.iterator,e=>Oe(this,e))},concat(...e){return ft(this).concat(...e.map(t=>A(t)?ft(t):t))},entries(){return Os(this,"entries",e=>(e[1]=Oe(this,e[1]),e))},every(e,t){return $e(this,"every",e,t,void 0,arguments)},filter(e,t){return $e(this,"filter",e,t,s=>s.map(n=>Oe(this,n)),arguments)},find(e,t){return $e(this,"find",e,t,s=>Oe(this,s),arguments)},findIndex(e,t){return $e(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return $e(this,"findLast",e,t,s=>Oe(this,s),arguments)},findLastIndex(e,t){return $e(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return $e(this,"forEach",e,t,void 0,arguments)},includes(...e){return As(this,"includes",e)},indexOf(...e){return As(this,"indexOf",e)},join(e){return ft(this).join(e)},lastIndexOf(...e){return As(this,"lastIndexOf",e)},map(e,t){return $e(this,"map",e,t,void 0,arguments)},pop(){return bt(this,"pop")},push(...e){return bt(this,"push",e)},reduce(e,...t){return Sn(this,"reduce",e,t)},reduceRight(e,...t){return Sn(this,"reduceRight",e,t)},shift(){return bt(this,"shift")},some(e,t){return $e(this,"some",e,t,void 0,arguments)},splice(...e){return bt(this,"splice",e)},toReversed(){return ft(this).toReversed()},toSorted(e){return ft(this).toSorted(e)},toSpliced(...e){return ft(this).toSpliced(...e)},unshift(...e){return bt(this,"unshift",e)},values(){return Os(this,"values",e=>Oe(this,e))}};function Os(e,t,s){const n=gs(e),r=n[t]();return n!==e&&!he(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=s(i.value)),i}),r}const Ci=Array.prototype;function $e(e,t,s,n,r,i){const o=gs(e),l=o!==e&&!he(e),f=o[t];if(f!==Ci[t]){const p=f.apply(e,i);return l?_e(p):p}let d=s;o!==e&&(l?d=function(p,w){return s.call(this,Oe(e,p),w,e)}:s.length>2&&(d=function(p,w){return s.call(this,p,w,e)}));const a=f.call(o,d,n);return l&&r?r(a):a}function Sn(e,t,s,n){const r=gs(e),i=r!==e&&!he(e);let o=s,l=!1;r!==e&&(i?(l=n.length===0,o=function(d,a,p){return l&&(l=!1,d=Oe(e,d)),s.call(this,d,Oe(e,a),p,e)}):s.length>3&&(o=function(d,a,p){return s.call(this,d,a,p,e)}));const f=r[t](o,...n);return l?Oe(e,f):f}function As(e,t,s){const n=D(e);Z(n,"iterate",It);const r=n[t](...s);return(r===-1||r===!1)&&on(s[0])?(s[0]=D(s[0]),n[t](...s)):r}function bt(e,t,s=[]){je(),Qs();const n=D(e)[t].apply(e,s);return Zs(),Le(),n}const Ti=Gs("__proto__,__v_isRef,__isVue"),dr=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Re));function Ei(e){Re(e)||(e=String(e));const t=D(this);return Z(t,"has",e),t.hasOwnProperty(e)}class hr{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,n){if(s==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!r;if(s==="__v_isReadonly")return r;if(s==="__v_isShallow")return i;if(s==="__v_raw")return n===(r?i?Ni:_r:i?mr:gr).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const o=A(t);if(!r){let f;if(o&&(f=wi[s]))return f;if(s==="hasOwnProperty")return Ei}const l=Reflect.get(t,s,ee(t)?t:n);if((Re(s)?dr.has(s):Ti(s))||(r||Z(t,"get",s),i))return l;if(ee(l)){const f=o&&Ys(s)?l:l.value;return r&&L(f)?Us(f):f}return L(l)?r?Us(l):nn(l):l}}class pr extends hr{constructor(t=!1){super(!1,t)}set(t,s,n,r){let i=t[s];const o=A(t)&&Ys(s);if(!this._isShallow){const d=Ue(i);if(!he(n)&&!Ue(n)&&(i=D(i),n=D(n)),!o&&ee(i)&&!ee(n))return d||(i.value=n),!0}const l=o?Number(s)e,Wt=e=>Reflect.getPrototypeOf(e);function Ii(e,t,s){return function(...n){const r=this.__v_raw,i=D(r),o=Je(i),l=e==="entries"||e===Symbol.iterator&&o,f=e==="keys"&&o,d=r[e](...n),a=s?Ls:t?ht:_e;return!t&&Z(i,"iterate",f?js:st),te(Object.create(d),{next(){const{value:p,done:w}=d.next();return w?{value:p,done:w}:{value:l?[a(p[0]),a(p[1])]:a(p),done:w}}})}}function Jt(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Ri(e,t){const s={get(r){const i=this.__v_raw,o=D(i),l=D(r);e||(Pe(r,l)&&Z(o,"get",r),Z(o,"get",l));const{has:f}=Wt(o),d=t?Ls:e?ht:_e;if(f.call(o,r))return d(i.get(r));if(f.call(o,l))return d(i.get(l));i!==o&&i.get(r)},get size(){const r=this.__v_raw;return!e&&Z(D(r),"iterate",st),r.size},has(r){const i=this.__v_raw,o=D(i),l=D(r);return e||(Pe(r,l)&&Z(o,"has",r),Z(o,"has",l)),r===l?i.has(r):i.has(r)||i.has(l)},forEach(r,i){const o=this,l=o.__v_raw,f=D(l),d=t?Ls:e?ht:_e;return!e&&Z(f,"iterate",st),l.forEach((a,p)=>r.call(i,d(a),d(p),o))}};return te(s,e?{add:Jt("add"),set:Jt("set"),delete:Jt("delete"),clear:Jt("clear")}:{add(r){const i=D(this),o=Wt(i),l=D(r),f=!t&&!he(r)&&!Ue(r)?l:r;return o.has.call(i,f)||Pe(r,f)&&o.has.call(i,r)||Pe(l,f)&&o.has.call(i,l)||(i.add(f),Ne(i,"add",f,f)),this},set(r,i){!t&&!he(i)&&!Ue(i)&&(i=D(i));const o=D(this),{has:l,get:f}=Wt(o);let d=l.call(o,r);d||(r=D(r),d=l.call(o,r));const a=f.call(o,r);return o.set(r,i),d?Pe(i,a)&&Ne(o,"set",r,i):Ne(o,"add",r,i),this},delete(r){const i=D(this),{has:o,get:l}=Wt(i);let f=o.call(i,r);f||(r=D(r),f=o.call(i,r)),l&&l.call(i,r);const d=i.delete(r);return f&&Ne(i,"delete",r,void 0),d},clear(){const r=D(this),i=r.size!==0,o=r.clear();return i&&Ne(r,"clear",void 0,void 0),o}}),["keys","values","entries",Symbol.iterator].forEach(r=>{s[r]=Ii(r,e,t)}),s}function sn(e,t){const s=Ri(e,t);return(n,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?n:Reflect.get(N(s,r)&&r in n?s:n,r,i)}const $i={get:sn(!1,!1)},Fi={get:sn(!1,!0)},Di={get:sn(!0,!1)};const gr=new WeakMap,mr=new WeakMap,_r=new WeakMap,Ni=new WeakMap;function Hi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function nn(e){return Ue(e)?e:rn(e,!1,Ai,$i,gr)}function Vi(e){return rn(e,!1,Mi,Fi,mr)}function Us(e){return rn(e,!0,Pi,Di,_r)}function rn(e,t,s,n,r){if(!L(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=r.get(e);if(i)return i;const o=Hi(fi(e));if(o===0)return e;const l=new Proxy(e,o===2?n:s);return r.set(e,l),l}function nt(e){return Ue(e)?nt(e.__v_raw):!!(e&&e.__v_isReactive)}function Ue(e){return!!(e&&e.__v_isReadonly)}function he(e){return!!(e&&e.__v_isShallow)}function on(e){return e?!!e.__v_raw:!1}function D(e){const t=e&&e.__v_raw;return t?D(t):e}function ji(e){return!N(e,"__v_skip")&&Object.isExtensible(e)&&er(e,"__v_skip",!0),e}const _e=e=>L(e)?nn(e):e,ht=e=>L(e)?Us(e):e;function ee(e){return e?e.__v_isRef===!0:!1}function ce(e){return Li(e,!1)}function Li(e,t){return ee(e)?e:new Ui(e,t)}class Ui{constructor(t,s){this.dep=new tn,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:D(t),this._value=s?t:_e(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,n=this.__v_isShallow||he(t)||Ue(t);t=n?t:D(t),Pe(t,s)&&(this._rawValue=t,this._value=n?t:_e(t),this.dep.trigger())}}function Ks(e){return ee(e)?e.value:e}const Ki={get:(e,t,s)=>t==="__v_raw"?e:Ks(Reflect.get(e,t,s)),set:(e,t,s,n)=>{const r=e[t];return ee(r)&&!ee(s)?(r.value=s,!0):Reflect.set(e,t,s,n)}};function yr(e){return nt(e)?e:new Proxy(e,Ki)}class Bi{constructor(t,s,n){this.fn=t,this.setter=s,this._value=void 0,this.dep=new tn(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Mt-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&W!==this)return or(this,!0),!0}get value(){const t=this.dep.track();return cr(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Wi(e,t,s=!1){let n,r;return R(e)?n=e:(n=e.get,r=e.set),new Bi(n,r,s)}const qt={},Zt=new WeakMap;let Ze;function Ji(e,t=!1,s=Ze){if(s){let n=Zt.get(s);n||Zt.set(s,n=[]),n.push(e)}}function qi(e,t,s=K){const{immediate:n,deep:r,once:i,scheduler:o,augmentJob:l,call:f}=s,d=O=>r?O:he(O)||r===!1||r===0?He(O,1):He(O);let a,p,w,C,H=!1,M=!1;if(ee(e)?(p=()=>e.value,H=he(e)):nt(e)?(p=()=>d(e),H=!0):A(e)?(M=!0,H=e.some(O=>nt(O)||he(O)),p=()=>e.map(O=>{if(ee(O))return O.value;if(nt(O))return d(O);if(R(O))return f?f(O,2):O()})):R(e)?t?p=f?()=>f(e,2):e:p=()=>{if(w){je();try{w()}finally{Le()}}const O=Ze;Ze=a;try{return f?f(e,3,[C]):e(C)}finally{Ze=O}}:p=Ie,t&&r){const O=p,Y=r===!0?1/0:r;p=()=>He(O(),Y)}const G=vi(),J=()=>{a.stop(),G&&G.active&&zs(G.effects,a)};if(i&&t){const O=t;t=(...Y)=>{const be=O(...Y);return J(),be}}let F=M?new Array(e.length).fill(qt):qt;const V=O=>{if(!(!(a.flags&1)||!a.dirty&&!O))if(t){const Y=a.run();if(O||r||H||(M?Y.some((be,ve)=>Pe(be,F[ve])):Pe(Y,F))){w&&w();const be=Ze;Ze=a;try{const ve=[Y,F===qt?void 0:M&&F[0]===qt?[]:F,C];F=Y,f?f(t,3,ve):t(...ve)}finally{Ze=be}}}else a.run()};return l&&l(V),a=new rr(p),a.scheduler=o?()=>o(V,!1):V,C=O=>Ji(O,!1,a),w=a.onStop=()=>{const O=Zt.get(a);if(O){if(f)f(O,4);else for(const Y of O)Y();Zt.delete(a)}},t?n?V(!0):F=a.run():o?o(V.bind(null,!0),!0):a.run(),J.pause=a.pause.bind(a),J.resume=a.resume.bind(a),J.stop=J,J}function He(e,t=1/0,s){if(t<=0||!L(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,ee(e))He(e.value,t,s);else if(A(e))for(let n=0;n{He(n,t,s)});else if(Qn(e)){for(const n in e)He(e[n],t,s);for(const n of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,n)&&He(e[n],t,s)}return e}/** +* @vue/runtime-core v3.5.42 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Ht(e,t,s,n){try{return n?e(...n):e()}catch(r){ms(r,t,s)}}function ye(e,t,s,n){if(R(e)){const r=Ht(e,t,s,n);return r&&Yn(r)&&r.catch(i=>{ms(i,t,s)}),r}if(A(e)){const r=[];for(let i=0;i>>1,r=re[n],i=Rt(r);i=Rt(s)?re.push(e):re.splice(Gi(t),0,e),e.flags|=1,xr()}}function xr(){es||(es=br.then(wr))}function zi(e){if(!A(e))We&&e.id===-1?We.splice(ct+1,0,e):e.flags&1||(at.push(e),e.flags|=1);else for(let t=0;tRt(s)-Rt(n));if(at.length=0,We){for(let s=0;se.id==null?e.flags&2?-1:1/0:e.id;function wr(e){try{for(Ee=0;Ee{n._d&&Fn(-1);const i=ts(t),o=rt.length;let l;try{l=e(...r)}finally{for(let f=rt.length;f>o;f--)Yr();ts(i),n._d&&Fn(1)}return l};return n._n=!0,n._c=!0,n._d=!0,n}function Et(e,t){if(de===null)return e;const s=xs(de),n=e.dirs||(e.dirs=[]);for(let r=0;r1)return s&&R(t)?t.call(n&&n.proxy):t}}const Qi=Symbol.for("v-scx"),Zi=()=>Xt(Qi);function Ps(e,t,s){return Tr(e,t,s)}function Tr(e,t,s=K){const{immediate:n,deep:r,flush:i,once:o}=s,l=te({},s),f=t&&n||!t&&i!=="post";let d;if(Dt){if(i==="sync"){const C=Zi();d=C.__watcherHandles||(C.__watcherHandles=[])}else if(!f){const C=()=>{};return C.stop=Ie,C.resume=Ie,C.pause=Ie,C}}const a=oe;l.call=(C,H,M)=>ye(C,a,H,M);let p=!1;i==="post"?l.scheduler=C=>{le(C,a&&a.suspense)}:i!=="sync"&&(p=!0,l.scheduler=(C,H)=>{H?C():ln(C)}),l.augmentJob=C=>{t&&(C.flags|=4),p&&(C.flags|=2,a&&(C.id=a.uid,C.i=a))};const w=qi(e,t,l);return Dt&&(d?d.push(w):f&&w()),w}function eo(e,t,s){const n=this.proxy,r=k(e)?e.includes(".")?Er(n,e):()=>n[e]:e.bind(n,n);let i;R(t)?i=t:(i=t.handler,s=t);const o=Vt(this),l=Tr(r,i.bind(n),s);return o(),l}function Er(e,t){const s=t.split(".");return()=>{let n=e;for(let r=0;re.__isTeleport,Ms=Symbol("_leaveCb");function so(e){let t=e[0];if(e.length>1){for(const s of e)if(s.type!==ot){t=s;break}}return t}function Or(e){if(!cn(e))return _s(e.type)&&e.children?so(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:s}=e;if(s){if(t&16)return s[0];if(t&32&&R(s.default))return s.default()}}function fn(e,t){if(e.shapeFlag&6&&e.component){e.transition=t;const s=e.component.subTree;fn(_s(s.type)&&Or(s)||s,t)}else e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ar(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Cn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ss=new WeakMap;function Ot(e,t,s,n,r=!1){if(A(e)){e.forEach((M,G)=>Ot(M,t&&(A(t)?t[G]:t),s,n,r));return}if(At(n)&&!r){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Ot(e,t,s,n.component.subTree);return}const i=n.shapeFlag&4?xs(n.component):n.el,o=r?null:i,{i:l,r:f}=e,d=t&&t.r,a=l.refs===K?l.refs={}:l.refs,p=l.setupState,w=D(p),C=p===K?zn:M=>Cn(a,M)?!1:N(w,M),H=(M,G)=>!(G&&Cn(a,G));if(d!=null&&d!==f){if(Tn(t),k(d))a[d]=null,C(d)&&(p[d]=null);else if(ee(d)){const M=t;H(d,M.k)&&(d.value=null),M.k&&(a[M.k]=null)}}if(R(f))Ht(f,l,12,[o,a]);else{const M=k(f),G=ee(f);if(M||G){const J=()=>{if(e.f){const F=M?C(f)?p[f]:a[f]:H()||!e.k?f.value:a[e.k];if(r)A(F)&&zs(F,i);else if(A(F))F.includes(i)||F.push(i);else if(M)a[f]=[i],C(f)&&(p[f]=a[f]);else{const V=[i];H(f,e.k)&&(f.value=V),e.k&&(a[e.k]=V)}}else M?(a[f]=o,C(f)&&(p[f]=o)):G&&(H(f,e.k)&&(f.value=o),e.k&&(a[e.k]=o))};if(o){const F=()=>{J(),ss.delete(e)};F.id=-1,ss.set(e,F),le(F,s)}else Tn(e),J()}}}function Tn(e){const t=ss.get(e);t&&(t.flags|=8,ss.delete(e))}hs().requestIdleCallback;hs().cancelIdleCallback;const At=e=>!!e.type.__asyncLoader,cn=e=>e.type.__isKeepAlive;function no(e,t){Pr(e,"a",t)}function ro(e,t){Pr(e,"da",t)}function Pr(e,t,s=oe){const n=e.__wdc||(e.__wdc=()=>{let r=s;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(ys(t,n,s),s){let r=s.parent;for(;r&&r.parent;)cn(r.parent.vnode)&&io(n,t,s,r),r=r.parent}}function io(e,t,s,n){const r=ys(t,e,n,!0);Ir(()=>{zs(n[t],r)},s)}function ys(e,t,s=oe,n=!1){if(s){const r=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...o)=>{je();const l=Vt(s),f=ye(t,s,e,o);return l(),Le(),f});return n?r.unshift(i):r.push(i),i}}const Ke=e=>(t,s=oe)=>{(!Dt||e==="sp")&&ys(e,(...n)=>t(...n),s)},oo=Ke("bm"),Mr=Ke("m"),lo=Ke("bu"),fo=Ke("u"),co=Ke("bum"),Ir=Ke("um"),uo=Ke("sp"),ao=Ke("rtg"),ho=Ke("rtc");function po(e,t=oe){ys("ec",e,t)}const go=Symbol.for("v-ndc");function Rr(e,t,s,n){let r;const i=s,o=A(e);if(o||k(e)){const l=o&&nt(e);let f=!1,d=!1;l&&(f=!he(e),d=Ue(e),e=gs(e)),r=new Array(e.length);for(let a=0,p=e.length;at(l,f,void 0,i));else{const l=Object.keys(e);r=new Array(l.length);for(let f=0,d=l.length;fe?ti(e)?xs(e):Bs(e.parent):null,Pt=te(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Bs(e.parent),$root:e=>Bs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Fr(e),$forceUpdate:e=>e.f||(e.f=()=>{ln(e.update)}),$nextTick:e=>e.n||(e.n=vr.bind(e.proxy)),$watch:e=>eo.bind(e)}),Is=(e,t)=>e!==K&&!e.__isScriptSetup&&N(e,t),mo={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:n,data:r,props:i,accessCache:o,type:l,appContext:f}=e;if(t[0]!=="$"){const w=o[t];if(w!==void 0)switch(w){case 1:return n[t];case 2:return r[t];case 4:return s[t];case 3:return i[t]}else{if(Is(n,t))return o[t]=1,n[t];if(r!==K&&N(r,t))return o[t]=2,r[t];if(N(i,t))return o[t]=3,i[t];if(s!==K&&N(s,t))return o[t]=4,s[t];Ws&&(o[t]=0)}}const d=Pt[t];let a,p;if(d)return t==="$attrs"&&Z(e.attrs,"get",""),d(e);if((a=l.__cssModules)&&(a=a[t]))return a;if(s!==K&&N(s,t))return o[t]=4,s[t];if(p=f.config.globalProperties,N(p,t))return p[t]},set({_:e},t,s){const{data:n,setupState:r,ctx:i}=e;return Is(r,t)?(r[t]=s,!0):n!==K&&N(n,t)?(n[t]=s,!0):N(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:n,appContext:r,props:i,type:o}},l){let f;return!!(s[l]||e!==K&&l[0]!=="$"&&N(e,l)||Is(t,l)||N(i,l)||N(n,l)||N(Pt,l)||N(r.config.globalProperties,l)||(f=o.__cssModules)&&f[l])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:N(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}};function En(e){return A(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}let Ws=!0;function _o(e){const t=Fr(e),s=e.proxy,n=e.ctx;Ws=!1,t.beforeCreate&&On(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:l,provide:f,inject:d,created:a,beforeMount:p,mounted:w,beforeUpdate:C,updated:H,activated:M,deactivated:G,beforeDestroy:J,beforeUnmount:F,destroyed:V,unmounted:O,render:Y,renderTracked:be,renderTriggered:ve,errorCaptured:Be,serverPrefetch:jt,expose:Ge,inheritAttrs:gt,components:Lt,directives:Ut,filters:Ss}=t;if(d&&yo(d,n,null),o)for(const q in o){const B=o[q];R(B)&&(n[q]=B.bind(s))}if(r){const q=r.call(s,s);L(q)&&(e.data=nn(q))}if(Ws=!0,i)for(const q in i){const B=i[q],ze=R(B)?B.bind(s,s):R(B.get)?B.get.bind(s,s):Ie,Kt=!R(B)&&R(B.set)?B.set.bind(s):Ie,Ye=el({get:ze,set:Kt});Object.defineProperty(n,q,{enumerable:!0,configurable:!0,get:()=>Ye.value,set:xe=>Ye.value=xe})}if(l)for(const q in l)$r(l[q],n,s,q);if(f){const q=R(f)?f.call(s):f;Reflect.ownKeys(q).forEach(B=>{Xi(B,q[B])})}a&&On(a,e,"c");function se(q,B){A(B)?B.forEach(ze=>q(ze.bind(s))):B&&q(B.bind(s))}if(se(oo,p),se(Mr,w),se(lo,C),se(fo,H),se(no,M),se(ro,G),se(po,Be),se(ho,be),se(ao,ve),se(co,F),se(Ir,O),se(uo,jt),A(Ge))if(Ge.length){const q=e.exposed||(e.exposed={});Ge.forEach(B=>{Object.defineProperty(q,B,{get:()=>s[B],set:ze=>s[B]=ze,enumerable:!0})})}else e.exposed||(e.exposed={});Y&&e.render===Ie&&(e.render=Y),gt!=null&&(e.inheritAttrs=gt),Lt&&(e.components=Lt),Ut&&(e.directives=Ut),jt&&Ar(e)}function yo(e,t,s=Ie){A(e)&&(e=Js(e));for(const n in e){const r=e[n];let i;L(r)?"default"in r?i=Xt(r.from||n,r.default,!0):i=Xt(r.from||n):i=Xt(r),ee(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:o=>i.value=o}):t[n]=i}}function On(e,t,s){ye(A(e)?e.map(n=>n.bind(t.proxy)):e.bind(t.proxy),t,s)}function $r(e,t,s,n){let r=n.includes(".")?Er(s,n):()=>s[n];if(k(e)){const i=t[e];R(i)&&Ps(r,i)}else if(R(e))Ps(r,e.bind(s));else if(L(e))if(A(e))e.forEach(i=>$r(i,t,s,n));else{const i=R(e.handler)?e.handler.bind(s):t[e.handler];R(i)&&Ps(r,i,e)}}function Fr(e){const t=e.type,{mixins:s,extends:n}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:o}}=e.appContext,l=i.get(t);let f;return l?f=l:!r.length&&!s&&!n?f=t:(f={},r.length&&r.forEach(d=>ns(f,d,o,!0)),ns(f,t,o)),L(t)&&i.set(t,f),f}function ns(e,t,s,n=!1){const{mixins:r,extends:i}=t;i&&ns(e,i,s,!0),r&&r.forEach(o=>ns(e,o,s,!0));for(const o in t)if(!(n&&o==="expose")){const l=bo[o]||s&&s[o];e[o]=l?l(e[o],t[o]):t[o]}return e}const bo={data:An,props:Pn,emits:Pn,methods:xt,computed:xt,beforeCreate:ne,created:ne,beforeMount:ne,mounted:ne,beforeUpdate:ne,updated:ne,beforeDestroy:ne,beforeUnmount:ne,destroyed:ne,unmounted:ne,activated:ne,deactivated:ne,errorCaptured:ne,serverPrefetch:ne,components:xt,directives:xt,watch:xo,provide:An,inject:vo};function An(e,t){return t?e?function(){return te(R(e)?e.call(this,this):e,R(t)?t.call(this,this):t)}:t:e}function vo(e,t){return xt(Js(e),Js(t))}function Js(e){if(A(e)){const t={};for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${ge(t)}Modifiers`]||e[`${lt(t)}Modifiers`];function To(e,t,...s){if(e.isUnmounted)return;const n=e.vnode.props||K;let r=s;const i=t.startsWith("update:"),o=i&&Co(n,t.slice(7));o&&(o.trim&&(r=s.map(a=>k(a)?a.trim():a)),o.number&&(r=r.map(ds)));let l,f=n[l=Cs(t)]||n[l=Cs(ge(t))];!f&&i&&(f=n[l=Cs(lt(t))]),f&&ye(f,e,6,r);const d=n[l+"Once"];if(d){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,ye(d,e,6,r)}}const Eo=new WeakMap;function Nr(e,t,s=!1){const n=s?Eo:t.emitsCache,r=n.get(e);if(r!==void 0)return r;const i=e.emits;let o={},l=!1;if(!R(e)){const f=d=>{const a=Nr(d,t,!0);a&&(l=!0,te(o,a))};!s&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}return!i&&!l?(L(e)&&n.set(e,null),null):(A(i)?i.forEach(f=>o[f]=null):te(o,i),L(e)&&n.set(e,o),o)}function bs(e,t){return!e||!cs(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),N(e,t[0].toLowerCase()+t.slice(1))||N(e,lt(t))||N(e,t))}function Mn(e){const{type:t,vnode:s,proxy:n,withProxy:r,propsOptions:[i],slots:o,attrs:l,emit:f,render:d,renderCache:a,props:p,data:w,setupState:C,ctx:H,inheritAttrs:M}=e,G=ts(e);let J,F;try{if(s.shapeFlag&4){const O=r||n,Y=O;J=Ae(d.call(Y,O,a,p,C,w,H)),F=l}else{const O=t;J=Ae(O.length>1?O(p,{attrs:l,slots:o,emit:f}):O(p,null)),F=t.props?l:Oo(l)}}catch(O){rt.length=0,ms(O,e,1),J=qe(ot)}let V=J;if(F&&M!==!1){const O=Object.keys(F),{shapeFlag:Y}=V;O.length&&Y&7&&(i&&O.some(us)&&(F=Ao(F,i)),V=pt(V,F,!1,!0))}if(s.dirs&&(V=pt(V,null,!1,!0),V.dirs=V.dirs?V.dirs.concat(s.dirs):s.dirs),s.transition){const O=_s(V.type)&&Or(V)||V;fn(O,s.transition)}return J=V,ts(G),J}const Oo=e=>{let t;for(const s in e)(s==="class"||s==="style"||cs(s))&&((t||(t={}))[s]=e[s]);return t},Ao=(e,t)=>{const s={};for(const n in e)(!us(n)||!(n.slice(9)in t))&&(s[n]=e[n]);return s};function Po(e,t,s){const{props:n,children:r,component:i}=e,{props:o,children:l,patchFlag:f}=t,d=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&f>=0){if(f&1024)return!0;if(f&16)return n?In(n,o,d):!!o;if(f&8){const a=t.dynamicProps;for(let p=0;pObject.create(Vr),Lr=e=>Object.getPrototypeOf(e)===Vr;function Io(e,t,s,n=!1){const r={},i=jr();e.propsDefaults=Object.create(null),Ur(e,t,r,i);for(const o in e.propsOptions[0])o in r||(r[o]=void 0);s?e.props=n?r:Vi(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function Ro(e,t,s,n){const{props:r,attrs:i,vnode:{patchFlag:o}}=e,l=D(r),[f]=e.propsOptions;let d=!1;if((n||o>0)&&!(o&16)){if(o&8){const a=e.vnode.dynamicProps;for(let p=0;p{f=!0;const[w,C]=Kr(p,t,!0);te(o,w),C&&l.push(...C)};!s&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}if(!i&&!f)return L(e)&&n.set(e,ut),ut;if(A(i))for(let a=0;ae==="_"||e==="_ctx"||e==="$stable",an=e=>A(e)?e.map(Ae):[Ae(e)],Fo=(e,t,s)=>{if(t._n)return t;const n=Yi((...r)=>an(t(...r)),s);return n._c=!1,n},Br=(e,t,s)=>{const n=e._ctx;for(const r in e){if(un(r))continue;const i=e[r];if(R(i))t[r]=Fo(r,i,n);else if(i!=null){const o=an(i);t[r]=()=>o}}},Wr=(e,t)=>{const s=an(t);e.slots.default=()=>s},Jr=(e,t,s)=>{for(const n in t)(s||!un(n))&&(e[n]=t[n])},Do=(e,t,s)=>{const n=e.slots=jr();if(e.vnode.shapeFlag&32){const r=t._;r?(Jr(n,t,s),s&&er(n,"_",r,!0)):Br(t,n)}else t&&Wr(e,t)},No=(e,t,s)=>{const{vnode:n,slots:r}=e;let i=!0,o=K;if(n.shapeFlag&32){const l=t._;l?s&&l===1?i=!1:Jr(r,t,s):(i=!t.$stable,Br(t,r)),o=t}else t&&(Wr(e,t),o={default:1});if(i)for(const l in r)!un(l)&&o[l]==null&&delete r[l]},le=Uo;function Ho(e){return Vo(e)}function Vo(e,t){const s=hs();s.__VUE__=!0;const{insert:n,remove:r,patchProp:i,createElement:o,createText:l,createComment:f,setText:d,setElementText:a,parentNode:p,nextSibling:w,setScopeId:C=Ie,insertStaticContent:H}=e,M=(c,u,h,y=null,_=null,g=null,x=void 0,v=null,b=!!u.dynamicChildren)=>{if(c===u)return;c&&!vt(c,u)&&(y=Bt(c),xe(c,_,g,!0),c=null),u.patchFlag===-2&&(b=!1,u.dynamicChildren=null);const{type:m,ref:E,shapeFlag:S}=u;switch(m){case vs:G(c,u,h,y);break;case ot:J(c,u,h,y);break;case $s:c==null&&F(u,h,y,x);break;case ae:Lt(c,u,h,y,_,g,x,v,b);break;default:S&1?Y(c,u,h,y,_,g,x,v,b):S&6?Ut(c,u,h,y,_,g,x,v,b):(S&64||S&128)&&m.process(c,u,h,y,_,g,x,v,b,_t)}E!=null&&_?Ot(E,c&&c.ref,g,u||c,!u):E==null&&c&&c.ref!=null&&Ot(c.ref,null,g,c,!0)},G=(c,u,h,y)=>{if(c==null)n(u.el=l(u.children),h,y);else{const _=u.el=c.el;u.children!==c.children&&d(_,u.children)}},J=(c,u,h,y)=>{c==null?n(u.el=f(u.children||""),h,y):u.el=c.el},F=(c,u,h,y)=>{[c.el,c.anchor]=H(c.children,u,h,y,c.el,c.anchor)},V=({el:c,anchor:u},h,y)=>{let _;for(;c&&c!==u;)_=w(c),n(c,h,y),c=_;n(u,h,y)},O=({el:c,anchor:u})=>{let h;for(;c&&c!==u;)h=w(c),r(c),c=h;r(u)},Y=(c,u,h,y,_,g,x,v,b)=>{if(u.type==="svg"?x="svg":u.type==="math"&&(x="mathml"),c==null)be(u,h,y,_,g,x,v,b);else{const m=c.el&&c.el._isVueCE?c.el:null;try{m&&m._beginPatch(),jt(c,u,_,g,x,v,b)}finally{m&&m._endPatch()}}},be=(c,u,h,y,_,g,x,v)=>{let b,m;const{props:E,shapeFlag:S,transition:T,dirs:P}=c;if(b=c.el=o(c.type,g,E&&E.is,E),S&8?a(b,c.children):S&16&&Be(c.children,b,null,y,_,Rs(c,g),x,v),P&&Xe(c,null,y,"created"),ve(b,c,c.scopeId,x,y),E){for(const U in E)U!=="value"&&!wt(U)&&i(b,U,null,E[U],g,y);"value"in E&&i(b,"value",null,E.value,g),(m=E.onVnodeBeforeMount)&&Te(m,y,c)}P&&Xe(c,null,y,"beforeMount");const $=jo(_,T);$&&T.beforeEnter(b),n(b,u,h),((m=E&&E.onVnodeMounted)||$||P)&&le(()=>{try{m&&Te(m,y,c),$&&T.enter(b),P&&Xe(c,null,y,"mounted")}finally{}},_)},ve=(c,u,h,y,_)=>{if(h&&C(c,h),y)for(let g=0;g{for(let m=b;m{const v=u.el=c.el;let{patchFlag:b,dynamicChildren:m,dirs:E}=u;b|=c.patchFlag&16;const S=c.props||K,T=u.props||K;let P;if(h&&Qe(h,!1),(P=T.onVnodeBeforeUpdate)&&Te(P,h,u,c),E&&Xe(u,c,h,"beforeUpdate"),h&&Qe(h,!0),m&&(!c.dynamicChildren||c.dynamicChildren.length!==m.length)&&(b=0,x=!1,m=null),(S.innerHTML&&T.innerHTML==null||S.textContent&&T.textContent==null)&&a(v,""),m?Ge(c.dynamicChildren,m,v,h,y,Rs(u,_),g):x||B(c,u,v,null,h,y,Rs(u,_),g,!1),b>0){if(b&16)gt(v,S,T,h,_);else if(b&2&&S.class!==T.class&&i(v,"class",null,T.class,_),b&4&&i(v,"style",S.style,T.style,_),b&8){const $=u.dynamicProps;for(let U=0;U<$.length;U++){const j=$[U],z=S[j],X=T[j];(X!==z||j==="value")&&i(v,j,z,X,_,h)}}b&1&&c.children!==u.children&&a(v,u.children)}else!x&&m==null&>(v,S,T,h,_);((P=T.onVnodeUpdated)||E)&&le(()=>{P&&Te(P,h,u,c),E&&Xe(u,c,h,"updated")},y)},Ge=(c,u,h,y,_,g,x)=>{for(let v=0;v{if(u!==h){if(u!==K)for(const g in u)!wt(g)&&!(g in h)&&i(c,g,u[g],null,_,y);for(const g in h){if(wt(g))continue;const x=h[g],v=u[g];x!==v&&g!=="value"&&i(c,g,v,x,_,y)}"value"in h&&i(c,"value",u.value,h.value,_)}},Lt=(c,u,h,y,_,g,x,v,b)=>{const m=u.el=c?c.el:l(""),E=u.anchor=c?c.anchor:l("");let{patchFlag:S,dynamicChildren:T,slotScopeIds:P}=u;P&&(v=v?v.concat(P):P),c==null?(n(m,h,y),n(E,h,y),Be(u.children||[],h,E,_,g,x,v,b)):S>0&&S&64&&T&&c.dynamicChildren&&c.dynamicChildren.length===T.length?(Ge(c.dynamicChildren,T,h,_,g,x,v),(u.key!=null||_&&u===_.subTree)&&qr(c,u,!0)):B(c,u,h,E,_,g,x,v,b)},Ut=(c,u,h,y,_,g,x,v,b)=>{u.slotScopeIds=v,c==null?u.shapeFlag&512?_.ctx.activate(u,h,y,x,b):Ss(u,h,y,_,g,x,b):dn(c,u,b)},Ss=(c,u,h,y,_,g,x)=>{const v=c.component=ko(c,y,_);if(cn(c)&&(v.ctx.renderer=_t),zo(v,!1,x),v.asyncDep){if(_&&_.registerDep(v,se,x),!c.el){const b=v.subTree=qe(ot);J(null,b,u,h),c.placeholder=b.el}}else se(v,c,u,h,_,g,x)},dn=(c,u,h)=>{const y=u.component=c.component;if(Po(c,u,h))if(y.asyncDep&&!y.asyncResolved){q(y,u,h);return}else y.next=u,y.update();else u.el=c.el,y.vnode=u},se=(c,u,h,y,_,g,x)=>{const v=()=>{if(c.isMounted){let{next:S,bu:T,u:P,parent:$,vnode:U}=c;{const we=kr(c);if(we){S&&(S.el=U.el,q(c,S,x)),we.asyncDep.then(()=>{le(()=>{c.isUnmounted||m()},_)});return}}let j=S,z;Qe(c,!1),S?(S.el=U.el,q(c,S,x)):S=U,T&&Yt(T),(z=S.props&&S.props.onVnodeBeforeUpdate)&&Te(z,$,S,U),Qe(c,!0);const X=Mn(c),Se=c.subTree;c.subTree=X,M(Se,X,p(Se.el),Bt(Se),c,_,g),S.el=X.el,j===null&&Mo(c,X.el),P&&le(P,_),(z=S.props&&S.props.onVnodeUpdated)&&le(()=>Te(z,$,S,U),_)}else{let S;const{el:T,props:P}=u,{bm:$,m:U,parent:j,root:z,type:X}=c,Se=At(u);Qe(c,!1),$&&Yt($),!Se&&(S=P&&P.onVnodeBeforeMount)&&Te(S,j,u),Qe(c,!0);{z.ce&&z.ce._hasShadowRoot()&&z.ce._injectChildStyle(X,c.parent?c.parent.type:void 0);const we=c.subTree=Mn(c);M(null,we,h,y,c,_,g),u.el=we.el}if(U&&le(U,_),!Se&&(S=P&&P.onVnodeMounted)){const we=u;le(()=>Te(S,j,we),_)}(u.shapeFlag&256||j&&At(j.vnode)&&j.vnode.shapeFlag&256)&&c.a&&le(c.a,_),c.isMounted=!0,u=h=y=null}};c.scope.on();const b=c.effect=new rr(v);c.scope.off();const m=c.update=b.run.bind(b),E=c.job=b.runIfDirty.bind(b);E.i=c,E.id=c.uid,b.scheduler=()=>ln(E),Qe(c,!0),m()},q=(c,u,h)=>{u.component=c;const y=c.vnode.props;c.vnode=u,c.next=null,Ro(c,u.props,y,h),No(c,u.children,h),je(),wn(c),Le()},B=(c,u,h,y,_,g,x,v,b=!1)=>{const m=c&&c.children,E=c?c.shapeFlag:0,S=u.children,{patchFlag:T,shapeFlag:P}=u;if(T>0){if(T&128){Kt(m,S,h,y,_,g,x,v,b);return}else if(T&256){ze(m,S,h,y,_,g,x,v,b);return}}P&8?(E&16&&mt(m,_,g),S!==m&&a(h,S)):E&16?P&16?Kt(m,S,h,y,_,g,x,v,b):mt(m,_,g,!0):(E&8&&a(h,""),P&16&&Be(S,h,y,_,g,x,v,b))},ze=(c,u,h,y,_,g,x,v,b)=>{c=c||ut,u=u||ut;const m=c.length,E=u.length,S=Math.min(m,E);let T;for(T=0;TE?mt(c,_,g,!0,!1,S):Be(u,h,y,_,g,x,v,b,S)},Kt=(c,u,h,y,_,g,x,v,b)=>{let m=0;const E=u.length;let S=c.length-1,T=E-1;for(;m<=S&&m<=T;){const P=c[m],$=u[m]=b?De(u[m]):Ae(u[m]);if(vt(P,$))M(P,$,h,null,_,g,x,v,b);else break;m++}for(;m<=S&&m<=T;){const P=c[S],$=u[T]=b?De(u[T]):Ae(u[T]);if(vt(P,$))M(P,$,h,null,_,g,x,v,b);else break;S--,T--}if(m>S){if(m<=T){const P=T+1,$=PT)for(;m<=S;)xe(c[m],_,g,!0),m++;else{const P=m,$=m,U=new Map;for(m=$;m<=T;m++){const fe=u[m]=b?De(u[m]):Ae(u[m]);fe.key!=null&&U.set(fe.key,m)}let j,z=0;const X=T-$+1;let Se=!1,we=0;const yt=new Array(X);for(m=0;m=X){xe(fe,_,g,!0);continue}let Ce;if(fe.key!=null)Ce=U.get(fe.key);else for(j=$;j<=T;j++)if(yt[j-$]===0&&vt(fe,u[j])){Ce=j;break}Ce===void 0?xe(fe,_,g,!0):(yt[Ce-$]=m+1,Ce>=we?we=Ce:Se=!0,M(fe,u[Ce],h,null,_,g,x,v,b),z++)}const gn=Se?Lo(yt):ut;for(j=gn.length-1,m=X-1;m>=0;m--){const fe=$+m,Ce=u[fe],mn=u[fe+1],_n=fe+1{const{el:g,type:x,transition:v,children:b,shapeFlag:m}=c;if(m&6){Ye(c.component.subTree,u,h,y);return}if(m&128){c.suspense.move(u,h,y);return}if(m&64){x.move(c,u,h,_t);return}if(x===ae){n(g,u,h);for(let S=0;Sv.enter(g),_));else{const{leave:S,delayLeave:T,afterLeave:P}=v,$=()=>{c.ctx.isUnmounted?r(g):n(g,u,h)},U=()=>{const j=g._isLeaving||!!g[Ms];g._isLeaving&&g[Ms](!0),v.persisted&&!j?$():S(g,()=>{$(),P&&P()})};T?T(g,$,U):U()}else n(g,u,h)},xe=(c,u,h,y=!1,_=!1)=>{const{type:g,props:x,ref:v,children:b,dynamicChildren:m,shapeFlag:E,patchFlag:S,dirs:T,cacheIndex:P,memo:$}=c;if(S===-2&&(_=!1),v!=null&&(je(),Ot(v,null,h,c,!0),Le()),P!=null&&(u.renderCache[P]=void 0),E&256){u.ctx.deactivate(c);return}const U=E&1&&T,j=!At(c);let z;if(j&&(z=x&&x.onVnodeBeforeUnmount)&&Te(z,u,c),E&6)oi(c.component,h,y);else{if(E&128){c.suspense.unmount(h,y);return}U&&Xe(c,null,u,"beforeUnmount"),E&64?c.type.remove(c,u,h,_t,y):m&&!m.hasOnce&&(g!==ae||S>0&&S&64)?mt(m,u,h,!1,!0):(g===ae&&S&384||!_&&E&16)&&mt(b,u,h),y&&hn(c)}const X=$!=null&&P==null;(j&&(z=x&&x.onVnodeUnmounted)||U||X)&&le(()=>{z&&Te(z,u,c),U&&Xe(c,null,u,"unmounted"),X&&(c.el=null)},h)},hn=c=>{const{type:u,el:h,anchor:y,transition:_}=c;if(u===ae){ii(h,y);return}if(u===$s){O(c);return}const g=()=>{r(h),_&&!_.persisted&&_.afterLeave&&_.afterLeave()};if(c.shapeFlag&1&&_&&!_.persisted){const{leave:x,delayLeave:v}=_,b=()=>x(h,g);v?v(c.el,g,b):b()}else g()},ii=(c,u)=>{let h;for(;c!==u;)h=w(c),r(c),c=h;r(u)},oi=(c,u,h)=>{const{bum:y,scope:_,job:g,subTree:x,um:v,m:b,a:m}=c;$n(b),$n(m),y&&Yt(y),_.stop(),g&&(g.flags|=8,xe(x,c,u,h)),v&&le(v,u),le(()=>{c.isUnmounted=!0},u)},mt=(c,u,h,y=!1,_=!1,g=0)=>{for(let x=g;x{if(c.shapeFlag&6)return Bt(c.component.subTree);if(c.shapeFlag&128)return c.suspense.next();const u=w(c.anchor||c.el),h=u&&u[to];return h?w(h):u};let ws=!1;const pn=(c,u,h)=>{let y;c==null?u._vnode&&(xe(u._vnode,null,null,!0),y=u._vnode.component):M(u._vnode||null,c,u,null,null,null,h),u._vnode=c,ws||(ws=!0,wn(y),Sr(),ws=!1)},_t={p:M,um:xe,m:Ye,r:hn,mt:Ss,mc:Be,pc:B,pbc:Ge,n:Bt,o:e};return{render:pn,hydrate:void 0,createApp:wo(pn)}}function Rs({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function Qe({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function jo(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function qr(e,t,s=!1){const n=e.children,r=t.children;if(A(n)&&A(r))for(let i=0;i>1,e[s[l]]0&&(t[n]=s[i-1]),s[i]=n)}}for(i=s.length,o=s[i-1];i-- >0;)s[i]=o,o=t[o];return s}function kr(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:kr(t)}function $n(e){if(e)for(let t=0;te.__isSuspense;function Uo(e,t){t&&t.pendingBranch?A(e)?t.effects.push(...e):t.effects.push(e):zi(e)}const ae=Symbol.for("v-fgt"),vs=Symbol.for("v-txt"),ot=Symbol.for("v-cmt"),$s=Symbol.for("v-stc"),rt=[];let ue=null;function ie(e=!1){rt.push(ue=e?null:[])}function Yr(){rt.pop(),ue=rt[rt.length-1]||null}let $t=1;function Fn(e,t=!1){$t+=e,e<0&&ue&&t&&(ue.hasOnce=!0)}function Xr(e){return e.dynamicChildren=$t>0?ue||ut:null,Yr(),$t>0&&ue&&ue.push(e),e}function pe(e,t,s,n,r,i){return Xr(I(e,t,s,n,r,i,!0))}function kt(e,t,s,n,r){return Xr(qe(e,t,s,n,r,!0))}function Qr(e){return e?e.__v_isVNode===!0:!1}function vt(e,t){return e.type===t.type&&e.key===t.key}const Zr=({key:e})=>e??null,Qt=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?k(e)||ee(e)||R(e)?{i:de,r:e,k:t,f:!!s}:e:null);function I(e,t=null,s=null,n=0,r=null,i=e===ae?0:1,o=!1,l=!1){const f={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Zr(t),ref:t&&Qt(t),scopeId:Cr,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:n,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:de};return l?(rs(f,s),i&128&&e.normalize(f)):s&&(f.shapeFlag|=k(s)?8:16),$t>0&&!o&&ue&&(f.patchFlag>0||i&6)&&f.patchFlag!==32&&ue.push(f),f}const qe=Ko;function Ko(e,t=null,s=null,n=0,r=null,i=!1){if((!e||e===go)&&(e=ot),Qr(e)){const l=pt(e,t,!0);return s&&rs(l,s),$t>0&&!i&&ue&&(l.shapeFlag&6?ue[ue.indexOf(e)]=l:ue.push(l)),l.patchFlag=-2,l}if(Zo(e)&&(e=e.__vccOpts),t){t=Bo(t);let{class:l,style:f}=t;l&&!k(l)&&(t.class=ps(l)),L(f)&&(on(f)&&!A(f)&&(f=te({},f)),t.style=Xs(f))}const o=k(e)?1:zr(e)?128:_s(e)?64:L(e)?4:R(e)?2:0;return I(e,t,s,n,r,o,i,!0)}function Bo(e){return e?on(e)||Lr(e)?te({},e):e:null}function pt(e,t,s=!1,n=!1){const{props:r,ref:i,patchFlag:o,children:l,transition:f}=e,d=t?Wo(r||{},t):r,a={__v_isVNode:!0,__v_skip:!0,type:e.type,props:d,key:d&&Zr(d),ref:t&&t.ref?s&&i?A(i)?i.concat(Qt(t)):[i,Qt(t)]:Qt(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==ae?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:f,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&pt(e.ssContent),ssFallback:e.ssFallback&&pt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return f&&n&&fn(a,f.clone(a)),a}function ei(e=" ",t=0){return qe(vs,null,e,t)}function Ae(e){return e==null||typeof e=="boolean"?qe(ot):A(e)?qe(ae,null,e.slice()):Qr(e)?De(e):qe(vs,null,String(e))}function De(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:pt(e)}function rs(e,t){let s=0;const{shapeFlag:n}=e;if(t==null)t=null;else if(A(t))s=16;else if(typeof t=="object")if(n&65){const r=t.default;r&&(r._c&&(r._d=!1),rs(e,r()),r._c&&(r._d=!0));return}else{s=32;const r=t._;!r&&!Lr(t)?t._ctx=de:r===3&&de&&(de.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(R(t)){if(n&65){rs(e,{default:t});return}t={default:t,_ctx:de},s=32}else t=String(t),n&64?(s=16,t=[ei(t)]):s=8;e.children=t,e.shapeFlag|=s}function Wo(...e){const t={};for(let s=0;soe||de;let is,Ft;{const e=hs(),t=(s,n)=>{let r;return(r=e[s])||(r=e[s]=[]),r.push(n),i=>{r.length>1?r.forEach(o=>o(i)):r[0](i)}};is=t("__VUE_INSTANCE_SETTERS__",s=>oe=s),Ft=t("__VUE_SSR_SETTERS__",s=>Dt=s)}const Vt=e=>{const t=oe;return is(e),e.scope.on(),()=>{e.scope.off(),is(t)}},Dn=()=>{oe&&oe.scope.off(),is(null)};function ti(e){return e.vnode.shapeFlag&4}let Dt=!1;function zo(e,t=!1,s=!1){t&&Ft(t);const{props:n,children:r}=e.vnode,i=ti(e);Io(e,n,i,t),Do(e,r,s||t);const o=i?Yo(e,t):void 0;return t&&Ft(!1),o}function Yo(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,mo);const{setup:n}=s;if(n){je();const r=e.setupContext=n.length>1?Qo(e):null,i=Vt(e),o=Ht(n,e,0,[e.props,r]),l=Yn(o);if(Le(),i(),(l||e.sp)&&!At(e)&&Ar(e),l){if(o.then(Dn,Dn),t)return o.then(f=>{Ft(!0);try{Nn(e,f,t)}finally{Ft(!1)}}).catch(f=>{ms(f,e,0)});e.asyncDep=o}else Nn(e,o)}else si(e)}function Nn(e,t,s){R(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:L(t)&&(e.setupState=yr(t)),si(e)}function si(e,t,s){const n=e.type;e.render||(e.render=n.render||Ie);{const r=Vt(e);je();try{_o(e)}finally{Le(),r()}}}const Xo={get(e,t){return Z(e,"get",""),e[t]}};function Qo(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,Xo),slots:e.slots,emit:e.emit,expose:t}}function xs(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(yr(ji(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Pt)return Pt[s](e)},has(t,s){return s in t||s in Pt}})):e.proxy}function Zo(e){return R(e)&&"__vccOpts"in e}const el=(e,t)=>Wi(e,t,Dt),tl="3.5.42";/** +* @vue/runtime-dom v3.5.42 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let ks;const Hn=typeof window<"u"&&window.trustedTypes;if(Hn)try{ks=Hn.createPolicy("vue",{createHTML:e=>e})}catch{}const ni=ks?e=>ks.createHTML(e):e=>e,sl="http://www.w3.org/2000/svg",nl="http://www.w3.org/1998/Math/MathML",Fe=typeof document<"u"?document:null,Vn=Fe&&Fe.createElement("template"),rl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const r=t==="svg"?Fe.createElementNS(sl,e):t==="mathml"?Fe.createElementNS(nl,e):s?Fe.createElement(e,{is:s}):Fe.createElement(e);return e==="select"&&n&&n.multiple!=null&&r.setAttribute("multiple",n.multiple),r},createText:e=>Fe.createTextNode(e),createComment:e=>Fe.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Fe.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,r,i){const o=s?s.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),s),!(r===i||!(r=r.nextSibling)););else{Vn.innerHTML=ni(n==="svg"?`${e}`:n==="mathml"?`${e}`:e);const l=Vn.content;if(n==="svg"||n==="mathml"){const f=l.firstChild;for(;f.firstChild;)l.appendChild(f.firstChild);l.removeChild(f)}t.insertBefore(l,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},il=Symbol("_vtc");function ol(e,t,s){const n=e[il];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const jn=Symbol("_vod"),ll=Symbol("_vsh"),fl=Symbol(""),cl=/(?:^|;)\s*display\s*:/;function ul(e,t,s){const n=e.style,r=k(s);let i=!1;if(s&&!r){if(t)if(k(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();s[l]==null&&St(n,l,"")}else for(const o in t)s[o]==null&&St(n,o,"");for(const o in s){o==="display"&&(i=!0);const l=s[o];l!=null?dl(e,o,!k(t)&&t?t[o]:void 0,l)||St(n,o,l):St(n,o,"")}}else if(r){if(t!==s){const o=n[fl];o&&(s+=";"+o),n.cssText=s,i=cl.test(s)}}else t&&e.removeAttribute("style");jn in e&&(e[jn]=i?n.display:"",e[ll]&&(n.display="none"))}const Gt=/\s*!important$/;function St(e,t,s){if(A(s))s.forEach(n=>St(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))Gt.test(s)?e.setProperty(t,s.replace(Gt,""),"important"):e.setProperty(t,s);else{const n=al(e,t);Gt.test(s)?e.setProperty(lt(n),s.replace(Gt,""),"important"):e[n]=s}}const Ln=["Webkit","Moz","ms"],Fs={};function al(e,t){const s=Fs[t];if(s)return s;let n=ge(t);if(n!=="filter"&&n in e)return Fs[t]=n;n=Zn(n);for(let r=0;rDs||(yl.then(()=>Ds=0),Ds=Date.now());function vl(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const r=s.value;if(A(r)){const i=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{i.call(n),n._stopped=!0};const o=r.slice(),l=[n];for(let f=0;fe.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,xl=(e,t,s,n,r,i)=>{const o=r==="svg";t==="class"?ol(e,n,o):t==="style"?ul(e,s,n):cs(t)?us(t)||pl(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Sl(e,t,n,o))?(Bn(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Kn(e,t,n,o,i,t!=="value")):e._isVueCE&&(wl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!k(n)))?Bn(e,ge(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),Kn(e,t,n,o))};function Sl(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&Jn(t)&&R(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Jn(t)&&k(s)?!1:t in e}function wl(e,t){const s=e._def.props;if(!s)return!1;const n=ge(t);return Array.isArray(s)?s.some(r=>ge(r)===n):Object.keys(s).some(r=>ge(r)===n)}const os=e=>{const t=e.props["onUpdate:modelValue"]||!1;return A(t)?s=>Yt(t,s):t};function Cl(e){e.target.composing=!0}function qn(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const tt=Symbol("_assign"),zt=Symbol("_initialValue");function Ns(e,t,s){return t&&(e=e.trim()),s&&(e=ds(e)),e}const ls={created(e,{modifiers:{lazy:t,trim:s,number:n}},r){e.parentNode&&(e.type==="text"?e[zt]=e.defaultValue.replace(/[\r\n]/g,""):e.type==="textarea"&&(e[zt]=e.defaultValue.replace(/\r\n?/g,` +`))),e[tt]=os(r);const i=n||r.props&&r.props.type==="number";et(e,t?"change":"input",o=>{o.target.composing||e[tt](Ns(e.value,s,i))}),(s||i)&&et(e,"change",()=>{e.value=Ns(e.value,s,i)}),t||(et(e,"compositionstart",Cl),et(e,"compositionend",qn),et(e,"change",qn))},mounted(e,{value:t,modifiers:{trim:s,number:n}}){const r=t??"",i=e[zt];delete e[zt],i!==void 0&&(e.type==="text"||e.type==="textarea")&&e.value!==i?e[tt](Ns(e.value,s,n)):e.value=r},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:r,number:i}},o){if(e[tt]=os(o),e.composing)return;const l=(i||e.type==="number")&&!/^0\d/.test(e.value)?ds(e.value):e.value,f=t??"";if(l===f)return;const d=e.getRootNode();(d instanceof Document||d instanceof ShadowRoot)&&d.activeElement===e&&e.type!=="range"&&(n&&t===s||r&&e.value.trim()===f)||(e.value=f)}},Tl={deep:!0,created(e,{value:t,modifiers:{number:s}},n){e._modelValue=t,et(e,"change",()=>{const r=Array.prototype.filter.call(e.options,f=>f.selected).map(f=>s?ds(fs(f)):fs(f)),i=e.multiple,o=i?it(e._modelValue)?new Set(r):r:r[0],l=e._pendingValue=[i,i?A(o)?r.slice():r:o];try{e[tt](o)}finally{vr(()=>{e._pendingValue===l&&(e._pendingValue=void 0)})}}),e[tt]=os(n)},mounted(e,{value:t}){kn(e,t)},beforeUpdate(e,{value:t},s){e._modelValue=t,e[tt]=os(s)},updated(e,{value:t}){const s=e._pendingValue;e._pendingValue=void 0,(!s||s[0]!==e.multiple||!El(t,s[1],s[0]))&&kn(e,t)}};function El(e,t,s){if(!s||A(e))return ke(e,t);if(it(e)){if(e.size!==t.length)return!1;for(const n of t)if(!e.has(n))return!1;return!0}return!1}function kn(e,t){const s=e.multiple,n=A(t);if(!(s&&!n&&!it(t))){for(let r=0,i=e.options.length;rString(d)===String(l)):o.selected=yi(t,l)>-1}else o.selected=t.has(l);else if(ke(fs(o),t)){e.selectedIndex!==r&&(e.selectedIndex=r);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function fs(e){return"_value"in e?e._value:e.value}const Ol=te({patchProp:xl},rl);let Gn;function Al(){return Gn||(Gn=Ho(Ol))}const Pl=(...e)=>{const t=Al().createApp(...e),{mount:s}=t;return t.mount=n=>{const r=Il(n);if(!r)return;const i=t._component;!R(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=s(r,!1,Ml(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t};function Ml(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Il(e){return k(e)?document.querySelector(e):e}function Me(){return window.svfsReady?window.svfsReady:Promise.reject(new Error("未检测到 svfs 桥:请在 SVFS Client 中运行本插件"))}async function Rl(){return(await Me()).host.info()}function $l(e=""){return{async get(t){const n=await(await Me()).storage.get(e+t);return n??null},async set(t,s){return(await Me()).storage.set(e+t,s)},async remove(t){return(await Me()).storage.remove(e+t)},async keys(){return(await Me()).storage.keys()}}}async function Fl(e){const t=await Me();if(!t.net)throw new Error("宿主没有 net 能力(manifest 需要声明 net 权限)");return t.net.request(e)}async function Dl(e){const t=await Fl(e);let s=null;try{s=t.bodyText?JSON.parse(t.bodyText):null}catch{s=t.bodyText}if(t.status>=400){const n=new Error(`HTTP ${t.status}`);throw n.response={status:t.status,data:s},n}return{status:t.status,data:s}}async function ri(e){return(await Me()).ui.notify(e)}async function Nl(e){return(await Me()).ui.setTitle(e)}async function Hl(e,t="确认"){return(await Me()).ui.confirm({text:e,title:t})}async function Vl(e,{title:t="提示",kind:s="info"}={}){return(await Me()).ui.message({text:e,title:t,kind:s})}const jl=(e,t)=>{const s=e.__vccOpts||e;for(const[n,r]of t)s[n]=r;return s},Ll={class:"panel"},Ul={key:0,class:"muted"},Kl={key:1},Bl={key:2,class:"muted"},Wl={__name:"HostPanel",setup(e){const t=ce(null),s=ce("");return Rl().then(n=>t.value=n).catch(n=>s.value=n.message),(n,r)=>(ie(),pe("section",Ll,[r[0]||(r[0]=I("h2",null,"host.info —— 无需权限",-1)),s.value?(ie(),pe("p",Ul,Ve(s.value),1)):t.value?(ie(),pe("dl",Kl,[(ie(!0),pe(ae,null,Rr(t.value,(i,o)=>(ie(),pe(ae,{key:o},[I("dt",null,Ve(o),1),I("dd",null,Ve(i??"-"),1)],64))),128))])):(ie(),pe("p",Bl,"加载中…"))]))}},Jl=jl(Wl,[["__scopeId","data-v-d22dfe76"]]),ql={class:"panel"},kl={__name:"StoragePanel",setup(e){const t=$l("demo:"),s=ce(0),n=ce("greeting"),r=ce("你好,SVFS"),i=ce("");Mr(async()=>{s.value=await t.get("visits")||0,s.value+=1,await t.set("visits",s.value)});async function o(){await t.set("visits",0),s.value=0,ri("计数已清零")}async function l(f){try{if(f==="set"){let d=r.value;try{d=JSON.parse(r.value)}catch{}await t.set(n.value,d),i.value=`set(${n.value}) 成功`}else f==="get"?i.value=`get(${n.value}) → ${JSON.stringify(await t.get(n.value))}`:f==="remove"?(await t.remove(n.value),i.value=`remove(${n.value}) 成功`):i.value=`keys() → ${JSON.stringify(await t.keys())}`}catch(d){i.value=`失败: ${d.message}`}}return(f,d)=>(ie(),pe("section",ql,[I("h2",null,"storage —— 按插件隔离的键值存储(本插件已打开 "+Ve(s.value)+" 次)",1),I("p",null,[Et(I("input",{"onUpdate:modelValue":d[0]||(d[0]=a=>n.value=a),placeholder:"键",size:"14"},null,512),[[ls,n.value]]),Et(I("input",{"onUpdate:modelValue":d[1]||(d[1]=a=>r.value=a),placeholder:"值(支持 JSON)",size:"22"},null,512),[[ls,r.value]]),I("button",{class:"act",onClick:d[2]||(d[2]=a=>l("set"))},"写入"),I("button",{class:"act",onClick:d[3]||(d[3]=a=>l("get"))},"读取"),I("button",{class:"act",onClick:d[4]||(d[4]=a=>l("remove"))},"删除"),I("button",{class:"act",onClick:d[5]||(d[5]=a=>l("keys"))},"全部键")]),I("pre",null,Ve(i.value||"(操作结果显示在这里)"),1),I("p",{class:"muted"},[I("button",{class:"act",onClick:o},"打开计数清零"),d[6]||(d[6]=ei(" 换台机器 / 重装插件,这个存储也不会串到别的插件。 ",-1))])]))}},Gl={class:"panel"},zl=["disabled"],Yl={__name:"NetPanel",setup(e){const t=ce("GET"),s=ce("/api/ping"),n=ce(""),r=ce("未请求。相对 path 拼在客户端配置的服务器地址后。"),i=ce(!1);async function o(){i.value=!0,r.value="请求中…";try{const l={method:t.value,path:s.value};n.value&&!["GET","HEAD"].includes(t.value)&&(l.body=n.value,l.headers={"Content-Type":"application/json"});const{status:f,data:d}=await Dl(l);r.value=`HTTP ${f} +${JSON.stringify(d,null,2)}`}catch(l){r.value=l.response?`HTTP ${l.response.status} +${JSON.stringify(l.response.data,null,2)}`:`请求失败: ${l.message} +(先在「设置 → 服务器地址」里配置服务器)`}finally{i.value=!1}}return(l,f)=>(ie(),pe("section",Gl,[f[4]||(f[4]=I("h2",null,"net —— 宿主代发请求(绕开 CORS,自动附带登录 token)",-1)),I("p",null,[Et(I("select",{"onUpdate:modelValue":f[0]||(f[0]=d=>t.value=d)},[...f[3]||(f[3]=[I("option",null,"GET",-1),I("option",null,"POST",-1),I("option",null,"PUT",-1),I("option",null,"DELETE",-1)])],512),[[Tl,t.value]]),Et(I("input",{"onUpdate:modelValue":f[1]||(f[1]=d=>s.value=d),size:"26"},null,512),[[ls,s.value]]),I("button",{class:"act",disabled:i.value,onClick:o},"发送请求",8,zl)]),Et(I("textarea",{"onUpdate:modelValue":f[2]||(f[2]=d=>n.value=d),placeholder:"POST/PUT 的 body(JSON 字符串)"},null,512),[[ls,n.value]]),I("pre",null,Ve(r.value),1),f[5]||(f[5]=I("p",{class:"muted"},"4xx/5xx 也是正常返回(不会 reject);netJson 只是把 ≥400 转成异常方便分支。",-1))]))}},Xl={class:"panel"},Ql={__name:"UiPanel",setup(e){const t=ce("(用户的选择显示在这里)");async function s(){const r=await Hl(`这是一条宿主原生确认框(QMessageBox.question)。 +点「确定」返回 true。`,"确认操作");t.value=`ui.confirm → ${r}`}async function n(r){await Vl(`这是一条 ${r} 级原生消息框。`,{title:"原生对话框",kind:r}),t.value=`ui.message(${r}) → 已关闭`}return(r,i)=>(ie(),pe("section",Xl,[i[5]||(i[5]=I("h2",null,"notify / title / dialog —— 原生对话框(dialog 权限,v4 起)",-1)),I("p",null,[I("button",{class:"act",onClick:i[0]||(i[0]=o=>Ks(ri)("来自 Vue 模板插件的通知"))},"发通知"),I("button",{class:"act",onClick:i[1]||(i[1]=o=>Ks(Nl)("Vue 模板插件 · "+new Date().toLocaleTimeString()))}," 改窗口标题 ")]),I("p",null,[I("button",{class:"act",onClick:s},"confirm(确定 / 取消)"),I("button",{class:"act",onClick:i[2]||(i[2]=o=>n("info"))},"message · info"),I("button",{class:"act",onClick:i[3]||(i[3]=o=>n("warn"))},"message · warn"),I("button",{class:"act",onClick:i[4]||(i[4]=o=>n("error"))},"message · error")]),I("pre",null,Ve(t.value),1),i[6]||(i[6]=I("p",{class:"muted"}," 浏览器 dev 模式下对话框退化为 confirm()/alert();真实效果以客户端内为准。 ",-1))]))}},Zl=["onClick"],ef={__name:"App",setup(e){const t=[{id:"host",label:"主机信息"},{id:"storage",label:"存储"},{id:"net",label:"网络"},{id:"ui",label:"界面 / 对话框"}],s=ce("host");return(n,r)=>(ie(),pe("main",null,[I("header",null,[r[0]||(r[0]=I("h1",null,"Vue 模板插件",-1)),I("nav",null,[(ie(),pe(ae,null,Rr(t,i=>I("button",{key:i.id,class:ps({active:s.value===i.id}),onClick:o=>s.value=i.id},Ve(i.label),11,Zl)),64))])]),s.value==="host"?(ie(),kt(Jl,{key:0})):s.value==="storage"?(ie(),kt(kl,{key:1})):s.value==="net"?(ie(),kt(Yl,{key:2})):(ie(),kt(Ql,{key:3}))]))}};Pl(ef).mount("#app"); diff --git a/svfs-plugins-root/com.example.vue-demo/blobs/80/8030640addc4c64069ee0d5302b4884b262c0e3069d92db99940edc626538292 b/svfs-plugins-root/com.example.vue-demo/blobs/80/8030640addc4c64069ee0d5302b4884b262c0e3069d92db99940edc626538292 new file mode 100644 index 0000000..e34fc03 --- /dev/null +++ b/svfs-plugins-root/com.example.vue-demo/blobs/80/8030640addc4c64069ee0d5302b4884b262c0e3069d92db99940edc626538292 @@ -0,0 +1,14 @@ + + + + + + Vue 模板插件 + + + + + +
+ + diff --git a/svfs-plugins-root/com.example.vue-demo/manifest.json b/svfs-plugins-root/com.example.vue-demo/manifest.json new file mode 100644 index 0000000..21305e8 --- /dev/null +++ b/svfs-plugins-root/com.example.vue-demo/manifest.json @@ -0,0 +1,53 @@ +{ + "blob_dir": "blobs", + "entries": { + "assets": { + "dir": true + }, + "assets/index-CAnw4XNC.css": { + "hash": "1252ac1b43e4d94d0a608eed0c76449112bee5f79613b66e6ec8c21b981a4dae", + "mime": "text/css", + "mode": 438, + "mtime": 1789521653, + "size": 1485 + }, + "assets/index-Cbfj95Ib.js": { + "hash": "770e86ec29471f0553cb3bef36063ebcc6fcf2fc7902ccd5e5d16665a91425ad", + "mime": "text/javascript", + "mode": 438, + "mtime": 1789521653, + "size": 72828 + }, + "index.html": { + "hash": "8030640addc4c64069ee0d5302b4884b262c0e3069d92db99940edc626538292", + "mime": "text/html", + "mode": 438, + "mtime": 1789521653, + "size": 501 + }, + "plugin.json": { + "hash": "43fa3c1fed2269b7afa1872274a002ef52e9e4c97eb6c44eecb575928d8e7710", + "mime": "application/json", + "mode": 438, + "mtime": 1789467293, + "size": 287 + } + }, + "format": "svfs-objects", + "name": "packed_filesystem", + "shard": 2, + "snapshot": { + "created_time": "2026-09-16T09:33:37.202155", + "digest": "b3bd3c62b80df3f97922abca255c16035c6a546d65a7f36b29b4857670179729", + "id": 1, + "name": "packed_filesystem" + }, + "stats": { + "directories": 1, + "files": 4, + "objects": 4, + "objects_stored_size": 30200, + "total_size": 75101 + }, + "version": 1 +} \ No newline at end of file diff --git a/svfs-plugins-root/com.svfs.demo/blobs/4b/4b8c8b29f0fa76262277aa1bb90eafc1dc9951355f4c6182276ab30c6fde54bd b/svfs-plugins-root/com.svfs.demo/blobs/4b/4b8c8b29f0fa76262277aa1bb90eafc1dc9951355f4c6182276ab30c6fde54bd new file mode 100644 index 0000000..78fbd21 --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/4b/4b8c8b29f0fa76262277aa1bb90eafc1dc9951355f4c6182276ab30c6fde54bd @@ -0,0 +1,15 @@ +{ + "id": "com.svfs.demo", + "name": "SVFS 演示插件", + "version": "3.0.0", + "api_version": 3, + "entry": "index.html", + "icon": "icon.svg", + "permissions": [ + "storage", + "notify", + "title", + "net" + ], + "description": "演示插件客户端桥能力的示例(host.info / storage / notify / title / net)。" +} \ No newline at end of file diff --git a/svfs-plugins-root/com.svfs.demo/blobs/57/576a53e67d8d31de75bf09bcb5339f22b9947aa0fee2d62452c64bd1d48dab0b b/svfs-plugins-root/com.svfs.demo/blobs/57/576a53e67d8d31de75bf09bcb5339f22b9947aa0fee2d62452c64bd1d48dab0b new file mode 100644 index 0000000..b57e1ef --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/57/576a53e67d8d31de75bf09bcb5339f22b9947aa0fee2d62452c64bd1d48dab0b @@ -0,0 +1,15 @@ +{ + "id": "com.svfs.demo", + "name": "SVFS 演示插件", + "version": "3.0.0", + "api_version": 2, + "entry": "index.html", + "icon": "icon.svg", + "permissions": [ + "storage", + "notify", + "title", + "net" + ], + "description": "演示插件客户端桥能力的示例(host.info / storage / notify / title / net)。" +} \ No newline at end of file diff --git a/svfs-plugins-root/com.svfs.demo/blobs/5d/5d7687a0e06fd8071626c020943af494613c38d9df283e491b9e3147cf7602e7 b/svfs-plugins-root/com.svfs.demo/blobs/5d/5d7687a0e06fd8071626c020943af494613c38d9df283e491b9e3147cf7602e7 new file mode 100644 index 0000000..04b05dc --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/5d/5d7687a0e06fd8071626c020943af494613c38d9df283e491b9e3147cf7602e7 @@ -0,0 +1,61 @@ +/* 演示插件的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */ +"use strict"; + +var $ = function (id) { return document.getElementById(id); }; +var PLUGIN_VERSION = "1.0.0"; + +function randomColor() { + var letters = "0123456789abcdef", out = "#"; + for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)]; + return out; +} + +async function main() { + if (!window.svfsReady) { + $("missing").hidden = false; + $("version").textContent = "插件 " + PLUGIN_VERSION + "(未连接宿主)"; + return; + } + var svfs = await window.svfsReady; + + var info = await svfs.host.info(); + $("version").textContent = "插件 " + PLUGIN_VERSION + " · 桥 API v" + info.apiVersion; + $("host-info").innerHTML = ""; + [["插件 id", info.pluginId], ["宿主", info.appName + " " + info.appVersion], + ["平台", info.platform], ["来源", info.source || "-"]] + .forEach(function (row) { + var dt = document.createElement("dt"); dt.textContent = row[0]; + var dd = document.createElement("dd"); dd.textContent = row[1]; + $("host-info").append(dt, dd); + }); + + // storage:按插件隔离的键值存储,换一台机器/重装插件也不会串到别的插件 + var count = 0; + try { + count = parseInt(await svfs.storage.get("visits"), 10) || 0; + count += 1; + await svfs.storage.set("visits", count); + } catch (e) { /* 没拿到 storage 权限时静默降级 */ } + $("count").textContent = String(count); + $("reset").onclick = async function () { + await svfs.storage.set("visits", 0); + $("count").textContent = "0"; + svfs.ui.notify("计数已清零"); + }; + $("add10").onclick = async function () { + count += 10; + await svfs.storage.set("visits", count); + $("count").textContent = String(count); + }; + + $("notify").onclick = function () { + svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")"); + }; + $("paint").onclick = function () { + svfs.ui.setTitle("SVFS Client · 演示插件 " + randomColor()); + }; + + $("changelog").textContent = "初始版本"; +} + +main(); diff --git a/svfs-plugins-root/com.svfs.demo/blobs/70/70bf75a25ed8bf3404a390f37b036ef6179ca45093ca4dbd4c5685758eeaee6f b/svfs-plugins-root/com.svfs.demo/blobs/70/70bf75a25ed8bf3404a390f37b036ef6179ca45093ca4dbd4c5685758eeaee6f new file mode 100644 index 0000000..5501b48 --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/70/70bf75a25ed8bf3404a390f37b036ef6179ca45093ca4dbd4c5685758eeaee6f @@ -0,0 +1,79 @@ +/* 演示插件的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */ +"use strict"; + +var $ = function (id) { return document.getElementById(id); }; +var PLUGIN_VERSION = "2.0.0"; + +function randomColor() { + var letters = "0123456789abcdef", out = "#"; + for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)]; + return out; +} + +async function main() { + if (!window.svfsReady) { + $("missing").hidden = false; + $("version").textContent = "插件 " + PLUGIN_VERSION + "(未连接宿主)"; + return; + } + var svfs = await window.svfsReady; + + var info = await svfs.host.info(); + $("version").textContent = "插件 " + PLUGIN_VERSION + " · 桥 API v" + info.apiVersion; + $("host-info").innerHTML = ""; + [["插件 id", info.pluginId], ["宿主", info.appName + " " + info.appVersion], + ["平台", info.platform], ["来源", info.source || "-"]] + .forEach(function (row) { + var dt = document.createElement("dt"); dt.textContent = row[0]; + var dd = document.createElement("dd"); dd.textContent = row[1]; + $("host-info").append(dt, dd); + }); + + // storage:按插件隔离的键值存储,换一台机器/重装插件也不会串到别的插件 + var count = 0; + try { + count = parseInt(await svfs.storage.get("visits"), 10) || 0; + count += 1; + await svfs.storage.set("visits", count); + } catch (e) { /* 没拿到 storage 权限时静默降级 */ } + $("count").textContent = String(count); + $("reset").onclick = async function () { + await svfs.storage.set("visits", 0); + $("count").textContent = "0"; + svfs.ui.notify("计数已清零"); + }; + $("add10").onclick = async function () { + count += 10; + await svfs.storage.set("visits", count); + $("count").textContent = String(count); + }; + + $("notify").onclick = function () { + svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")"); + }; + $("paint").onclick = function () { + svfs.ui.setTitle("SVFS Client · 演示插件 " + randomColor()); + }; + + // net:宿主代发 HTTP 请求(不经过浏览器的 CORS)。相对 path 拼在 + // 客户端配置的服务器地址(base_url)后;也可以给 {url: "https://…"}。 + $("ping").onclick = async function () { + if (!svfs.net) { + $("net-out").textContent = "这个宿主没有 net 能力(需要客户端支持桥 API v2)"; + return; + } + $("net-out").textContent = "请求中…"; + try { + var res = await svfs.net.request({ method: "GET", path: "/api/ping" }); + $("net-out").textContent = "HTTP " + res.status + "\n" + + (res.bodyText || "(空响应)").slice(0, 300); + } catch (e) { + $("net-out").textContent = "请求失败: " + e.message + + "\n(先在「插件管理 → 服务器」里配置地址,服务端需提供 /api/ping)"; + } + }; + + $("changelog").textContent = "v2:新增主题色切换与变更记录"; +} + +main(); diff --git a/svfs-plugins-root/com.svfs.demo/blobs/7b/7bee0271377e51324261839349c6f59d460d3f0cf4a3d556c2d30aa089e3b8d7 b/svfs-plugins-root/com.svfs.demo/blobs/7b/7bee0271377e51324261839349c6f59d460d3f0cf4a3d556c2d30aa089e3b8d7 new file mode 100644 index 0000000..825ff8a --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/7b/7bee0271377e51324261839349c6f59d460d3f0cf4a3d556c2d30aa089e3b8d7 @@ -0,0 +1,79 @@ +/* 演示插件的前端逻辑:全部能力都走宿主注入的 window.svfs(Promise)。 */ +"use strict"; + +var $ = function (id) { return document.getElementById(id); }; +var PLUGIN_VERSION = "3.0.0"; + +function randomColor() { + var letters = "0123456789abcdef", out = "#"; + for (var i = 0; i < 6; i++) out += letters[Math.floor(Math.random() * 16)]; + return out; +} + +async function main() { + if (!window.svfsReady) { + $("missing").hidden = false; + $("version").textContent = "插件 " + PLUGIN_VERSION + "(未连接宿主)"; + return; + } + var svfs = await window.svfsReady; + + var info = await svfs.host.info(); + $("version").textContent = "插件 " + PLUGIN_VERSION + " · 桥 API v" + info.apiVersion; + $("host-info").innerHTML = ""; + [["插件 id", info.pluginId], ["宿主", info.appName + " " + info.appVersion], + ["平台", info.platform], ["来源", info.source || "-"]] + .forEach(function (row) { + var dt = document.createElement("dt"); dt.textContent = row[0]; + var dd = document.createElement("dd"); dd.textContent = row[1]; + $("host-info").append(dt, dd); + }); + + // storage:按插件隔离的键值存储,换一台机器/重装插件也不会串到别的插件 + var count = 0; + try { + count = parseInt(await svfs.storage.get("visits"), 10) || 0; + count += 1; + await svfs.storage.set("visits", count); + } catch (e) { /* 没拿到 storage 权限时静默降级 */ } + $("count").textContent = String(count); + $("reset").onclick = async function () { + await svfs.storage.set("visits", 0); + $("count").textContent = "0"; + svfs.ui.notify("计数已清零"); + }; + $("add10").onclick = async function () { + count += 10; + await svfs.storage.set("visits", count); + $("count").textContent = String(count); + }; + + $("notify").onclick = function () { + svfs.ui.notify("来自 " + info.pluginId + " 的通知(" + PLUGIN_VERSION + ")"); + }; + $("paint").onclick = function () { + svfs.ui.setTitle("SVFS Client · 演示插件 " + randomColor()); + }; + + // net:宿主代发 HTTP 请求(不经过浏览器的 CORS)。相对 path 拼在 + // 客户端配置的服务器地址(base_url)后;也可以给 {url: "https://…"}。 + $("ping").onclick = async function () { + if (!svfs.net) { + $("net-out").textContent = "这个宿主没有 net 能力(需要客户端支持桥 API v2)"; + return; + } + $("net-out").textContent = "请求中…"; + try { + var res = await svfs.net.request({ method: "GET", path: "/api/ping" }); + $("net-out").textContent = "HTTP " + res.status + "\n" + + (res.bodyText || "(空响应)").slice(0, 300); + } catch (e) { + $("net-out").textContent = "请求失败: " + e.message + + "\n(先在「插件管理 → 服务器」里配置地址,服务端需提供 /api/ping)"; + } + }; + + $("changelog").textContent = "v2:新增主题色切换与变更记录"; +} + +main(); diff --git a/svfs-plugins-root/com.svfs.demo/blobs/a8/a86a4e282266dfc54b728435fc022a9338a41345dc54fb06ca7abd3148f5f1db b/svfs-plugins-root/com.svfs.demo/blobs/a8/a86a4e282266dfc54b728435fc022a9338a41345dc54fb06ca7abd3148f5f1db new file mode 100644 index 0000000..01f1786 --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/a8/a86a4e282266dfc54b728435fc022a9338a41345dc54fb06ca7abd3148f5f1db @@ -0,0 +1,5 @@ + + + + diff --git a/svfs-plugins-root/com.svfs.demo/blobs/d0/d03a927ed676d8f302587b18db67c58a3504aa25e7c7da13717d2dafc830a6a7 b/svfs-plugins-root/com.svfs.demo/blobs/d0/d03a927ed676d8f302587b18db67c58a3504aa25e7c7da13717d2dafc830a6a7 new file mode 100644 index 0000000..2620988 --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/d0/d03a927ed676d8f302587b18db67c58a3504aa25e7c7da13717d2dafc830a6a7 @@ -0,0 +1,14 @@ +{ + "id": "com.svfs.demo", + "name": "SVFS 演示插件", + "version": "1.0.0", + "api_version": 1, + "entry": "index.html", + "icon": "icon.svg", + "permissions": [ + "storage", + "notify", + "title" + ], + "description": "演示插件客户端桥能力的示例(host.info / storage / notify / title)。" +} \ No newline at end of file diff --git a/svfs-plugins-root/com.svfs.demo/blobs/d3/d3fa3ebce65859c6e31795b0adf2a5ae9de2e7151ff12e7b7d1b0d57a3d052c7 b/svfs-plugins-root/com.svfs.demo/blobs/d3/d3fa3ebce65859c6e31795b0adf2a5ae9de2e7151ff12e7b7d1b0d57a3d052c7 new file mode 100644 index 0000000..8134ea3 --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/d3/d3fa3ebce65859c6e31795b0adf2a5ae9de2e7151ff12e7b7d1b0d57a3d052c7 @@ -0,0 +1,45 @@ + + + + + +SVFS 演示插件 + + + +
+
+ icon +
+

SVFS 演示插件

+

正在连接宿主…

+
+
+ + + +
+

宿主信息

+
加载中…
+
+ +
+

存储(storage 权限,按插件隔离)

+

这个插件被打开了 … 次

+ + +
+ +
+

通知与标题(notify / title 权限)

+ + +

+
+
+ + + diff --git a/svfs-plugins-root/com.svfs.demo/blobs/d4/d4aaec893ca84ba86e7b365ed55d18ac5f902f4090051d59b5d6779b0b657df8 b/svfs-plugins-root/com.svfs.demo/blobs/d4/d4aaec893ca84ba86e7b365ed55d18ac5f902f4090051d59b5d6779b0b657df8 new file mode 100644 index 0000000..b6fdabc --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/d4/d4aaec893ca84ba86e7b365ed55d18ac5f902f4090051d59b5d6779b0b657df8 @@ -0,0 +1,28 @@ +:root { + --bg: #16181d; --panel: #1c1f26; --border: #2b3038; + --text: #e4e7ec; --muted: #9aa3b2; --accent: #5b9dff; +} +* { box-sizing: border-box; } +body { + margin: 0; padding: 28px; background: var(--bg); color: var(--text); + font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; +} +main { max-width: 640px; margin: 0 auto; } +header { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; } +h1 { font-size: 20px; margin: 0; } +#version { color: var(--muted); margin: 2px 0 0; font-size: 13px; } +.card { + background: var(--panel); border: 1px solid var(--border); + border-radius: 8px; padding: 14px 16px; margin-bottom: 12px; +} +.card h2 { font-size: 13px; color: var(--muted); margin: 0 0 10px; font-weight: 600; } +dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; margin: 0; } +dt { color: var(--muted); } dd { margin: 0; font-family: Consolas, monospace; font-size: 12px; } +b { color: var(--accent); font-size: 16px; } +button { + background: #22262f; color: var(--text); border: 1px solid #3a4150; + border-radius: 6px; padding: 6px 14px; cursor: pointer; margin-right: 8px; +} +button:hover { border-color: var(--accent); color: var(--accent); } +.note { color: var(--muted); font-size: 12px; } +#missing { border-color: #6b552a; color: #f5b544; } diff --git a/svfs-plugins-root/com.svfs.demo/blobs/e2/e2d2bd0426577a29934007d756ba029f19652f9742ad38c70ffe5cb3a2645c35 b/svfs-plugins-root/com.svfs.demo/blobs/e2/e2d2bd0426577a29934007d756ba029f19652f9742ad38c70ffe5cb3a2645c35 new file mode 100644 index 0000000..fb4e31a --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/e2/e2d2bd0426577a29934007d756ba029f19652f9742ad38c70ffe5cb3a2645c35 @@ -0,0 +1,15 @@ +{ + "id": "com.svfs.demo", + "name": "SVFS 演示插件", + "version": "2.0.0", + "api_version": 2, + "entry": "index.html", + "icon": "icon.svg", + "permissions": [ + "storage", + "notify", + "title", + "net" + ], + "description": "演示插件客户端桥能力的示例(host.info / storage / notify / title / net)。" +} \ No newline at end of file diff --git a/svfs-plugins-root/com.svfs.demo/blobs/e8/e87bc4fd4a0c7a3dddc7919a724a45ec84dd9c99d550d5d36fd53a6047c8a701 b/svfs-plugins-root/com.svfs.demo/blobs/e8/e87bc4fd4a0c7a3dddc7919a724a45ec84dd9c99d550d5d36fd53a6047c8a701 new file mode 100644 index 0000000..9abdb68 --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/blobs/e8/e87bc4fd4a0c7a3dddc7919a724a45ec84dd9c99d550d5d36fd53a6047c8a701 @@ -0,0 +1,51 @@ + + + + + +SVFS 演示插件 + + + +
+
+ icon +
+

SVFS 演示插件

+

正在连接宿主…

+
+
+ + + +
+

宿主信息

+
加载中…
+
+ +
+

存储(storage 权限,按插件隔离)

+

这个插件被打开了 … 次

+ + +
+ +
+

接口服务(net 权限,经宿主请求服务器 API)

+ +
未请求。相对 path 会拼在客户端配置的服务器地址后。
+
+ +
+

通知与标题(notify / title 权限)

+ + +

+
+
+ + + diff --git a/svfs-plugins-root/com.svfs.demo/manifest.json b/svfs-plugins-root/com.svfs.demo/manifest.json new file mode 100644 index 0000000..ab3d0a9 --- /dev/null +++ b/svfs-plugins-root/com.svfs.demo/manifest.json @@ -0,0 +1,57 @@ +{ + "blob_dir": "blobs", + "entries": { + "app.js": { + "hash": "7bee0271377e51324261839349c6f59d460d3f0cf4a3d556c2d30aa089e3b8d7", + "mime": "text/javascript", + "mode": 438, + "mtime": 1789458308, + "size": 2955 + }, + "icon.svg": { + "hash": "a86a4e282266dfc54b728435fc022a9338a41345dc54fb06ca7abd3148f5f1db", + "mime": "image/svg+xml", + "mode": 438, + "mtime": 1789404516, + "size": 272 + }, + "index.html": { + "hash": "e87bc4fd4a0c7a3dddc7919a724a45ec84dd9c99d550d5d36fd53a6047c8a701", + "mime": "text/html", + "mode": 438, + "mtime": 1789406426, + "size": 1564 + }, + "plugin.json": { + "hash": "4b8c8b29f0fa76262277aa1bb90eafc1dc9951355f4c6182276ab30c6fde54bd", + "mime": "application/json", + "mode": 438, + "mtime": 1789458402, + "size": 334 + }, + "style.css": { + "hash": "d4aaec893ca84ba86e7b365ed55d18ac5f902f4090051d59b5d6779b0b657df8", + "mime": "text/css", + "mode": 438, + "mtime": 1789404516, + "size": 1294 + } + }, + "format": "svfs-objects", + "name": "packed_filesystem", + "shard": 2, + "snapshot": { + "created_time": "2026-09-15T14:22:29.122983", + "digest": "308fe2e39369c94be63fc0f1c638b82bfe69d616ae80a065d8bb1c23e16e4bf6", + "id": 6, + "name": "2.0.0-4" + }, + "stats": { + "directories": 0, + "files": 5, + "objects": 11, + "objects_stored_size": 9038, + "total_size": 6419 + }, + "version": 1 +} \ No newline at end of file diff --git a/tests/test_client_auth.py b/tests/test_client_auth.py new file mode 100644 index 0000000..a0ff348 --- /dev/null +++ b/tests/test_client_auth.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +"""svfs_client 纯标准库层的登录认证测试(不 import Qt)。 + +用本地 http.server 起一个假服务端(行为可用类属性调整),覆盖 +auth.login / verify_token / logout 的成功与各类失败,以及 settings +新增键(auth_token / auth_user)的持久化。 +""" + +import json +import os +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from helpers import VFSTestCase + +from svfs_client import auth, netutil +from svfs_client import paths as client_paths +from svfs_client import settings as client_settings + + +class _AuthApiHandler(BaseHTTPRequestHandler): + """假服务端:/api/login、/api/me、/api/logout;``mode`` 可注入异常行为。""" + + users = {"admin": "admin123"} + sessions = {} + mode = {} + + def _bearer(self): + header = self.headers.get("Authorization") or "" + return header[7:].strip() if header.startswith("Bearer ") else "" + + def _reply(self, code, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_json(self): + length = int(self.headers.get("Content-Length") or 0) + try: + return json.loads(self.rfile.read(length) or b"{}") + except ValueError: + return {} + + def do_POST(self): # noqa: N802 + data = self._read_json() + if self.path == "/api/login": + if self.mode.get("login") == "badjson": + raw = b"this is not json" + self.send_response(200) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + return + username = str(data.get("username") or "") + if username and self.users.get(username) == data.get("password"): + token = f"tok-{username}-{len(self.sessions)}" + self.sessions[token] = { + "username": username, + "login_time": "2026-09-15T10:00:00", + } + if self.mode.get("login") == "notoken": + self._reply(200, {"ok": True, "username": username}) + else: + self._reply(200, {"ok": True, "token": token, + "username": username, + "login_time": "2026-09-15T10:00:00"}) + else: + self._reply(401, {"detail": "用户名或密码不正确"}) + elif self.path == "/api/logout": + self.sessions.pop(self._bearer(), None) + self._reply(200, {"ok": True}) + else: + self._reply(404, {"detail": "not found"}) + + def do_GET(self): # noqa: N802 + if self.path == "/api/me": + session = self.sessions.get(self._bearer()) + if session: + self._reply(200, {"ok": True, **session}) + else: + self._reply(401, {"detail": "未登录或登录已过期"}) + else: + self._reply(404, {"detail": "not found"}) + + def log_message(self, *args): # 静音 + pass + + +class AuthApiTest(VFSTestCase): + """auth 模块对真实 HTTP 的行为(经本地假服务端)。""" + + def setUp(self): + super().setUp() + _AuthApiHandler.sessions.clear() + _AuthApiHandler.mode.clear() + self.server = ThreadingHTTPServer(("127.0.0.1", 0), _AuthApiHandler) + thread = threading.Thread(target=self.server.serve_forever, daemon=True) + thread.start() + self.addCleanup(self.server.shutdown) + self.addCleanup(self.server.server_close) + self.base = f"http://127.0.0.1:{self.server.server_address[1]}" + + def test_login_success_normalizes_base_url(self): + session = auth.login(self.base + "/", "admin", "admin123") + self.assertTrue(session["token"]) + self.assertEqual(session["username"], "admin") + self.assertEqual(session["base_url"], self.base) + self.assertEqual(session["login_time"], "2026-09-15T10:00:00") + + def test_login_bad_password(self): + with self.assertRaises(auth.AuthError) as caught: + auth.login(self.base, "admin", "wrong") + self.assertIn("不正确", str(caught.exception)) + + def test_login_requires_credentials(self): + with self.assertRaises(auth.AuthError): + auth.login(self.base, "", "admin123") + with self.assertRaises(auth.AuthError): + auth.login(self.base, "admin", "") + + def test_login_rejects_invalid_base_url(self): + with self.assertRaises(auth.AuthError): + auth.login("ftp://nope", "admin", "admin123") + + def test_login_invalid_response_body(self): + _AuthApiHandler.mode["login"] = "badjson" + with self.assertRaises(auth.AuthError) as caught: + auth.login(self.base, "admin", "admin123") + self.assertIn("响应无效", str(caught.exception)) + + def test_login_response_without_token(self): + _AuthApiHandler.mode["login"] = "notoken" + with self.assertRaises(auth.AuthError) as caught: + auth.login(self.base, "admin", "admin123") + self.assertIn("token", str(caught.exception)) + + def test_login_network_error(self): + # 停掉服务器再请求:连接层失败 → 「无法连接」 + base = self.base + self.server.shutdown() + with self.assertRaises(auth.AuthError) as caught: + auth.login(base, "admin", "admin123") + self.assertIn("无法连接服务器", str(caught.exception)) + + def test_verify_token_roundtrip(self): + token = auth.login(self.base, "admin", "admin123")["token"] + result = auth.verify_token(self.base, token) + self.assertEqual(result["username"], "admin") + self.assertEqual(result["base_url"], self.base) + self.assertEqual(result["login_time"], "2026-09-15T10:00:00") + + def test_verify_token_rejects_garbage(self): + with self.assertRaises(auth.AuthError): + auth.verify_token(self.base, "not-a-token") + with self.assertRaises(auth.AuthError) as caught: + auth.verify_token(self.base, " ") + self.assertIn("凭据", str(caught.exception)) + + def test_logout_invalidates_token(self): + token = auth.login(self.base, "admin", "admin123")["token"] + self.assertTrue(auth.verify_token(self.base, token)) + auth.logout(self.base, token) + with self.assertRaises(auth.AuthError): + auth.verify_token(self.base, token) + + def test_logout_is_best_effort(self): + # 服务器已停 / token 无效都不抛 + self.server.shutdown() + auth.logout(self.base, "whatever") + auth.logout(self.base, "") + + +class SettingsAuthTest(VFSTestCase): + """settings 新增键的缺省与持久化。""" + + def test_defaults_include_auth_keys(self): + base = client_paths.ensure_layout(self.path("data")) + data = client_settings.load(base) + self.assertEqual(data["auth_token"], "") + self.assertEqual(data["auth_user"], "") + + def test_auth_keys_roundtrip(self): + base = client_paths.ensure_layout(self.path("data")) + data = client_settings.load(base) + data.update({"base_url": "http://10.0.0.5:9000", + "auth_token": "tok", "auth_user": "admin"}) + client_settings.save(base, data) + loaded = client_settings.load(base) + self.assertEqual(loaded["auth_token"], "tok") + self.assertEqual(loaded["auth_user"], "admin") + + +class NetUtilTest(VFSTestCase): + """loopback 请求的代理绕过:系统代理黑掉 localhost 是"接口一直在 + 请求中"的经典原因。""" + + class _OkHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + body = b'{"ok": true}' + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + def setUp(self): + super().setUp() + # 全局策略与 opener 缓存跨用例残留,每个用例复位 + self.addCleanup(netutil.configure, "", "") + netutil.configure("", "") + + def test_is_loopback(self): + for url in ("http://localhost:8000/a", "http://127.0.0.1:8000/", + "http://[::1]:8000/", "http://api.localhost/x", + "HTTP://LOCALHOST/x"): + self.assertTrue(netutil.is_loopback(url), url) + for url in ("http://example.com/", "http://192.168.1.5:8000/", + "not a url"): + self.assertFalse(netutil.is_loopback(url), url) + + def test_loopback_bypasses_system_proxy(self): + import urllib.error + import urllib.request + + server = ThreadingHTTPServer(("127.0.0.1", 0), self._OkHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + base = f"http://127.0.0.1:{server.server_address[1]}" + + # 模拟系统代理指向一个死端口:默认 urlopen 会失败(走代理), + # netutil 对 loopback 强制直连 → 成功 + old_http = os.environ.get("http_proxy") + old_https = os.environ.get("https_proxy") + os.environ["http_proxy"] = "http://127.0.0.1:9" + os.environ["https_proxy"] = "http://127.0.0.1:9" + try: + # 代理环境变量已生效(默认 urllib 会把请求发往该代理)。 + # 注意:这里只查 getproxies(),绝不能真用默认 urlopen 发请求—— + # 那会把"含死代理的 opener"缓存进全局,殃及之后所有测试模块。 + proxies = urllib.request.getproxies() + self.assertEqual(proxies.get("http"), "http://127.0.0.1:9") + # netutil 对 loopback 强制直连 → 不受代理影响 + fresh = urllib.request.Request(base + "/ok") + with netutil.urlopen(fresh, timeout=3) as response: + self.assertEqual(response.status, 200) + finally: + if old_http is None: + os.environ.pop("http_proxy", None) + else: + os.environ["http_proxy"] = old_http + if old_https is None: + os.environ.pop("https_proxy", None) + else: + os.environ["https_proxy"] = old_https + + +class _FlakyHandler(BaseHTTPRequestHandler): + """前 ``fail_times`` 次请求回 503,之后回 200;记录收到的每个请求。 + + 模块级而不是塞在测试类里:嵌套类的方法体里看不到外层类作用域, + 引用它得写全限定名,很别扭。 + """ + + fail_times = 0 + hits = [] + + def _handle(self): + # 先把请求体读完再应答:不读就回错误码会让客户端在写 body 时 + # 撞上连接被重置(表现为 ConnectionResetError 而不是 HTTPError) + length = int(self.headers.get("Content-Length") or 0) + if length: + self.rfile.read(length) + _FlakyHandler.hits.append((self.command, self.path)) + if len(_FlakyHandler.hits) <= _FlakyHandler.fail_times: + self.send_response(503) + self.send_header("Content-Length", "0") + self.end_headers() + return + body = b'{"ok": true}' + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + do_GET = _handle + do_HEAD = _handle + do_POST = _handle + + def log_message(self, *args): + pass + + +class NetPolicyTest(VFSTestCase): + """代理/CA 配置、幂等重试与设置落点(P1-5 / P1-7)。""" + + def setUp(self): + super().setUp() + self.addCleanup(netutil.configure, "", "") + netutil.configure("", "") + _FlakyHandler.hits = [] + _FlakyHandler.fail_times = 0 + + def _serve(self, handler=None): + server = ThreadingHTTPServer(("127.0.0.1", 0), handler or _FlakyHandler) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + return server, f"http://127.0.0.1:{server.server_address[1]}" + + @staticmethod + def _noop_sleep(_seconds): + """不真等退避时间(真实退避从 0.3s 起,两次重试就该等 0.9s)。""" + + # ---- 重试策略 --------------------------------------------------- + + def test_idempotent_get_retries_until_success(self): + import urllib.request + + _server, base = self._serve() + _FlakyHandler.fail_times = 2 # 503、503、200 + request = urllib.request.Request(base + "/api/x") + with netutil.open_with_retry(request, timeout=5, retries=2, + sleep=self._noop_sleep) as response: + self.assertEqual(response.status, 200) + self.assertEqual(len(_FlakyHandler.hits), 3) + + def test_post_is_never_retried(self): + import urllib.error + import urllib.request + + _server, base = self._serve() + _FlakyHandler.fail_times = 99 + request = urllib.request.Request(base + "/api/order", data=b"{}", + method="POST") + with self.assertRaises(urllib.error.HTTPError) as caught: + netutil.open_with_retry(request, timeout=5, retries=3, + sleep=self._noop_sleep) + self.assertEqual(caught.exception.code, 503) + # 自动重发 POST 可能把"下单"变成两单:只允许发一次 + self.assertEqual(len(_FlakyHandler.hits), 1) + + def test_retries_exhausted_raises_last_error(self): + import urllib.error + import urllib.request + + _server, base = self._serve() + _FlakyHandler.fail_times = 99 + request = urllib.request.Request(base + "/api/x") + with self.assertRaises(urllib.error.HTTPError): + netutil.open_with_retry(request, timeout=5, retries=2, + sleep=self._noop_sleep) + self.assertEqual(len(_FlakyHandler.hits), 3) # 1 次 + 2 次重试 + + def test_non_retryable_status_is_not_retried(self): + import urllib.error + import urllib.request + + class _NotFound(BaseHTTPRequestHandler): + hits = 0 + + def do_GET(self): # noqa: N802 + _NotFound.hits += 1 + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *args): + pass + + _server, base = self._serve(_NotFound) + request = urllib.request.Request(base + "/api/x") + with self.assertRaises(urllib.error.HTTPError): + netutil.open_with_retry(request, timeout=5, retries=3, + sleep=self._noop_sleep) + self.assertEqual(_NotFound.hits, 1) # 404 重试没有意义 + + def test_transport_error_is_retried(self): + """连接层失败(拒绝连接/DNS/超时)也重试——服务器刚起来那几秒。""" + import urllib.error + import urllib.request + + attempts = [] + + class _Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def flaky(request, *, timeout): + attempts.append(1) + if len(attempts) < 3: + raise urllib.error.URLError("connection refused") + return _Response() + + original = netutil.urlopen + netutil.urlopen = flaky + self.addCleanup(setattr, netutil, "urlopen", original) + request = urllib.request.Request("http://127.0.0.1:1/api/x") + with netutil.open_with_retry(request, timeout=5, retries=2, + sleep=self._noop_sleep) as response: + self.assertEqual(response.status, 200) + self.assertEqual(len(attempts), 3) + + # ---- 代理与 CA --------------------------------------------------- + + def test_explicit_proxy_for_remote_and_loopback_stays_direct(self): + netutil.configure("http://proxy.corp:3128") + remote = {type(h).__name__: h for h in netutil._opener(False).handlers} + self.assertEqual( + remote["ProxyHandler"].proxies, + {"http": "http://proxy.corp:3128", + "https": "http://proxy.corp:3128"}, + ) + # loopback 即使配了代理也直连:opener 里干脆没有代理handler + # (ProxyHandler({}) 不生成 *_open 方法,build_opener 会丢掉它) + local = {type(h).__name__ for h in netutil._opener(True).handlers} + self.assertNotIn("ProxyHandler", local) + + def test_missing_ca_file_is_reported_and_not_applied(self): + error = netutil.configure("", self.path("nope.pem")) + self.assertIsNotNone(error) + self.assertIn("不存在", error) + self.assertEqual(netutil.current(), ("", "")) # 没生效(不假装配上了) + + def test_ca_bundle_valid_pem_installs_https_handler(self): + import ssl + + source = ssl.get_default_verify_paths().cafile + if not source or not os.path.isfile(source): + self.skipTest("本机没有可复制的默认 CA 文件(Windows 无 cafile)") + bundle = self.path("ca.pem") + with open(source, "rb") as src, open(bundle, "wb") as dst: + dst.write(src.read()) + self.assertIsNone(netutil.configure("", bundle)) + handlers = netutil._opener(False).handlers + self.assertTrue(any(type(h).__name__ == "HTTPSHandler" for h in handlers)) + + def test_bad_ca_content_degrades_without_crashing(self): + """CA 文件在但内容是垃圾:退回系统信任库,不能把客户端搞挂。""" + from svfs_client import diagnostics + + bundle = self.path("junk.pem") + with open(bundle, "w", encoding="utf-8") as handle: + handle.write("这不是一个 PEM 证书\n") + self.assertIsNone(netutil.configure("", bundle)) # 文件存在 → 配置接受 + netutil._opener(False) # 构建 opener 不抛(退回系统信任库) + self.assertIn("CA 证书无法加载", "\n".join(diagnostics.recent())) + + def test_apply_settings_reads_settings_json(self): + base = client_paths.ensure_layout(self.path("data")) + data = client_settings.load(base) + data["proxy"] = "10.0.0.9:8080" # 只写 host:port 也认 + client_settings.save(base, data) + self.assertIsNone(netutil.apply_settings(base)) + self.assertEqual(netutil.current()[0], "http://10.0.0.9:8080") + + def test_settings_net_options_fall_back_on_bad_values(self): + base = client_paths.ensure_layout(self.path("data")) + self.assertEqual(client_settings.net_options(base), + (client_settings.NET_TIMEOUT_DEFAULT, + client_settings.NET_RETRIES_DEFAULT)) + data = client_settings.load(base) + data.update({"net_timeout": "很快", "net_retries": -3}) + client_settings.save(base, data) + # 坏配置不该让网络不可用:一律落回默认值 + self.assertEqual(client_settings.net_options(base), + (client_settings.NET_TIMEOUT_DEFAULT, + client_settings.NET_RETRIES_DEFAULT)) + data.update({"net_timeout": 0, "net_retries": 0}) + client_settings.save(base, data) + # 超时 0 没意义(会把工作线程钉死)→ 用默认;重试 0 = 明确不重试 + self.assertEqual(client_settings.net_options(base), + (client_settings.NET_TIMEOUT_DEFAULT, 0)) + + def test_bridge_reads_net_options_from_provider(self): + """桥现读设置(provider 抛错时退回默认值,坏回调不该让桥瘫痪)。""" + from svfs_client.bridge import NET_TIMEOUT, SvfsBridge + + bridge = SvfsBridge("com.example.demo", ["net"], + self.path("data/storage.json"), + base_url_provider=lambda: "http://127.0.0.1:1", + net_options_provider=lambda: (3, 2)) + self.addCleanup(bridge.shutdown) + self.assertEqual(bridge._net_options(), (3, 2)) + bridge._net_options_provider = lambda: 1 / 0 + self.assertEqual(bridge._net_options(), (NET_TIMEOUT, 0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_credentials.py b/tests/test_client_credentials.py new file mode 100644 index 0000000..240d268 --- /dev/null +++ b/tests/test_client_credentials.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""登录凭据的安全存储测试(纯标准库层)。 + +重点是三件事: + +1. **不再明文落盘**:settings.json 里不出现 token,凭据文件里也不是原文; +2. **强度如实汇报**:DPAPI → 受保护;两个加密后端都不可用 → ``plain`` + 且 ``is_secure()`` 为 False(界面据此提示); +3. **换服务器不串凭据**:令牌只交还给签发它的那个 base_url。 +""" + +import base64 +import json +import os +import sys +import unittest + +from helpers import VFSTestCase + +from svfs_client import credentials, paths as client_paths, settings + +assert sys.modules.get("helpers") is not None + + +class _FakeKeyring: + """keyring 的最小替身(够 credentials 用:后端名 + 三件套)。""" + + def __init__(self): + self.store = {} + + def get_keyring(self): + return "fake-keyring" + + def set_password(self, service, username, password): + self.store[(service, username)] = password + + def get_password(self, service, username): + return self.store.get((service, username)) + + def delete_password(self, service, username): + self.store.pop((service, username), None) + + +class CredentialsTest(VFSTestCase): + BASE_URL = "http://10.0.0.5:8000" + + def setUp(self): + super().setUp() + self.base = client_paths.ensure_layout(self.path("data")) + + # ---- 通用(任何后端都要满足) ------------------------------------ + + def test_roundtrip_and_base_url_gate(self): + credentials.save(self.base, "tok-1", base_url=self.BASE_URL, + username="admin") + self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-1") + # 换服务器:旧令牌不交还(它只对签发它的服务器有效) + self.assertEqual(credentials.load(self.base, "http://other:9000"), "") + # 不指定服务器时不设门槛(调用方明确表示"就是当前这个") + self.assertEqual(credentials.load(self.base), "tok-1") + + def test_token_never_in_settings(self): + settings.save(self.base, {**settings.DEFAULTS, + "base_url": self.BASE_URL, + "auth_user": "admin"}) + credentials.save(self.base, "tok-secret", base_url=self.BASE_URL) + raw = open(client_paths.settings_file(self.base), encoding="utf-8").read() + self.assertNotIn("tok-secret", raw) + + def test_empty_token_clears(self): + credentials.save(self.base, "tok-1", base_url=self.BASE_URL) + credentials.save(self.base, "", base_url=self.BASE_URL) + self.assertEqual(credentials.load(self.base, self.BASE_URL), "") + self.assertFalse(os.path.exists(client_paths.credentials_file(self.base))) + + def test_clear_removes_credential_file(self): + credentials.save(self.base, "tok-1", base_url=self.BASE_URL) + self.assertTrue(os.path.exists(client_paths.credentials_file(self.base))) + credentials.clear(self.base) + self.assertEqual(credentials.load(self.base, self.BASE_URL), "") + self.assertFalse(os.path.exists(client_paths.credentials_file(self.base))) + + def test_corrupt_file_reads_as_logged_out(self): + credentials.save(self.base, "tok-1", base_url=self.BASE_URL) + with open(client_paths.credentials_file(self.base), "w", + encoding="utf-8") as handle: + handle.write("{ 不是 JSON") + self.assertEqual(credentials.load(self.base, self.BASE_URL), "") + + def test_migrate_moves_settings_token(self): + settings.save(self.base, {**settings.DEFAULTS, "base_url": self.BASE_URL, + "auth_token": "tok-legacy", + "auth_user": "admin"}) + self.assertEqual(credentials.migrate(self.base), + credentials.backend(self.base)) + self.assertEqual(credentials.load(self.base, self.BASE_URL), + "tok-legacy") + # 原字段清空(落盘的内容里不再有 token) + self.assertEqual(settings.load(self.base)["auth_token"], "") + raw = open(client_paths.settings_file(self.base), encoding="utf-8").read() + self.assertNotIn("tok-legacy", raw) + # 幂等:再迁一次什么也不做 + self.assertEqual(credentials.migrate(self.base), "") + + def test_migrate_without_legacy_token_is_noop(self): + settings.save(self.base, {**settings.DEFAULTS, "base_url": self.BASE_URL}) + self.assertEqual(credentials.migrate(self.base), "") + + +class PlainBackendTest(CredentialsTest): + """两个加密后端都不可用时的兜底路径(仍是原来的通用断言)。""" + + def setUp(self): + super().setUp() + # 关掉 DPAPI 与 keyring:只剩 plain + self._patch("_dpapi_usable", lambda: False) + self._patch("_keyring_usable", lambda: False) + self._patch("_keyring", lambda: None) + + def _patch(self, name, value): + original = getattr(credentials, name) + setattr(credentials, name, value) + self.addCleanup(setattr, credentials, name, original) + + def test_reports_insecure(self): + self.assertEqual(credentials.backend(self.base), "plain") + self.assertFalse(credentials.is_secure(self.base)) + self.assertIn("明文", credentials.describe(self.base)) + + def test_encryption_failure_degrades_instead_of_losing_token(self): + """加密后端当场失败(DPAPI 被策略禁、keyring 后端挂)时降级明文, + 但不能把用户刚登录拿到的令牌弄丢。""" + def boom(data, protect): + raise OSError("DPAPI 被组策略禁用") + + self._patch("_dpapi_usable", lambda: True) + self._patch("_dpapi_call", boom) + self.assertEqual( + credentials.save(self.base, "tok-x", base_url=self.BASE_URL), + "plain") + self.assertEqual(credentials.load(self.base, self.BASE_URL), "tok-x") + + +class KeyringBackendTest(CredentialsTest): + """keyring 可用(非 Windows 的常规路径)——文件里不该有令牌。""" + + def setUp(self): + super().setUp() + self.fake = _FakeKeyring() + self._patch("_dpapi_usable", lambda: False) + self._patch("_keyring", lambda: self.fake) + self._patch("_keyring_usable", lambda: True) + + def _patch(self, name, value): + original = getattr(credentials, name) + setattr(credentials, name, value) + self.addCleanup(setattr, credentials, name, original) + + def test_token_lives_in_keyring_not_in_file(self): + self.assertEqual(credentials.backend(self.base), "keyring") + self.assertTrue(credentials.is_secure(self.base)) + credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL) + self.assertEqual(credentials.load(self.base, self.BASE_URL), + "tok-keyring") + raw = open(client_paths.credentials_file(self.base), + encoding="utf-8").read() + self.assertNotIn("tok-keyring", raw) + self.assertEqual( + self.fake.store[("svfs-client", self.BASE_URL)], "tok-keyring") + + def test_load_is_empty_when_keyring_backend_disappears(self): + credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL) + self._patch("_keyring", lambda: None) + self.assertEqual(credentials.load(self.base, self.BASE_URL), "") + + def test_clear_removes_keyring_entry(self): + credentials.save(self.base, "tok-keyring", base_url=self.BASE_URL) + credentials.clear(self.base) + self.assertEqual(self.fake.store, {}) + + +@unittest.skipUnless(os.name == "nt", "DPAPI 只在 Windows 上存在") +class DpapiBackendTest(CredentialsTest): + """Windows 默认后端:文件里是密文,且只有本机本用户能解。""" + + def test_default_backend_is_dpapi(self): + self.assertEqual(credentials.backend(self.base), "dpapi") + self.assertTrue(credentials.is_secure(self.base)) + self.assertIn("DPAPI", credentials.describe(self.base)) + + def test_stored_bytes_are_ciphertext(self): + credentials.save(self.base, "tok-secret-value", base_url=self.BASE_URL) + with open(client_paths.credentials_file(self.base), "rb") as handle: + raw = handle.read() + self.assertNotIn(b"tok-secret-value", raw) + record = json.loads(raw.decode("utf-8")) + self.assertEqual(record["backend"], "dpapi") + self.assertTrue(record["token"]) + # 存的是密文的 base64,不是令牌本身 + self.assertNotEqual(base64.b64decode(record["token"]), + b"tok-secret-value") + + def test_tampered_ciphertext_reads_as_logged_out(self): + credentials.save(self.base, "tok-secret-value", base_url=self.BASE_URL) + file = client_paths.credentials_file(self.base) + record = json.load(open(file, encoding="utf-8")) + blob = bytearray(base64.b64decode(record["token"])) + blob[len(blob) // 2] ^= 0xFF # 篡改一个字节 + record["token"] = base64.b64encode(bytes(blob)).decode("ascii") + with open(file, "w", encoding="utf-8") as handle: + json.dump(record, handle) + # 解不开就当没登录(不能崩,也不能返回垃圾) + self.assertEqual(credentials.load(self.base, self.BASE_URL), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_logic.py b/tests/test_client_logic.py new file mode 100644 index 0000000..baabb6d --- /dev/null +++ b/tests/test_client_logic.py @@ -0,0 +1,744 @@ +#!/usr/bin/env python3 +"""svfs_client 纯标准库层的测试:manifest / paths / registry / installer / sample。 + +不 import Qt——这五个模块必须能在没有 PySide6 的环境里跑通(分层约定见 +src/svfs_client/__init__.py 的文档字符串)。 +""" + +import json +import os +import sys +import unittest + +from helpers import VFSTestCase, make_tree, quiet, temp_dir, write_file + +from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.publish import export_objects + +from svfs_client import PLUGIN_API_VERSION, installer, manifest as manifest_mod +from svfs_client import diagnostics, paths, registry, sample, search, settings + +# helpers.py 会把 src/ 加进 sys.path;这里只需要保证它是测试的先决导入 +assert sys.modules.get("helpers") is not None + + +# -------------------------------------------------------------------------- +# 测试用的插件包构造 +# -------------------------------------------------------------------------- + +BASE_MANIFEST = { + "id": "com.example.demo", + "name": "演示插件", + "version": "1.0.0", + "api_version": PLUGIN_API_VERSION, + "entry": "index.html", + "icon": "icon.svg", + "permissions": ["storage"], + "description": "测试用", +} + + +def build_plugin(out_db, spec, version="1.0.0", overrides=None): + """把一棵目录树打成插件包(沿用 installer 的打包路径)。""" + tree = dict(spec) + data = dict(BASE_MANIFEST) + data["version"] = version + data.update(overrides or {}) + tree["plugin.json"] = json.dumps(data, ensure_ascii=False).encode("utf-8") + with temp_dir() as src: + make_tree(src, tree) + installer._pack_tree(out_db, src, version) + return out_db + + +V1_SPEC = { + "index.html": b"v1", + "style.css": b"body { color: #eee; }", + "icon.svg": b"", + "app.js": b"console.log('v1');", +} + +V2_SPEC = { + "index.html": b"v1", # 未变 → 应共享 blob + "style.css": b"body { color: #eee; }", # 未变 → 应共享 blob + "icon.svg": b"", # 未变 → 应共享 blob + "app.js": b"console.log('v2 with more code');", + "changelog.txt": b"v2: new feature", +} + + +class ManifestTest(unittest.TestCase): + def test_valid_minimal(self): + data = manifest_mod.validate(dict(BASE_MANIFEST, icon=None)) + self.assertEqual(data["permissions"], ["storage"]) + + def test_net_permission_accepted(self): + data = manifest_mod.validate( + dict(BASE_MANIFEST, permissions=["net", "storage", "net"]) + ) + self.assertEqual(data["permissions"], ["net", "storage"]) + + def test_missing_field(self): + for field in ("id", "name", "version", "entry", "api_version"): + broken = {k: v for k, v in BASE_MANIFEST.items() if k != field} + with self.assertRaisesRegex(manifest_mod.ManifestError, field): + manifest_mod.validate(broken) + + def test_bad_id(self): + for bad in ("demo", "Demo.App", "com/example", "-com.example", "", 123, + "a" * 64 + ".x"): + with self.assertRaises(manifest_mod.ManifestError, msg=repr(bad)): + manifest_mod.validate(dict(BASE_MANIFEST, id=bad)) + + def test_wrong_api_version(self): + with self.assertRaisesRegex(manifest_mod.ManifestError, "api_version"): + manifest_mod.validate(dict(BASE_MANIFEST, api_version=99)) + + def test_older_api_version_accepted(self): + # 旧清单跑在新宿主上没问题:api_version 是下限匹配,不是相等匹配 + data = manifest_mod.validate( + dict(BASE_MANIFEST, api_version=PLUGIN_API_VERSION - 1) + ) + self.assertEqual(data["api_version"], PLUGIN_API_VERSION - 1) + + def test_api_version_must_be_positive(self): + for bad in (0, -1): + with self.assertRaisesRegex(manifest_mod.ManifestError, "api_version"): + manifest_mod.validate(dict(BASE_MANIFEST, api_version=bad)) + + def test_permissions_normalized(self): + data = manifest_mod.validate(dict(BASE_MANIFEST, permissions=["notify", "storage", "notify"])) + self.assertEqual(data["permissions"], ["notify", "storage"]) + + def test_net_hosts_optional_and_normalized(self): + # 不写 = 不限制(空列表) + self.assertEqual(manifest_mod.validate(BASE_MANIFEST)["net_hosts"], []) + data = manifest_mod.validate({ + **BASE_MANIFEST, "permissions": ["net"], + "net_hosts": ["API.Example.com", "*.Corp.Example.", "api.example.com"], + }) + self.assertEqual(data["net_hosts"], + ["*.corp.example", "api.example.com"]) + + def test_net_hosts_rejects_bad_input(self): + with self.assertRaisesRegex(manifest_mod.ManifestError, "net_hosts"): + manifest_mod.validate({**BASE_MANIFEST, "permissions": ["net"], + "net_hosts": []}) + with self.assertRaisesRegex(manifest_mod.ManifestError, "net_hosts"): + manifest_mod.validate({**BASE_MANIFEST, "permissions": ["net"], + "net_hosts": ["http://x:8000/api"]}) + # 声明了白名单却没有 net 权限:几乎肯定写错了,安装时就报出来 + with self.assertRaisesRegex(manifest_mod.ManifestError, "net 权限"): + manifest_mod.validate({**BASE_MANIFEST, "permissions": ["storage"], + "net_hosts": ["api.example.com"]}) + + def test_host_allowed(self): + # 空 = 不限制 + self.assertTrue(manifest_mod.host_allowed("any.example", [])) + self.assertTrue(manifest_mod.host_allowed("api.example.com", + ["api.example.com"])) + self.assertFalse(manifest_mod.host_allowed("evil.example", + ["api.example.com"])) + # *. 后缀匹配:子域与其本身都算,但不吃 "notcorp.example" + patterns = ["*.corp.example"] + self.assertTrue(manifest_mod.host_allowed("a.corp.example", patterns)) + self.assertTrue(manifest_mod.host_allowed("corp.example", patterns)) + self.assertFalse(manifest_mod.host_allowed("notcorp.example", patterns)) + # 大小写不敏感 + self.assertTrue(manifest_mod.host_allowed("API.Example.com", + ["api.example.com"])) + + def test_bad_permission(self): + with self.assertRaises(manifest_mod.ManifestError): + manifest_mod.validate(dict(BASE_MANIFEST, permissions=["filesystem"])) + + def test_from_json_bom(self): + text = b"\xef\xbb\xbf" + json.dumps(BASE_MANIFEST).encode("utf-8") + data = manifest_mod.from_json(text.decode("utf-8")) + self.assertEqual(data["id"], BASE_MANIFEST["id"]) + + def test_from_json_broken(self): + with self.assertRaises(manifest_mod.ManifestError): + manifest_mod.from_json("{oops") + + +class PathsTest(VFSTestCase): + def test_explicit_data_dir(self): + self.assertEqual(paths.data_dir("D:/ somewhere/x"), os.path.abspath("D:/ somewhere/x")) + + def test_layout_and_id_paths(self): + base = paths.ensure_layout(self.path("data")) + for sub in ("plugins", "storage", "web"): + self.assertTrue(os.path.isdir(os.path.join(base, sub))) + pkg = paths.plugin_package(base, "com.example.demo") + self.assertTrue(pkg.endswith(os.path.join("plugins", "com.example.demo") + os.sep + "plugin.svfs")) + self.assertEqual( + paths.storage_file(base, "com.example.demo"), + os.path.join(base, "storage", "com.example.demo.json"), + ) + + def test_id_with_slash_rejected(self): + with self.assertRaises(ValueError): + paths.plugin_package(self.tmp, "../evil") + + def test_dir_size_and_clear(self): + make_tree(self.path("cache"), {"a.bin": b"x" * 10, "sub/b.bin": b"y" * 5}) + self.assertEqual(paths.dir_size(self.path("cache")), 15) + self.assertEqual(paths.clear_dir_contents(self.path("cache")), 2) + self.assertTrue(os.path.isdir(self.path("cache"))) # 目录本身保留 + self.assertEqual(paths.dir_size(self.path("cache")), 0) + + def test_dir_helpers_tolerate_missing_dir(self): + self.assertEqual(paths.dir_size(self.path("nope")), 0) + self.assertEqual(paths.clear_dir_contents(self.path("nope")), 0) + + def test_object_cache_dir_is_absolute(self): + self.assertTrue(os.path.isabs(paths.object_cache_dir())) + + def test_data_dir_pointer_file(self): + # 默认:无指针 → 默认目录 + self.assertEqual(paths.resolve_data_dir(), paths.data_dir()) + # 写指针(目标目录必须真实存在)→ 生效 + target = paths.ensure_layout(self.path("alt-data")) + self.assertIsNone(paths.set_data_dir(target)) + self.assertEqual(paths.resolve_data_dir(), os.path.abspath(target)) + # --data 命令行参数最优先 + self.assertEqual( + paths.resolve_data_dir(self.path("cmdline")), + os.path.abspath(self.path("cmdline")), + ) + # 指向不存在的目录 → 回退默认(坏指针不让客户端起不来) + os.unlink(paths.data_dir_file()) + self.assertEqual(paths.resolve_data_dir(), paths.data_dir()) + + def test_set_data_dir_clear_and_invalid(self): + target = paths.ensure_layout(self.path("alt-data")) + paths.set_data_dir(target) + # 空串 = 清除重定向 + self.assertIsNone(paths.set_data_dir("")) + self.assertEqual(paths.resolve_data_dir(), paths.data_dir()) + # 目录不存在 → 报错且不写指针 + error = paths.set_data_dir(self.path("nope")) + self.assertIn("不存在", error) + self.assertEqual(paths.resolve_data_dir(), paths.data_dir()) + + +class RegistryTest(VFSTestCase): + def test_empty_load(self): + data = registry.load(self.tmp) + self.assertEqual(data["plugins"], {}) + + def test_roundtrip(self): + data = registry.load(self.tmp) + registry.put(data, {"id": "com.a.b", "name": "A", "enabled": True}) + registry.save(self.tmp, data) + again = registry.load(self.tmp) + self.assertEqual(again["plugins"]["com.a.b"]["name"], "A") + self.assertEqual(registry.get(again, "com.a.b")["enabled"], True) + self.assertIsNone(registry.remove(again, "nope")) + self.assertEqual(registry.remove(again, "com.a.b")["id"], "com.a.b") + + +class SettingsTest(VFSTestCase): + """base_url / 登录凭据的规范化、持久化与地址解析。""" + + def test_normalize(self): + self.assertEqual(settings.normalize_base_url(" http://srv:8000/ "), "http://srv:8000") + self.assertEqual(settings.normalize_base_url("https://a.b/c/"), "https://a.b/c") + self.assertEqual(settings.normalize_base_url(None), "") + self.assertEqual(settings.normalize_base_url(""), "") + for bad in ("ftp://x", "srv:8000", "//host", "http:/miss"): + with self.assertRaisesRegex(ValueError, "http", msg=bad): + settings.normalize_base_url(bad) + + def test_load_defaults_and_roundtrip(self): + data = settings.load(self.tmp) + # 缺文件 → 默认(含登录凭据/启动直显/缓存目录/网络/日志) + self.assertEqual( + data, + {"base_url": "", "auth_token": "", "auth_user": "", + "startup_plugin": "", "cache_dir": "", + "net_timeout": settings.NET_TIMEOUT_DEFAULT, + "net_retries": settings.NET_RETRIES_DEFAULT, + "proxy": "", "ca_bundle": "", "log_to_file": True}, + ) + data["base_url"] = "http://10.0.0.5:9000" + settings.save(self.tmp, data) + self.assertEqual(settings.load(self.tmp)["base_url"], "http://10.0.0.5:9000") + + def test_broken_settings_file_is_ignored(self): + write_file(paths.settings_file(self.tmp), b"{broken") + self.assertEqual( + settings.load(self.tmp), + {"base_url": "", "auth_token": "", "auth_user": "", + "startup_plugin": "", "cache_dir": "", + "net_timeout": settings.NET_TIMEOUT_DEFAULT, + "net_retries": settings.NET_RETRIES_DEFAULT, + "proxy": "", "ca_bundle": "", "log_to_file": True}, + ) + + def test_resolve_cache_dir(self): + # 未配置 → 系统默认 + self.assertEqual(settings.resolve_cache_dir(self.tmp), + paths.object_cache_dir()) + # 配置了(支持 ~ 与相对展开)→ 生效 + data = settings.load(self.tmp) + data["cache_dir"] = "~/svfs-cache" + settings.save(self.tmp, data) + resolved = settings.resolve_cache_dir(self.tmp) + self.assertTrue(os.path.isabs(resolved)) + self.assertIn("svfs-cache", resolved) + self.assertNotIn("~", resolved) + + def test_resolve_manifest_url(self): + base = "http://srv:8000" + # 完整地址原样通过 + self.assertEqual( + settings.resolve_manifest_url(base, "http://other/x/manifest.json"), + "http://other/x/manifest.json", + ) + # 插件 id → 约定子目录 + self.assertEqual( + settings.resolve_manifest_url(base, "com.example.clock"), + "http://srv:8000/com.example.clock/manifest.json", + ) + # 相对路径 + self.assertEqual( + settings.resolve_manifest_url(base, "/clock/stable/manifest.json"), + "http://srv:8000/clock/stable/manifest.json", + ) + # 未配置服务器时相对地址无从解析 + with self.assertRaisesRegex(ValueError, "服务器"): + settings.resolve_manifest_url("", "com.example.clock") + with self.assertRaisesRegex(ValueError, "请填写"): + settings.resolve_manifest_url(base, " ") + + def test_resolve_net_url(self): + base = "http://srv:8000" + self.assertEqual( + settings.resolve_net_url(base, url="https://api.x/v1"), "https://api.x/v1" + ) + self.assertEqual( + settings.resolve_net_url(base, path="/api/ping"), "http://srv:8000/api/ping" + ) + with self.assertRaises(ValueError): + settings.resolve_net_url("", path="/api/ping") + with self.assertRaises(ValueError): + settings.resolve_net_url(base) + with self.assertRaisesRegex(ValueError, "http"): + settings.resolve_net_url(base, url="file:///etc/passwd") + + +class InstallerTest(VFSTestCase): + def setUp(self): + super().setUp() + self.base = paths.ensure_layout(self.path("data")) + self.pid = BASE_MANIFEST["id"] + + def install(self, spec, version="1.0.0", **kwargs): + pkg = self.path(f"plugin-{version}.svfs") + build_plugin(pkg, spec, version=version) + with quiet(): + return installer.install_from_file(pkg, base=self.base, **kwargs) + + def test_first_install(self): + entry = self.install(V1_SPEC) + self.assertTrue(entry["enabled"]) + self.assertEqual(entry["version"], "1.0.0") + self.assertEqual(entry["source"]["type"], "local") + target = paths.plugin_package(self.base, self.pid) + self.assertTrue(os.path.exists(target)) + # 注册表落盘,列表可读 + self.assertEqual(installer.list_installed(self.base)[0]["id"], self.pid) + + def test_reinstall_is_upgrade(self): + self.install(V1_SPEC) + entry = self.install(V2_SPEC, version="2.0.0") + self.assertEqual(entry["version"], "2.0.0") + self.assertEqual([s["name"] for s in entry["snapshots"]], ["1.0.0", "2.0.0"]) + self.assertEqual(entry["current_snapshot_id"], entry["snapshots"][1]["id"]) + + # 快照即版本:新旧内容都在一个包里 + vfs = SQLiteVFS(paths.plugin_package(self.base, self.pid), readonly=True) + try: + self.assertEqual(vfs.read_file("app.js"), V2_SPEC["app.js"]) + vfs.use_snapshot(1) + self.assertEqual(vfs.read_file("app.js"), V1_SPEC["app.js"]) + # 未变的三个文件共享 blob:8 个 blob < 两个快照的条目总数 + self.assertLess(len(vfs.list_blobs()), 5 + 6) + finally: + vfs.close() + + def test_upgrade_rollback(self): + self.install(V1_SPEC) + self.install(V2_SPEC, version="2.0.0") + entry = installer.use_version(self.base, self.pid, 1) + self.assertEqual(entry["version"], "1.0.0") + self.assertEqual(entry["current_snapshot_id"], 1) + # 回滚对协议层生效:按注册表指向的快照开包,看到的是旧内容 + registry_data = registry.load(self.base) + sid = registry_data["plugins"][self.pid]["current_snapshot_id"] + vfs = SQLiteVFS(paths.plugin_package(self.base, self.pid), readonly=True, + snapshot_id=sid) + try: + self.assertEqual(vfs.read_file("app.js"), V1_SPEC["app.js"]) + finally: + vfs.close() + + def test_rollback_to_missing_snapshot(self): + self.install(V1_SPEC) + with self.assertRaises(installer.InstallError): + installer.use_version(self.base, self.pid, 42) + + def test_uninstall(self): + self.install(V1_SPEC) + # 留一份键值存储,卸载时应一并删除 + write_file(paths.storage_file(self.base, self.pid), b"{}") + installer.uninstall(self.base, self.pid) + self.assertFalse(os.path.exists(paths.plugin_package(self.base, self.pid))) + self.assertFalse(os.path.exists(paths.storage_file(self.base, self.pid))) + self.assertEqual(installer.list_installed(self.base), []) + + def test_reject_package_without_manifest(self): + src = make_tree(self.path("bare"), {"index.html": b""}) + pkg = self.path("bare.svfs") + with quiet(): + vfs = SQLiteVFS(pkg, compress=True) + try: + from sqlite_vfs.folder_packer import FolderPacker + + FolderPacker(vfs).pack_folder(src, incremental=True) + finally: + vfs.close() + with quiet(), self.assertRaisesRegex(installer.InstallError, "plugin.json"): + installer.install_from_file(pkg, base=self.base) + + def test_reject_tampered_package(self): + pkg = self.path("plugin-1.0.0.svfs") + build_plugin(pkg, V1_SPEC) + # 绕过 API 改元数据但不 recount():摘要对不上,verify() 应当拦住它 + vfs = SQLiteVFS(pkg) + try: + vfs.conn.execute( + "UPDATE files SET file_size = file_size + 1 WHERE is_directory = 0" + ) + vfs.conn.commit() + finally: + vfs.close() + with quiet(), self.assertRaises(installer.InstallError): + installer.install_from_file(pkg, base=self.base) + + def test_reject_wrong_api_version(self): + pkg = self.path("future.svfs") + build_plugin(pkg, V1_SPEC, overrides={"api_version": PLUGIN_API_VERSION + 1}) + with quiet(), self.assertRaisesRegex(installer.InstallError, "api_version"): + installer.install_from_file(pkg, base=self.base) + + def test_accept_older_api_version(self): + # 语义同 manifest:要求 ≤ 宿主版本即可安装(升级宿主不废掉旧插件) + pkg = self.path("older.svfs") + build_plugin(pkg, V1_SPEC, overrides={"api_version": PLUGIN_API_VERSION - 1}) + with quiet(): + entry = installer.install_from_file(pkg, base=self.base) + self.assertEqual(entry["id"], self.pid) + + def test_set_enabled(self): + self.install(V1_SPEC) + entry = installer.set_enabled(self.base, self.pid, False) + self.assertFalse(entry["enabled"]) + self.assertFalse(registry.load(self.base)["plugins"][self.pid]["enabled"]) + with self.assertRaises(installer.InstallError): + installer.set_enabled(self.base, "nope.app", True) + + def test_install_progress(self): + pkg = self.path("plugin-1.0.0.svfs") + build_plugin(pkg, V1_SPEC) + events = [] + with quiet(): + installer.install_from_file(pkg, base=self.base, + progress=lambda d, t, label: events.append((d, t, label))) + self.assertEqual(events, []) # 首次安装是整包复制,不走逐文件进度 + + v2 = self.path("plugin-2.0.0.svfs") + build_plugin(v2, V2_SPEC, version="2.0.0") + events = [] + with quiet(): + installer.install_from_file(v2, base=self.base, + progress=lambda d, t, label: events.append((d, t, label))) + self.assertTrue(events) + self.assertEqual(max(d for d, _, _ in events), max(t for _, t, _ in events)) + + +class RemoteInstallTest(VFSTestCase): + def setUp(self): + super().setUp() + self.base = paths.ensure_layout(self.path("data")) + self.pid = BASE_MANIFEST["id"] + + def test_install_from_local_objects(self): + src = self.path("plugin-1.0.0.svfs") + build_plugin(src, V1_SPEC) + objects = export_objects(src, self.path("public")) + + entry = installer.install_from_url( + os.path.join(objects["out_dir"], "manifest.json"), base=self.base + ) + self.assertEqual(entry["version"], "1.0.0") + self.assertEqual(entry["source"]["type"], "url") + # 内容逐字节一致 + vfs = SQLiteVFS(paths.plugin_package(self.base, self.pid), readonly=True) + try: + self.assertEqual(vfs.read_file("index.html"), V1_SPEC["index.html"]) + self.assertEqual(vfs.read_file("app.js"), V1_SPEC["app.js"]) + finally: + vfs.close() + + def test_remote_upgrade(self): + src1 = self.path("plugin-1.0.0.svfs") + build_plugin(src1, V1_SPEC) + with quiet(): + installer.install_from_file(src1, base=self.base) + + src2 = self.path("plugin-2.0.0.svfs") + build_plugin(src2, V2_SPEC, version="2.0.0") + objects = export_objects(src2, self.path("p2")) + entry = installer.install_from_url( + os.path.join(objects["out_dir"], "manifest.json"), base=self.base + ) + self.assertEqual(entry["version"], "2.0.0") + self.assertEqual([s["name"] for s in entry["snapshots"]], ["1.0.0", "2.0.0"]) + + +class SampleTest(VFSTestCase): + def setUp(self): + super().setUp() + self.base = paths.ensure_layout(self.path("data")) + + def test_sample_source_is_valid_plugin(self): + with temp_dir() as src: + sample.write_source(src, version=1) + with open(os.path.join(src, "plugin.json"), "rb") as f: + data = manifest_mod.from_json(f.read().decode("utf-8")) + self.assertEqual(data["id"], sample.PLUGIN_ID) + for name in ("index.html", "app.js", "style.css", "icon.svg"): + self.assertTrue(os.path.exists(os.path.join(src, name)), name) + with open(os.path.join(src, "app.js"), encoding="utf-8") as f: + self.assertIn("svfsReady", f.read()) + + def test_sample_build_and_upgrade(self): + out = self.path("sample.svfs") + _, v = sample.build(out) + self.assertEqual(v, "1.0.0") + with quiet(): + installer.install_from_file(out, base=self.base) + + _, v = sample.build(out) + self.assertEqual(v, "2.0.0") + with quiet(): + entry = installer.install_from_file(out, base=self.base) + self.assertEqual(entry["version"], "2.0.0") + self.assertEqual([s["name"] for s in entry["snapshots"]], ["1.0.0", "2.0.0"]) + + # 升级后的包必须能通过权威校验 + with quiet(): + report = installer._verify_content(out) + self.assertTrue(report["ok"], report["errors"]) + + +class VersionCompareTest(VFSTestCase): + """更新检查用的版本比较(installer.is_newer_version)。""" + + def test_newer(self): + self.assertTrue(installer.is_newer_version("2.0.0", "1.0.0")) + self.assertTrue(installer.is_newer_version("1.10.0", "1.9.0")) + self.assertTrue(installer.is_newer_version("1.0.1", "1.0.0")) + self.assertTrue(installer.is_newer_version("2.0.0", "1.0.0-2")) + + def test_not_newer(self): + for remote, current in (("1.0.0", "1.0.0"), ("1.0.0", "2.0.0"), + ("1.0.0", "1.0.0-2")): + self.assertFalse(installer.is_newer_version(remote, current), + f"{remote} vs {current}") + + def test_unparsable_is_conservative(self): + self.assertFalse(installer.is_newer_version("", "1.0.0")) + self.assertFalse(installer.is_newer_version(None, "1.0.0")) + + def test_client_update_status(self): + payload = {"version": "0.9.0", "url": "https://x/SVFS-Client.zip", + "notes": "修了几个坑"} + result = installer.client_update_status(payload, "0.1.0") + self.assertTrue(result["update"]) + self.assertEqual(result["latest"], "0.9.0") + self.assertEqual(result["url"], "https://x/SVFS-Client.zip") + self.assertEqual(result["notes"], "修了几个坑") + self.assertEqual(result["current"], "0.1.0") + # 不比当前新 → 不提示 + self.assertFalse( + installer.client_update_status(payload, "0.9.0")["update"]) + self.assertFalse( + installer.client_update_status(payload, "1.0.0")["update"]) + + def test_client_update_status_tolerates_missing_or_bad_payload(self): + for payload in (None, [], "x", {}, {"url": "https://x"}): + result = installer.client_update_status(payload, "0.1.0") + self.assertFalse(result["update"], payload) + self.assertEqual(result["latest"], "") + self.assertEqual(result["current"], "0.1.0") + + +class DiagnosticsTest(VFSTestCase): + """诊断日志:环形缓冲、报告、自身绝不抛异常。""" + + def setUp(self): + super().setUp() + diagnostics.clear() + # 落盘是全局开关:默认关掉(否则会把日志写进别的用例的临时目录) + diagnostics.configure(None) + + def tearDown(self): + diagnostics.clear() + diagnostics.configure(None) + super().tearDown() + + def test_log_and_recent(self): + diagnostics.log("第一条", "worker") + diagnostics.log("第二条", "net") + lines = diagnostics.recent() + self.assertEqual(len(lines), 2) + self.assertIn("[worker] 第一条", lines[0]) + self.assertIn("[net] 第二条", lines[1]) + self.assertEqual(diagnostics.recent(1), [lines[-1]]) + + def test_ring_limit(self): + for i in range(diagnostics.LOG_LIMIT + 30): + diagnostics.log(f"第 {i} 条") + lines = diagnostics.recent() + self.assertEqual(len(lines), diagnostics.LOG_LIMIT) + # 最旧的被挤掉,最新的一定在 + self.assertIn(f"第 {diagnostics.LOG_LIMIT + 29} 条", lines[-1]) + self.assertNotIn("第 0 条", lines[0]) + + def test_clear(self): + diagnostics.log("x") + diagnostics.clear() + self.assertEqual(diagnostics.recent(), []) + + def test_log_never_raises(self): + # 日志服务于排障,不能反过来把调用方搞挂 + diagnostics.log(None) # 非字符串 message + diagnostics.log("ok", object()) # 非字符串 source + self.assertTrue(diagnostics.recent()) + + def test_report_contains_env_and_logs(self): + diagnostics.log("页面报错: boom", "console:com.svfs.demo") + text = diagnostics.report({"数据目录": "C:/x", "Qt 版本": "6.6"}) + self.assertIn("SVFS Client 诊断报告", text) + self.assertIn("数据目录: C:/x", text) + self.assertIn("Qt 版本: 6.6", text) + self.assertIn("页面报错: boom", text) + self.assertIn("console:com.svfs.demo", text) + + +class DiagnosticsFileTest(VFSTestCase): + """诊断日志落盘与轮转:崩了/重启之后还能回看现场。""" + + def setUp(self): + super().setUp() + diagnostics.clear() + self.logs = self.path("logs") + self.addCleanup(diagnostics.configure, None) + + def _log_file(self): + return os.path.join(self.logs, diagnostics.LOG_FILE_NAME) + + def test_log_is_appended_to_file(self): + diagnostics.configure(self.logs) + self.assertEqual(diagnostics.log_path(), self._log_file()) + diagnostics.log("插件页面报错", "console:com.svfs.demo") + with open(self._log_file(), encoding="utf-8") as handle: + text = handle.read() + self.assertIn("[console:com.svfs.demo] 插件页面报错", text) + # 报告里也带上日志文件位置(贴给开发者时知道去哪儿找全量日志) + self.assertIn(diagnostics.log_path(), diagnostics.report({})) + + def test_configure_none_stops_writing(self): + diagnostics.configure(self.logs) + diagnostics.log("第一条") + diagnostics.configure(None) + self.assertEqual(diagnostics.log_path(), "") + diagnostics.log("第二条(不落盘)") + with open(self._log_file(), encoding="utf-8") as handle: + text = handle.read() + self.assertIn("第一条", text) + self.assertNotIn("不落盘", text) + # 内存那份照旧(导出报告仍然有它) + self.assertIn("第二条(不落盘)", diagnostics.recent()[-1]) + + def test_rotation_keeps_size_bounded(self): + diagnostics.configure(self.logs, max_bytes=1024, backups=2) + for index in range(200): + diagnostics.log(f"第 {index} 行,凑一点长度好触发轮转") + files = sorted(os.listdir(self.logs)) + self.assertIn(diagnostics.LOG_FILE_NAME, files) + self.assertIn(diagnostics.LOG_FILE_NAME + ".1", files) + # 份数封顶:最多 1 个当前文件 + backups 个历史文件 + self.assertLessEqual(len(files), 1 + 2) + # 最新的日志一定还在当前文件里;总量有界(不会无限长) + with open(self._log_file(), encoding="utf-8") as handle: + self.assertIn("第 199 行", handle.read()) + total = sum(os.path.getsize(os.path.join(self.logs, name)) + for name in files) + self.assertLess(total, 1024 * (1 + 2) * 4) + + def test_unusable_log_dir_falls_back_to_memory_only(self): + # 目标是个文件(不是目录):建不出来 → 只留内存,不能抛 + blocker = self.path("not-a-dir") + write_file(blocker, b"x") + diagnostics.configure(blocker) + self.assertEqual(diagnostics.log_path(), "") + diagnostics.log("还能记") + self.assertIn("还能记", diagnostics.recent()[-1]) + + +class SearchTest(unittest.TestCase): + """侧栏搜索的模糊匹配(纯标准库)。""" + + def test_empty_query_matches_everything(self): + self.assertEqual(search.score("", "任意"), 0.0) + self.assertTrue(search.matches(" ", "任意")) + + def test_contains_ignores_case(self): + self.assertTrue(search.matches("svfs", "SVFS 桥调试台", "com.svfs.demo")) + self.assertTrue(search.matches("SVFS", "svfs demo")) + self.assertTrue(search.matches("桥", "SVFS 桥调试台", "com.svfs.demo")) + + def test_contains_beats_acronym(self): + # "clock" 包含匹配;"clk" 只能缩写匹配 —— 包含分应更高 + contains = search.score("clo", "com.example.clock") + acronym = search.score("clo", "c-l-o") + self.assertIsNotNone(contains) + self.assertIsNotNone(acronym) + self.assertGreater(contains, acronym) + + def test_acronym_matches_subsequence(self): + self.assertTrue(search.matches("csd", "SVFS 桥调试台", "com.svfs.demo")) + self.assertTrue(search.matches("cec", "clock", "com.example.clock")) + + def test_position_matters(self): + early = search.score("ab", "ab-xyz") + late = search.score("ab", "xyz-abcdef") + self.assertGreater(early, late) + + def test_no_match_returns_none(self): + self.assertIsNone(search.score("zzz", "SVFS 桥调试台", "com.svfs.demo")) + # 字符顺序不对 → 不算缩写匹配 + self.assertIsNone(search.score("dcs", "SVFS", "com.svfs.demo")) + + def test_matches_id_or_name(self): + # 显示名不匹配但 id 匹配 + self.assertTrue(search.matches("demo", "调试台", "com.svfs.demo")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_login_ui.py b/tests/test_client_login_ui.py new file mode 100644 index 0000000..768966b --- /dev/null +++ b/tests/test_client_login_ui.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""登录界面 / 启动动画 / 桥 token 注入的界面测试(offscreen 渲染)。 + +约定与 test_client_ui.py 相同:``QT_QPA_PLATFORM=offscreen`` 必须在任何 +PySide6 导入之前设置;没装 PySide6 时整个模块干净跳过。 +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox --disable-gpu") + +import json +import sys +import tempfile +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from helpers import VFSTestCase + +try: + from PySide6.QtCore import QSettings, QTimer + from PySide6.QtWidgets import QApplication + + HAVE_QT = True +except ImportError: + HAVE_QT = False + +if HAVE_QT: + from svfs_client import PLUGIN_API_VERSION, auth as client_auth + from svfs_client import credentials as client_credentials + from svfs_client import paths as client_paths, settings as client_settings + from svfs_client.app import create_app + from svfs_client.bridge import SvfsBridge + from svfs_client.login import SPLASH_MIN_SECONDS, LoginWindow, SplashWindow, StartupFlow + + +class _AuthApiHandler(BaseHTTPRequestHandler): + """登录流程测试的假服务端(含回显 Authorization 的 /api/whoami)。""" + + users = {"admin": "admin123", "bob": "hunter2"} + sessions = {} + + def _bearer(self): + header = self.headers.get("Authorization") or "" + return header[7:].strip() if header.startswith("Bearer ") else "" + + def _reply(self, code, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length") or 0) + try: + data = json.loads(self.rfile.read(length) or b"{}") + except ValueError: + data = {} + if self.path == "/api/login": + username = str(data.get("username") or "") + if username and self.users.get(username) == data.get("password"): + token = f"tok-{username}-{len(self.sessions)}" + self.sessions[token] = username + self._reply(200, {"ok": True, "token": token, "username": username}) + else: + self._reply(401, {"detail": "用户名或密码不正确"}) + elif self.path == "/api/logout": + self.sessions.pop(self._bearer(), None) + self._reply(200, {"ok": True}) + else: + self._reply(404, {"detail": "not found"}) + + def do_GET(self): # noqa: N802 + if self.path == "/api/me": + username = self.sessions.get(self._bearer()) + if username: + self._reply(200, {"ok": True, "username": username}) + else: + self._reply(401, {"detail": "未登录或登录已过期"}) + elif self.path == "/api/whoami": + # 不做校验,回显 Authorization 头:验证宿主是否注入了 token + self._reply(200, {"authorization": self.headers.get("Authorization") or ""}) + else: + self._reply(404, {"detail": "not found"}) + + def log_message(self, *args): # 静音 + pass + + +def start_auth_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _AuthApiHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, f"http://127.0.0.1:{server.server_address[1]}" + + +def stop_auth_server(server): + server.shutdown() + server.server_close() + + +def pump(app, predicate=None, timeout=15.0, interval=0.02, settle=0.08): + """跑事件循环:predicate 为 None 时跑 settle 秒;否则等到谓词成立或超时。""" + deadline = time.monotonic() + timeout + if predicate is None: + end = time.monotonic() + settle + while time.monotonic() < end: + app.processEvents() + time.sleep(interval) + return True + while time.monotonic() < deadline: + app.processEvents() + time.sleep(interval) + if predicate(): + end = time.monotonic() + settle + while time.monotonic() < end: + app.processEvents() + time.sleep(interval) + return True + return False + + +if HAVE_QT: + + _APP = None + + def setUpModule(): + global _APP + QSettings.setDefaultFormat(QSettings.Format.IniFormat) + QSettings.setPath( + QSettings.Format.IniFormat, QSettings.Scope.UserScope, + tempfile.mkdtemp(prefix="svfs_client_login_settings_"), + ) + _APP = QApplication.instance() or create_app([sys.argv[0]]) + _APP.setQuitOnLastWindowClosed(False) + + class SplashTest(VFSTestCase): + """启动动画:渲染、状态文字、最短展示时长与结束回调。""" + + def test_renders_and_finishes_after_min_time(self): + splash = SplashWindow() + closed = [] + splash.closed.connect(lambda: closed.append(True)) + started = time.monotonic() + splash.show() + pump(_APP, settle=0.2) # 让动画跑一会儿(paintEvent 至少走几帧) + splash.set_status("正在验证登录…") + self.assertEqual(splash._status, "正在验证登录…") + splash.finish() + self.assertTrue(pump(_APP, predicate=lambda: bool(closed), timeout=10), + "splash 没有在超时内关闭") + self.assertGreaterEqual( + time.monotonic() - started, SPLASH_MIN_SECONDS - 0.05, + "最短展示时长未被保证", + ) + splash.deleteLater() + + class LoginWindowTest(VFSTestCase): + """登录窗:预填、成功流程、失败提示与持久化。""" + + def setUp(self): + super().setUp() + self.base = client_paths.ensure_layout(self.path("data")) + self.server, self.base_url = start_auth_server() + self.addCleanup(stop_auth_server, self.server) + _AuthApiHandler.sessions.clear() + + def _make(self): + login = LoginWindow(self.base) + self.addCleanup(login.deleteLater) + self.addCleanup(self._stop_thread, login) + login.show() + pump(_APP) + return login + + @staticmethod + def _stop_thread(login): + # 未走 done() 的用例里工作线程还开着,显式停掉 + if login._thread is not None: + login._thread.quit() + login._thread.wait(5000) + + def _fill(self, login, user="admin", password="admin123"): + login.server_edit.setText(self.base_url) + login.user_edit.setText(user) + login.password_edit.setText(password) + + def test_renders_with_prefill_and_primary_button(self): + client_settings.save(self.base, {**client_settings.DEFAULTS, + "base_url": self.base_url, + "auth_user": "bob"}) + login = self._make() + self.assertEqual(login.server_edit.text(), self.base_url) + self.assertEqual(login.user_edit.text(), "bob") + self.assertTrue(login.remember_check.isChecked()) + self.assertEqual(login.login_button.objectName(), "Primary") + self.assertFalse(login._error_label.isVisibleTo(login)) + + def test_login_success_persists_token(self): + login = self._make() + self._fill(login) + login._submit() + self.assertTrue( + pump(_APP, predicate=lambda: login.session is not None, timeout=20), + "登录没有在超时内完成", + ) + self.assertEqual(login.session["username"], "admin") + self.assertTrue(login.session["token"]) + data = client_settings.load(self.base) + self.assertEqual(data["base_url"], self.base_url) + self.assertEqual(data["auth_user"], "admin") + # token 不再进 settings.json(明文落盘是旧行为) + self.assertEqual(data["auth_token"], "") + raw = open(client_paths.settings_file(self.base), encoding="utf-8").read() + self.assertNotIn(login.session["token"], raw) + # 记住的凭据在凭据存储里,能取回来 + self.assertEqual(client_credentials.load(self.base, self.base_url), + login.session["token"]) + + def test_login_without_remember_keeps_token_in_memory_only(self): + login = self._make() + self._fill(login) + login.remember_check.setChecked(False) + login._submit() + self.assertTrue( + pump(_APP, predicate=lambda: login.session is not None, timeout=20) + ) + self.assertTrue(login.session["token"]) + data = client_settings.load(self.base) + self.assertEqual(data["base_url"], self.base_url) # 地址仍要记住 + self.assertEqual(data["auth_user"], "admin") + self.assertEqual(data["auth_token"], "") # token 不落盘 + self.assertEqual(client_credentials.load(self.base, self.base_url), "") + + def test_wrong_password_shows_error(self): + login = self._make() + self._fill(login, password="wrong") + login._submit() + self.assertTrue( + pump(_APP, predicate=lambda: login._error_label.isVisibleTo(login), + timeout=20) + ) + self.assertIn("不正确", login._error_label.text()) + self.assertIsNone(login.session) + self.assertTrue(login.login_button.isEnabled()) # 可重试 + + def test_missing_server_shows_error_without_request(self): + login = self._make() + login.server_edit.setText(" ") + login.user_edit.setText("admin") + login.password_edit.setText("x") + login._submit() + pump(_APP, settle=0.2) + self.assertTrue(login._error_label.isVisibleTo(login)) + self.assertFalse(_AuthApiHandler.sessions) # 没有打到服务器 + + class BridgeAuthTest(VFSTestCase): + """桥的 token 注入:同源附加、跨源不附加、插件自带认证不被覆盖。""" + + def setUp(self): + super().setUp() + _AuthApiHandler.sessions.clear() + self.server, self.base_url = start_auth_server() + self.addCleanup(stop_auth_server, self.server) + self.other_server, self.other_base = start_auth_server() + self.addCleanup(stop_auth_server, self.other_server) + + def _bridge(self, session): + bridge = SvfsBridge( + "com.test.plugin", ["net"], self.path("storage.json"), + base_url_provider=lambda: self.base_url, + session_provider=lambda: session, + ) + self.addCleanup(bridge.shutdown) + return bridge + + def _whoami(self, bridge, payload): + seen = [] + bridge.netResult.connect(lambda js: seen.append(json.loads(js))) + bridge.netRequest(json.dumps(payload)) + self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=20)) + return seen[0] + + def test_host_info_carries_auth_user(self): + bridge = self._bridge({"token": "tok", "username": "admin"}) + info = bridge.hostInfo() + self.assertEqual(info["authUser"], "admin") + self.assertEqual(info["apiVersion"], PLUGIN_API_VERSION) + + def test_same_origin_request_injects_token(self): + bridge = self._bridge({"token": "tok-abc", "username": "admin"}) + result = self._whoami(bridge, {"path": "/api/whoami"}) + self.assertEqual(result["bodyText"], + json.dumps({"authorization": "Bearer tok-abc"})) + + def test_cross_origin_request_no_token(self): + bridge = self._bridge({"token": "tok-abc", "username": "admin"}) + result = self._whoami(bridge, {"url": f"{self.other_base}/api/whoami"}) + self.assertEqual(result["bodyText"], json.dumps({"authorization": ""})) + + def test_plugin_authorization_not_overridden(self): + bridge = self._bridge({"token": "tok-abc", "username": "admin"}) + result = self._whoami( + bridge, + {"path": "/api/whoami", "headers": {"Authorization": "Xyz custom"}}, + ) + self.assertEqual(result["bodyText"], + json.dumps({"authorization": "Xyz custom"})) + + def test_no_session_no_header(self): + bridge = self._bridge(None) + result = self._whoami(bridge, {"path": "/api/whoami"}) + self.assertEqual(result["bodyText"], json.dumps({"authorization": ""})) + + class StartupFlowTest(VFSTestCase): + """启动链:记住的有效 token 静默通过;失效的弹登录窗。""" + + def setUp(self): + super().setUp() + self.base = client_paths.ensure_layout(self.path("data")) + self.server, self.base_url = start_auth_server() + self.addCleanup(stop_auth_server, self.server) + _AuthApiHandler.sessions.clear() + + def _remember(self, token="tok-stale"): + """模拟"上次勾了记住登录":地址与用户名在 settings, + 令牌在凭据存储(token 不进 settings.json)。""" + client_settings.save(self.base, {**client_settings.DEFAULTS, + "base_url": self.base_url, + "auth_user": "admin"}) + client_credentials.save(self.base, token, base_url=self.base_url, + username="admin") + + def test_valid_token_skips_login(self): + session = client_auth.login(self.base_url, "admin", "admin123") + self._remember(session["token"]) + # run() 内部跑自己的事件循环(splash 动画 + 静默校验),测试 + # 线程阻塞在里面等结果即可 + flow = StartupFlow(self.base) + result = flow.run() + self.assertEqual(result["username"], "admin") + self.assertEqual(result["token"], session["token"]) + # 主窗口亮出来之前启动动画必须已经真正关掉(隐形置顶窗会 + # 挡住后面的窗口并拦截点击) + self.assertFalse(flow._splash.isVisible()) + + def test_legacy_settings_token_is_migrated(self): + """老版本把 token 放在 settings.json:启动时搬到凭据存储, + 原字段清空,且这次启动照常免登录。""" + session = client_auth.login(self.base_url, "admin", "admin123") + client_settings.save(self.base, {**client_settings.DEFAULTS, + "base_url": self.base_url, + "auth_token": session["token"], + "auth_user": "admin"}) + flow = StartupFlow(self.base) + result = flow.run() + self.assertEqual(result["username"], "admin") + self.assertEqual(result["token"], session["token"]) + self.assertEqual( + client_credentials.load(self.base, self.base_url), + session["token"]) + self.assertEqual( + client_settings.load(self.base)["auth_token"], "") + + def test_offline_grace_when_server_unreachable(self): + # 记住的凭据 + 服务器不可达 → 离线宽限进入(不弹登录窗) + client_settings.save(self.base, {**client_settings.DEFAULTS, + "base_url": "http://127.0.0.1:1", + "auth_user": "admin"}) + client_credentials.save(self.base, "tok-offline", + base_url="http://127.0.0.1:1", + username="admin") + flow = StartupFlow(self.base) + result = flow.run() + self.assertIsNotNone(result, "离线宽限没有放行(弹了登录窗)") + self.assertTrue(result.get("offline")) + self.assertEqual(result["username"], "admin") + self.assertEqual(result["token"], "tok-offline") + self.assertFalse(flow._splash.isVisible()) + + def test_expired_token_opens_login_then_quit_closes(self): + self._remember("tok-stale") # 服务端会话表里没有它 + flow = StartupFlow(self.base) + + # 登录窗一出现就记录 splash 的可见性:此刻它必须已关闭 + seen = {"splash_visible_at_login": None} + + poll = QTimer() + poll.setInterval(50) + + def check(): + if seen["splash_visible_at_login"] is not None: + return + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, LoginWindow) and widget.isVisible(): + seen["splash_visible_at_login"] = flow._splash.isVisible() + poll.stop() + + poll.timeout.connect(check) + poll.start() + + # 登录窗弹出后(约 1.7s)由定时器关掉:run() 应返回 None + def close_login(): + for widget in QApplication.topLevelWidgets(): + if isinstance(widget, LoginWindow): + widget.reject() + + QTimer.singleShot(6000, close_login) + QTimer.singleShot(30000, close_login) # 兜底,防用例挂死 + result = flow.run() + self.assertIsNone(result) + self.assertIn("过期", flow._login_note) + self.assertIsNotNone(seen["splash_visible_at_login"], + "登录窗没有出现") + self.assertFalse(seen["splash_visible_at_login"], + "登录窗出现时启动动画窗口还开着(隐形置顶窗会挡住它)") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client_ui.py b/tests/test_client_ui.py new file mode 100644 index 0000000..6dcc9bb --- /dev/null +++ b/tests/test_client_ui.py @@ -0,0 +1,1357 @@ +#!/usr/bin/env python3 +"""svfs_client Qt 层的界面测试(offscreen 渲染)。 + +约定与 test_studio_ui.py 相同:``QT_QPA_PLATFORM=offscreen`` 必须在任何 +PySide6 导入之前设置;没装含 WebEngine 的 PySide6 时整个模块干净跳过。 + +WebEngine 冒烟测试会真正创建 QWebEngineView 并加载示例插件——部分环境 +(无显卡/容器)可能起不来,那种情况跳过而不是误报失败。 +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +# WebEngine 在无显示环境下的兜底(可用环境变量覆盖) +os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox --disable-gpu") + +import json +import sys +import tempfile +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from helpers import VFSTestCase, make_tree, quiet + +from sqlite_vfs.core import SQLiteVFS + +try: + from PySide6.QtCore import QSettings, Qt, QUrl + from PySide6.QtWidgets import QApplication + from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 - WebEngine 探测 + + HAVE_QT = True +except ImportError: + HAVE_QT = False + +if HAVE_QT: + from svfs_client import PLUGIN_API_VERSION, paths as client_paths, settings as client_settings + from svfs_client.app import create_app + from svfs_client.bridge import SvfsBridge + from svfs_client.scheme import ( + SvfsSchemeHandler, + looks_like_navigation, + make_lookup, + plugin_url, + ) + from svfs_client.worker import Client + + from svfs_client import sample as client_sample + from svfs_client.installer import install_from_file + from svfs_client.shell import MainWindow + from svfs_studio import theme as client_theme + + +class _ApiHandler(BaseHTTPRequestHandler): + """net 测试用的最小 API 服务:/api/ping → 200,//manifest.json → 清单, + /api/private → 恒 401,其它 → 404。``manifest_version`` 非 None 时用来模拟 + 服务器上有新版本;``ping_status`` 用来模拟"服务器在线但拒绝凭据"。""" + + manifest_version = None + ping_status = 200 + client_version = "" + + def _reply(self, code, body): + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/api/ping": + if _ApiHandler.ping_status != 200: + self._reply(_ApiHandler.ping_status, b'{"detail": "unauthorized"}') + return + self._reply(200, b'{"pong": true}') + return + if self.path == "/api/private": + # 恒定 401:登录失效链路(桥的信号 + 状态灯)测试用 + self._reply(401, b'{"detail": "token expired"}') + return + if self.path == "/client/manifest.json": + if not _ApiHandler.client_version: + self._reply(404, b'{"error": "no client manifest"}') + return + body = json.dumps({ + "version": _ApiHandler.client_version, + "url": "https://example.invalid/SVFS-Client.zip", + "notes": "测试用新版本", + }).encode("utf-8") + self._reply(200, body) + return + if (_ApiHandler.manifest_version + and self.path.endswith("/manifest.json")): + plugin_id = self.path.strip("/").split("/")[0] + body = json.dumps({ + "format": "svfs-objects", "version": 1, "name": plugin_id, + "snapshot": {"name": _ApiHandler.manifest_version, + "digest": "deadbeef"}, + "entries": {}, + }).encode("utf-8") + self._reply(200, body) + return + self._reply(404, b'{"error": "not found"}') + + def log_message(self, *args): # 静音 + pass + + +def start_api_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, f"http://127.0.0.1:{server.server_address[1]}" + + +def pump(app, predicate=None, timeout=15.0, interval=0.02, settle=0.08): + """跑事件循环:predicate 为 None 时跑 settle 秒;否则等到谓词成立或超时。""" + deadline = time.monotonic() + timeout + if predicate is None: + end = time.monotonic() + settle + while time.monotonic() < end: + app.processEvents() + time.sleep(interval) + return True + while time.monotonic() < deadline: + app.processEvents() + time.sleep(interval) + if predicate(): + end = time.monotonic() + settle + while time.monotonic() < end: + app.processEvents() + time.sleep(interval) + return True + return False + + +if HAVE_QT: + + _APP = None + + def setUpModule(): + global _APP + # QSettings 重定向到临时目录,测试不碰真实注册表/配置 + QSettings.setDefaultFormat(QSettings.Format.IniFormat) + QSettings.setPath( + QSettings.Format.IniFormat, QSettings.Scope.UserScope, + tempfile.mkdtemp(prefix="svfs_client_settings_"), + ) + # create_app 内部会先 register_scheme() 再建 QApplication(顺序是硬要求) + _APP = QApplication.instance() or create_app([sys.argv[0]]) + _APP.setQuitOnLastWindowClosed(False) + + class BridgeTest(VFSTestCase): + """桥的权限模型与存储隔离(不经过 WebView)。""" + + def bridge(self, permissions): + return SvfsBridge( + "com.example.demo", permissions, + client_paths.storage_file(self.path("data"), "com.example.demo"), + ) + + def test_storage_roundtrip(self): + bridge = self.bridge(["storage"]) + self.assertTrue(bridge.storageSet("visits", "42")) + self.assertEqual(json.loads(bridge.storageGet("visits")), 42) + self.assertEqual(bridge.storageKeys(), ["visits"]) + self.assertTrue(bridge.storageRemove("visits")) + self.assertIsNone(bridge.storageGet("visits")) + + def test_storage_persists_to_isolated_file(self): + self.bridge(["storage"]).storageSet("k", '"v"') + self.assertTrue( + client_paths.storage_file(self.path("data"), "com.example.demo").endswith( + "com.example.demo.json" + ) + ) + with open(client_paths.storage_file(self.path("data"), "com.example.demo"), + encoding="utf-8") as f: + self.assertEqual(json.load(f), {"k": "v"}) + + def test_permission_denied(self): + bridge = self.bridge([]) # 什么都没声明 + self.assertFalse(bridge.storageSet("k", "1")) + self.assertIsNone(bridge.storageGet("k")) + self.assertFalse(bridge.notify("hi")) + self.assertFalse(bridge.setTitle("t")) + self.assertEqual(bridge.storageKeys(), []) + + def test_bad_values_rejected(self): + bridge = self.bridge(["storage"]) + self.assertFalse(bridge.storageSet("k", "not-json")) + self.assertFalse(bridge.storageSet("", "1")) + self.assertFalse(bridge.storageSet("x" * 300, "1")) + + def test_notify_and_title_signals(self): + bridge = self.bridge(["notify", "title"]) + seen = [] + bridge.notifyRequested.connect(seen.append) + bridge.titleRequested.connect(lambda t: seen.append("T:" + t)) + self.assertTrue(bridge.notify("你好")) + self.assertTrue(bridge.setTitle("新标题")) + pump(_APP, settle=0.05) + self.assertIn("你好", seen) + self.assertIn("T:新标题", seen) + + def test_host_info_needs_no_permission(self): + info = self.bridge([]).hostInfo() + self.assertEqual(info["pluginId"], "com.example.demo") + self.assertEqual(info["apiVersion"], PLUGIN_API_VERSION) + self.assertEqual(info["baseUrl"], "") # 没给 provider + + def test_host_info_carries_base_url(self): + bridge = SvfsBridge( + "com.example.demo", [], + client_paths.storage_file(self.path("data"), "com.example.demo"), + base_url_provider=lambda: " http://srv:8000/ ", # 故意带斜杠和空格 + ) + info = bridge.hostInfo() + self.assertEqual(info["baseUrl"], "http://srv:8000") + + def test_dialog_needs_permission(self): + bridge = SvfsBridge( + "com.example.demo", ["net"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + ) + self.addCleanup(bridge.shutdown) + result = json.loads( + bridge.uiDialog(json.dumps({"kind": "info", "text": "hi"})) + ) + self.assertFalse(result["ok"]) + self.assertIn("权限", result["error"]) + + def test_dialog_rejects_bad_payload(self): + bridge = SvfsBridge( + "com.example.demo", ["dialog"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + ) + self.addCleanup(bridge.shutdown) + result = json.loads(bridge.uiDialog("not json")) + self.assertFalse(result["ok"]) + + def test_dialog_shows_and_returns(self): + # 真弹原生 QMessageBox:定时器在嵌套事件循环里把它自动关掉 + # (等价于用户点掉了对话框),slot 才会返回。 + bridge = SvfsBridge( + "com.example.demo", ["dialog"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + ) + self.addCleanup(bridge.shutdown) + from PySide6.QtCore import QTimer + + def close_modal(): + modal = QApplication.activeModalWidget() + if modal is not None: + modal.close() + + # confirm(Ok/Cancel 两个按钮):直接关闭 = 取消 → accepted False + QTimer.singleShot(150, close_modal) + result = json.loads( + bridge.uiDialog(json.dumps( + {"kind": "confirm", "title": "调试", "text": "自动关闭"} + )) + ) + self.assertTrue(result["ok"]) + self.assertFalse(result["accepted"]) + + # message info(只有 Ok 一个按钮):直接关闭 = 默认按钮 Ok → True + QTimer.singleShot(150, close_modal) + result = json.loads( + bridge.uiDialog(json.dumps( + {"kind": "info", "title": "调试", "text": "自动关闭"} + )) + ) + self.assertTrue(result["ok"]) + self.assertTrue(result["accepted"]) + + def test_net_request_roundtrip(self): + server, base = start_api_server() + self.addCleanup(server.shutdown) + bridge = SvfsBridge( + "com.example.demo", ["net"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + base_url_provider=lambda: base, + ) + self.addCleanup(bridge.shutdown) + seen = [] + bridge.netResult.connect(seen.append) + req_id = bridge.netRequest(json.dumps({"path": "/api/ping"})) + self.assertTrue(req_id) + self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15)) + msg = json.loads(seen[-1]) + self.assertEqual(msg["reqId"], req_id) + self.assertTrue(msg["ok"]) + self.assertEqual(msg["status"], 200) + self.assertIn("pong", msg["bodyText"]) + + def test_net_http_error_and_missing_permission(self): + server, base = start_api_server() + self.addCleanup(server.shutdown) + bridge = SvfsBridge( + "com.example.demo", ["net"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + base_url_provider=lambda: base, + ) + self.addCleanup(bridge.shutdown) + seen = [] + bridge.netResult.connect(seen.append) + # 404 走 HTTPError 分支:请求送达,状态码交给插件处理 + bridge.netRequest(json.dumps({"path": "/api/nope"})) + self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15)) + msg = json.loads(seen[-1]) + self.assertFalse(msg["ok"]) + self.assertEqual(msg["status"], 404) + # 无效方法被拒 + before = len(seen) + bridge.netRequest(json.dumps({"path": "/api/ping", "method": "TRACE"})) + self.assertTrue(pump(_APP, predicate=lambda: len(seen) > before, timeout=15)) + self.assertIn("error", json.loads(seen[-1])) + + def test_net_denied_without_permission(self): + bridge = self.bridge([]) # 未声明 net + seen = [] + bridge.netResult.connect(seen.append) + self.assertEqual(bridge.netRequest(json.dumps({"path": "/api/ping"})), "") + pump(_APP, settle=0.2) + self.assertEqual(seen, []) + + def test_net_host_allowlist_blocks_other_hosts(self): + """清单声明了 net_hosts:白名单外的主机被拒(请求根本发不出去)。 + + 拒绝也要回一个明确错误给插件(不是静默超时),否则插件作者 + 只会看到"请求中…"。 + """ + bridge = SvfsBridge( + "com.example.demo", ["net"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + base_url_provider=lambda: "http://127.0.0.1:1", + allowed_hosts=["api.example.com"], + ) + self.addCleanup(bridge.shutdown) + seen = [] + bridge.netResult.connect(seen.append) + bridge.netRequest(json.dumps( + {"url": "http://evil.example/collect", "method": "GET"})) + self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=10)) + msg = json.loads(seen[-1]) + self.assertFalse(msg["ok"]) + self.assertIn("net_hosts", msg["error"]) + self.assertIn("evil.example", msg["error"]) + + def test_net_host_allowlist_allows_listed_host(self): + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + bridge = SvfsBridge( + "com.example.demo", ["net"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + base_url_provider=lambda: base, + allowed_hosts=["127.0.0.1"], # 不带端口:按主机名匹配 + ) + self.addCleanup(bridge.shutdown) + seen = [] + bridge.netResult.connect(seen.append) + self.assertTrue(bridge.netRequest(json.dumps({"path": "/api/ping"}))) + self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15)) + self.assertEqual(json.loads(seen[-1])["status"], 200) + + def test_net_401_reports_session_rejection(self): + """宿主注入的 token 被 401 拒绝 → 通知壳层引导重新登录。 + + 插件拿到的应答不变(401 仍照常交付),只是宿主多知道一件事。 + """ + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + bridge = SvfsBridge( + "com.example.demo", ["net"], + client_paths.storage_file(self.path("data"), "com.example.demo"), + base_url_provider=lambda: base, + session_provider=lambda: {"token": "tok", "username": "admin"}, + ) + self.addCleanup(bridge.shutdown) + seen, rejected = [], [] + bridge.netResult.connect(seen.append) + bridge.sessionRejected.connect(rejected.append) + self.assertTrue(bridge.netRequest(json.dumps({"path": "/api/private"}))) + self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15)) + msg = json.loads(seen[-1]) + self.assertFalse(msg["ok"]) + self.assertEqual(msg["status"], 401) + # 信号从工作线程发出,排队到主线程 → 再跑一次事件循环 + self.assertTrue(pump(_APP, predicate=lambda: bool(rejected), timeout=5), + "401 没有触发 sessionRejected") + self.assertIn("401", rejected[0]) + + def test_net_401_without_host_token_is_silent(self): + """没有宿主注入的 token(未登录 / 插件自带 Authorization)时, + 401 与宿主会话无关,不该惊动用户。""" + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + + for provider, extra in ( + (None, {}), # 未登录:没有可注入的凭据 + (lambda: {"token": "tok", "username": "admin"}, + {"headers": {"Authorization": "Bearer plugin-own"}}), # 插件自带 + ): + bridge = SvfsBridge( + "com.example.demo", ["net"], + client_paths.storage_file(self.path("data"), + "com.example.demo"), + base_url_provider=lambda: base, + session_provider=provider, + ) + self.addCleanup(bridge.shutdown) + seen, rejected = [], [] + bridge.netResult.connect(seen.append) + bridge.sessionRejected.connect(rejected.append) + payload = {"path": "/api/private", **extra} + self.assertTrue(bridge.netRequest(json.dumps(payload))) + self.assertTrue(pump(_APP, predicate=lambda: bool(seen), timeout=15)) + self.assertEqual(json.loads(seen[-1])["status"], 401) + pump(_APP, settle=0.2) + self.assertEqual(rejected, []) + + class SchemeHandlerTest(VFSTestCase): + """协议层的路径解析、查找闭包与句柄缓存。""" + + def setUp(self): + super().setUp() + self.base = client_paths.ensure_layout(self.path("data")) + package = self.path("plugin.svfs") + with quiet(): + client_sample.build(package) + install_from_file(package, base=self.base) + self.pid = client_sample.PLUGIN_ID + + def test_make_lookup_respects_registry(self): + lookup = make_lookup(self.base) + entry = lookup(self.pid) + self.assertIsNotNone(entry) + self.assertEqual(entry["entry"], "index.html") + self.assertTrue(entry["package_path"].endswith("plugin.svfs")) + # 未安装的插件拿不到 + self.assertIsNone(lookup("com.nope.app")) + # 禁用后拿不到(隔离由 lookup 把守) + from svfs_client import installer + + installer.set_enabled(self.base, self.pid, False) + self.assertIsNone(lookup(self.pid)) + + def test_package_handle_reads_bytes(self): + lookup = make_lookup(self.base) + handle = SvfsSchemeHandler(lookup)._handle_for(lookup(self.pid)) + data, ok = handle.read("index.html") + self.assertTrue(ok) + self.assertIn(b"", data) + # 桥的用法在 app.js 里(页面里引用了它) + data, ok = handle.read("app.js") + self.assertTrue(ok) + self.assertIn(b"svfsReady", data) + data, ok = handle.read("no/such/file.js") + self.assertFalse(ok) + self.assertEqual(data, b"") + handle.close() + + def test_normalize_rejects_climbing(self): + normalize = SvfsSchemeHandler._normalize + self.assertEqual(normalize("/"), "") + self.assertEqual(normalize("/app.js"), "app.js") + # 相对段被收敛后落回包内:/a/../b → b(仍在包根层级,安全) + self.assertEqual(normalize("/a/../b"), "b") + # 绝对路径穿出顶层会被收敛回根下,同样安全 + self.assertEqual(normalize("/../x"), "x") + self.assertEqual(normalize("/a/../../etc/passwd"), "etc/passwd") + # 收敛不掉的相对段(非 QUrl 常态,纯防御)直接拒绝 + self.assertIsNone(normalize("../../etc/passwd")) + + def test_spa_heuristic(self): + self.assertTrue(looks_like_navigation("settings")) + self.assertTrue(looks_like_navigation("user/42")) + self.assertTrue(looks_like_navigation("assets/logo")) # 无扩展名按路由对待(与 fastapi_static 同一启发式) + self.assertTrue(looks_like_navigation("images/")) # 目录路径同理(先试目录索引,再回退 SPA) + self.assertFalse(looks_like_navigation("app.js")) + + def test_invalidate_closes_handles(self): + lookup = make_lookup(self.base) + handler = SvfsSchemeHandler(lookup) + entry = lookup(self.pid) + handle = handler._handle_for(entry) + self.assertIsNotNone(handle.read("index.html")[0]) + handler.invalidate(self.pid) + # 句柄被关闭:缓存为空,老句柄再也读不到内容 + self.assertEqual(len(handler._cache), 0) + data, ok = handle.read("index.html") + self.assertFalse(ok) + self.assertEqual(data, b"") + + class MainWindowTest(VFSTestCase): + """主窗口渲染与工作线程流程(不创建 WebEngine 视图)。""" + + def setUp(self): + super().setUp() + self.base = client_paths.ensure_layout(self.path("data")) + # 侧栏收缩/最近打开经 QSettings 跨用例持久化(真实行为就是记住), + # 测试之间必须复位,否则前一个用例的状态会带进下一个窗口 + from svfs_client.shell import _ui_settings + + _ui_settings().remove("sidebar/collapsed") + _ui_settings().remove("ui/last_plugin") + self.window = MainWindow(self.base) + self.window.show() + pump(_APP) + # 等第一次列表到达(启动直显只消费第一次列表),测试才有确定状态 + self.assertTrue(pump(_APP, predicate=lambda: self.window._startup_resolved)) + + def tearDown(self): + self.window._thread.quit() + self.window._thread.wait(5000) + self.window.deleteLater() + pump(_APP) + super().tearDown() + + def _submit_and_wait(self, op, payload, timeout=20.0): + done, failed = [], [] + self.window.client.submit( + op, payload, on_done=lambda p: done.append(p), + on_fail=lambda t, d: failed.append((t, d)), + ) + self.assertTrue(pump(_APP, predicate=lambda: done or failed, + timeout=timeout), + f"{op} 没有在 {timeout}s 内完成") + self.assertFalse(failed, f"{op} 失败: {failed}") + return done[0] + + def test_empty_render(self): + self.assertIs(self.window.stack.currentWidget(), self.window.manager) + self.assertTrue(self.window.manager.empty.isVisibleTo(self.window.manager)) + self.assertEqual(self.window.plugin_list.count(), 0) + + def test_install_sample_flow(self): + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + pump(_APP) + pid = client_sample.PLUGIN_ID + self.assertIn(pid, self.window.manager.cards) + card = self.window.manager.cards[pid] + self.assertTrue(card.enabled_check.isChecked()) + self.assertTrue(card.open_button.isEnabled()) + self.assertFalse(self.window.manager.empty.isVisibleTo(self.window.manager)) + # 注册表里也是启用状态 + from svfs_client import registry + + self.assertTrue( + registry.load(self.base)["plugins"][pid]["enabled"] + ) + + def test_install_rejects_non_plugin_package(self): + # 打一个没有 plugin.json 的普通包 + src = make_tree(self.path("bare"), {"readme.txt": b"not a plugin"}) + bare = self.path("bare.svfs") + packer = SQLiteVFS(bare, compress=True) + try: + from sqlite_vfs.folder_packer import FolderPacker + + FolderPacker(packer).pack_folder(src, incremental=True) + finally: + packer.close() + failed = [] + self.window.client.submit( + "install_file", {"path": bare}, + on_done=lambda p: None, + on_fail=lambda t, d: failed.append((t, d)), + ) + self.assertTrue(pump(_APP, predicate=lambda: bool(failed))) + title, detail = failed[0] + self.assertIn("plugin.json", detail) + + def test_disable_removes_from_sidebar(self): + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + pump(_APP) + pid = client_sample.PLUGIN_ID + payload = self._submit_and_wait( + "set_enabled", {"id": pid, "enabled": False} + ) + self.window.apply_payload(payload) + pump(_APP) + self.assertEqual(self.window.plugin_list.count(), 0) + card = self.window.manager.cards[pid] + self.assertEqual(card.state_badge.text(), "已禁用") + self.assertFalse(card.open_button.isEnabled()) + + def test_version_switch_updates_registry(self): + with quiet(): + client_sample.build(self.path("sample.svfs")) + install_from_file(self.path("sample.svfs"), base=self.base) + client_sample.build(self.path("sample.svfs")) # 升到 2.0.0 + install_from_file(self.path("sample.svfs"), base=self.base) + payload = self._submit_and_wait("list_plugins", {}) + self.window.apply_payload(payload) + pump(_APP) + pid = client_sample.PLUGIN_ID + entry = self.window.entry_of(pid) + self.assertEqual(len(entry["snapshots"]), 2) + old_id = entry["snapshots"][0]["id"] + payload = self._submit_and_wait("use_version", + {"id": pid, "snapshot_id": old_id}) + self.window.apply_payload(payload) + pump(_APP) + self.assertEqual( + self.window.entry_of(pid)["version"], + entry["snapshots"][0]["name"], + ) + + def test_verify_reports_ok(self): + with quiet(): + client_sample.build(self.path("sample.svfs")) + install_from_file(self.path("sample.svfs"), base=self.base) + done = [] + self.window.client.submit( + "verify", {"id": client_sample.PLUGIN_ID, "check_content": True}, + on_done=done.append, + ) + self.assertTrue(pump(_APP, predicate=lambda: bool(done))) + self.assertTrue(done[0]["verify"]["ok"], done[0]["verify"]["errors"]) + + def test_server_config_flow(self): + # 初始未配置 + self.assertIn("未配置", self.window.manager.server_url_label.full_text()) + # 设置(故意带尾部斜杠,应被规范化) + self.assertIsNone(self.window.set_base_url(" http://10.0.0.5:9000/ ")) + self.assertEqual( + self.window.manager.server_url_label.full_text(), "http://10.0.0.5:9000" + ) + self.assertEqual( + client_settings.load(self.base)["base_url"], "http://10.0.0.5:9000" + ) + self.assertEqual(self.window.status_label.text(), "服务器地址已更新") + # 非法地址被拒绝,且不落盘 + error = self.window.set_base_url("ftp://nope") + self.assertIn("http", error) + self.assertEqual( + client_settings.load(self.base)["base_url"], "http://10.0.0.5:9000" + ) + # 清除 + self.assertIsNone(self.window.set_base_url(" ")) + self.assertEqual(client_settings.load(self.base)["base_url"], "") + self.assertIn("未配置", self.window.manager.server_url_label.full_text()) + + def _settle_sidebar(self, width, timeout=5.0): + """等收缩动画结束(宽度是并行的 min/max 属性动画)。""" + self.assertTrue( + pump(_APP, predicate=lambda: self.window.sidebar.width() == width, + timeout=timeout), + f"侧栏宽度没有到达 {width}(当前 {self.window.sidebar.width()})", + ) + + def test_sidebar_collapse_toggle(self): + from svfs_client.shell import RAIL_WIDTH, SIDEBAR_WIDTH + from PySide6.QtWidgets import QBoxLayout + + self.assertFalse(self.window._sidebar_collapsed) + self.window._set_sidebar_collapsed(True) + self.assertTrue(self.window._sidebar_collapsed) + self._settle_sidebar(RAIL_WIDTH) + self.assertEqual(self.window.collapse_button.text(), "»") + self.assertFalse(self.window.plugin_list.isVisibleTo(self.window.sidebar)) + # 收起后不是"插件不可达":图标栏接过列表职责 + self.assertTrue(self.window.rail_list.isVisibleTo(self.window.sidebar)) + self.assertEqual(self.window.rail_list.width(), RAIL_WIDTH - 8) + # 搜索框只在展开态出现 + self.assertFalse(self.window.search_edit.isVisibleTo(self.window.sidebar)) + # 底栏竖排(头像在齿轮上方),头像收成图标 + self.assertEqual(self.window._bottom_box.direction(), + QBoxLayout.Direction.TopToBottom) + self.assertEqual(self.window.user_button.text(), "") + self.assertEqual(self.window.settings_button.text(), "⚙") + + self.window._set_sidebar_collapsed(False) + self.assertFalse(self.window._sidebar_collapsed) + self._settle_sidebar(SIDEBAR_WIDTH) + self.assertEqual(self.window.collapse_button.text(), "«") + self.assertTrue(self.window.plugin_list.isVisibleTo(self.window.sidebar)) + self.assertFalse(self.window.rail_list.isVisibleTo(self.window.sidebar)) + self.assertTrue(self.window.search_edit.isVisibleTo(self.window.sidebar)) + self.assertEqual(self.window._bottom_box.direction(), + QBoxLayout.Direction.LeftToRight) + # 齿轮按钮两种状态下都是图标态(账号条右侧的固定方块) + self.assertEqual(self.window.settings_button.text(), "⚙") + self.assertEqual(self.window.settings_button.width(), 30) + + def test_sidebar_search_filters_both_lists(self): + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + pid = client_sample.PLUGIN_ID # com.svfs.demo + pump(_APP) + self.assertEqual(self.window.plugin_list.count(), 1) + self.assertEqual(self.window.rail_list.count(), 1) + + # 按 id 片段(缩写匹配)命中 + self.window.search_edit.setText("csd") + pump(_APP) + self.assertEqual(self.window.plugin_list.count(), 1) + self.assertEqual(self.window.rail_list.count(), 1) + self.assertFalse(self.window.search_hint.isVisibleTo(self.window.sidebar)) + + # 无匹配:两套列表都空 + 显示提示 + self.window.search_edit.setText("zzz-nothing") + pump(_APP) + self.assertEqual(self.window.plugin_list.count(), 0) + self.assertEqual(self.window.rail_list.count(), 0) + self.assertTrue(self.window.search_hint.isVisibleTo(self.window.sidebar)) + + # 清空即恢复全部 + self.window.search_edit.clear() + pump(_APP) + self.assertEqual(self.window.plugin_list.count(), 1) + self.assertEqual(self.window.rail_list.count(), 1) + self.assertFalse(self.window.search_hint.isVisibleTo(self.window.sidebar)) + self.assertEqual(pid, self.window.plugin_list.item(0).data( + Qt.ItemDataRole.UserRole)) + + def test_rail_click_opens_plugin_and_syncs_selection(self): + from svfs_client.shell import RAIL_WIDTH + + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + pump(_APP) + self.window._set_sidebar_collapsed(True) + self._settle_sidebar(RAIL_WIDTH) + + # 图标栏点击 → 打开对应插件,两套列表选中同步 + self.window._on_rail_row(0) + pump(_APP) + pid = client_sample.PLUGIN_ID + self.assertEqual(self.window._current_view_id(), pid) + self.assertEqual(self.window.plugin_list.currentRow(), 0) + self.assertEqual(self.window.rail_list.currentRow(), 0) + + def test_sidebar_has_no_manager_or_sample_button(self): + # 插件管理入口在设置里,示例插件入口已删除;侧栏只剩插件列表 + self.assertFalse(hasattr(self.window, "manage_button")) + self.assertFalse(hasattr(self.window, "sample_button")) + self.assertFalse(hasattr(self.window.manager, "sample_button")) + + def _install_extra_plugin(self, plugin_id): + """装一个非示例插件(最小合法清单 + 入口),用于多插件场景。""" + src = make_tree(self.path(plugin_id), { + "plugin.json": json.dumps({ + "id": plugin_id, "name": "Other", "version": "1.0.0", + "api_version": PLUGIN_API_VERSION, "entry": "index.html", + "permissions": [], + }).encode("utf-8"), + "index.html": b"other", + }) + pkg = self.path(plugin_id + ".svfs") + packer = SQLiteVFS(pkg) + try: + from sqlite_vfs.folder_packer import FolderPacker + + FolderPacker(packer).pack_folder(src) + finally: + packer.close() + with quiet(): + install_from_file(pkg, base=self.base) + + def test_startup_plugin_resolution(self): + from svfs_client.shell import _ui_settings + + # last_plugin 经 QSettings 跨用例持久化(如调试台用例会写它), + # 先清掉才能假设"无配置、无最近打开" + _ui_settings().remove("ui/last_plugin") + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) # _startup_resolved 已是 True,不会自动打开 + pid = client_sample.PLUGIN_ID + other = "com.other.app" + self._install_extra_plugin(other) + self.window.apply_payload(self._submit_and_wait("list_plugins", {})) + + # 默认规则:无配置、无 last_plugin → 按 id 排序第一个启用的插件 + self.assertEqual( + self.window._resolve_startup_plugin(), min(pid, other) + ) + # 配置优先于一切 + self.assertIsNone(self.window.set_startup_plugin(other)) + self.assertEqual(self.window._resolve_startup_plugin(), other) + self.assertEqual( + client_settings.load(self.base)["startup_plugin"], other + ) + # 未安装的插件被拒绝 + self.assertIn("未安装", self.window.set_startup_plugin("com.nope.app")) + # 清配置 → 上次打开的插件 + self.assertIsNone(self.window.set_startup_plugin("")) + _ui_settings().setValue("ui/last_plugin", other) + self.assertEqual(self.window._resolve_startup_plugin(), other) + # 禁用的插件跳过:禁用 other 后配置它也落空 → 回到第一个启用的 + _ui_settings().remove("ui/last_plugin") + payload = self._submit_and_wait( + "set_enabled", {"id": other, "enabled": False} + ) + self.window.apply_payload(payload) + self.window.set_startup_plugin(other) + self.assertEqual(self.window._resolve_startup_plugin(), pid) + # 收尾清理:不给后续用例留下配置与 last_plugin + self.assertIsNone(self.window.set_startup_plugin("")) + _ui_settings().remove("ui/last_plugin") + + def test_settings_dialog_startup_and_cache(self): + from svfs_client.dialogs import SettingsDialog + + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + pid = client_sample.PLUGIN_ID + dialog = SettingsDialog( + self.base, apply_base_url=self.window.set_base_url, + plugins_provider=lambda: [ + (e["id"], e.get("name") or e["id"]) for e in self.window._plugins + ], + startup_provider=lambda: ( + client_settings.load(self.base)["startup_plugin"] + ), + apply_startup=self.window.set_startup_plugin, + ) + self.addCleanup(dialog.deleteLater) + # 启动插件下拉含已装插件;选它并保存 + self.assertIsNotNone(dialog.startup_combo.findData(pid)) + dialog.startup_combo.setCurrentIndex(dialog.startup_combo.findData(pid)) + dialog._apply_startup_clicked() + self.assertEqual( + client_settings.load(self.base)["startup_plugin"], pid + ) + self.assertIn("已保存", dialog.startup_status.text()) + # 缓存路径:自定义 → 落盘并显示生效路径;恢复默认 → 清空 + custom = self.path("cache-custom") + dialog.cache_edit.setText(custom) + dialog._save_cache_dir() + self.assertEqual(client_settings.load(self.base)["cache_dir"], custom) + self.assertIn("cache-custom", dialog.cache_label.full_text()) + dialog._reset_cache_dir() + self.assertEqual(client_settings.load(self.base)["cache_dir"], "") + + def test_settings_dialog_opens_manager(self): + from svfs_client.dialogs import SettingsDialog + + dialog = SettingsDialog( + self.base, apply_base_url=self.window.set_base_url, + on_open_manager=self.window._show_manager, + ) + self.addCleanup(dialog.deleteLater) + dialog._open_manager() + # 入口先关对话框再切到插件管理页 + self.assertIs(self.window.stack.currentWidget(), self.window.manager) + + def test_settings_dialog_debug_callback(self): + from svfs_client.dialogs import SettingsDialog + + calls = [] + dialog = SettingsDialog( + self.base, apply_base_url=self.window.set_base_url, + on_install_debug=lambda: calls.append(True), + ) + self.addCleanup(dialog.deleteLater) + dialog._install_debug() + self.assertEqual(len(calls), 1) + + # ---- 更新检查 / 服务器状态 / 诊断 ---- + + def test_check_updates_marks_cards(self): + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + pid = client_sample.PLUGIN_ID + card = self.window.manager.cards[pid] + + # 未检查前没有可升级标记 + self.assertFalse(card.update_badge.isVisibleTo(card)) + self.assertFalse(card.upgrade_button.isVisibleTo(card)) + + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + _ApiHandler.manifest_version = "9.9.9" # 服务器上有"新版本" + self.addCleanup(setattr, _ApiHandler, "manifest_version", None) + self.window.set_base_url(base) + + result = self._submit_and_wait("check_updates", {}) + self.assertEqual(result["updates"], {pid: "9.9.9"}) + self.window._on_updates(result) + pump(_APP) + self.assertTrue(card.update_badge.isVisibleTo(card)) + self.assertIn("9.9.9", card.update_badge.text()) + self.assertTrue(card.upgrade_button.isVisibleTo(card)) + + # 服务器上的版本不比本地新 → 不标记 + _ApiHandler.manifest_version = "0.0.1" + result = self._submit_and_wait("check_updates", {}) + self.assertEqual(result["updates"], {}) + self.window._on_updates(result) + pump(_APP) + self.assertFalse(card.update_badge.isVisibleTo(card)) + + def test_upgrade_plugin_resolves_manifest_url(self): + payload = self._submit_and_wait("install_sample", {}) + self.window.apply_payload(payload) + pid = client_sample.PLUGIN_ID + self.window.set_base_url("http://10.0.0.7:9000") + + captured = [] + original = self.window._submit_install + self.window._submit_install = ( + lambda op, payload, label: captured.append((op, payload, label))) + try: + self.window._upgrade_plugin(pid) + finally: + self.window._submit_install = original + self.assertEqual(len(captured), 1) + op, payload, _label = captured[0] + self.assertEqual(op, "install_url") + self.assertEqual(payload["url"], + f"http://10.0.0.7:9000/{pid}/manifest.json") + + def test_ping_server_status_light(self): + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + + # 未配置:灰灯、无文字,不发请求 + self.window._ping_server() + pump(_APP, settle=0.2) + self.assertEqual(self.window.manager.server_status_label.text(), "") + self.assertIn(client_theme.TEXT_FAINT, + self.window.manager.server_dot.styleSheet()) + + # 可达:绿灯 + 延迟 + self.window.set_base_url(base) + result = self._submit_and_wait("ping_server", {}) + self.assertTrue(result["online"]) + self.window._on_server_status(result) + self.assertIn("ms", self.window.manager.server_status_label.text()) + + # 不可达:红灯 + 离线 + self.window.set_base_url("http://127.0.0.1:1") + result = self._submit_and_wait("ping_server", {}) + self.assertFalse(result["online"]) + self.window._on_server_status(result) + self.assertEqual(self.window.manager.server_status_label.text(), "离线") + + def test_ping_reports_expired_session_as_warn(self): + """服务器在线但拒绝凭据:状态灯是「登录已失效」(黄),不是「离线」。 + + 报"离线"会把用户引去查网络,而真相是会话没了——这两件事要分开。 + """ + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + self.window.set_base_url(base) + self.window.set_session({"username": "admin", "token": "tok", + "base_url": base}) + _ApiHandler.ping_status = 401 + self.addCleanup(setattr, _ApiHandler, "ping_status", 200) + + result = self._submit_and_wait("ping_server", {}) + self.assertTrue(result["online"]) + self.assertTrue(result["auth_rejected"]) + self.window._on_server_status(result) + pump(_APP) + self.assertEqual(self.window.manager.server_status_label.text(), + "登录已失效") + self.assertIn(client_theme.WARN, + self.window.manager.server_dot.styleSheet()) + # 同一件事也把会话标成失效(用户中心/侧栏头像跟着变) + self.assertTrue(self.window._session.get("invalid")) + + def test_session_rejected_prompts_relogin_once(self): + """插件 net 撞上 401 → 提示一次 + 一键重新登录;重登后恢复。""" + self.window.set_session({"username": "admin", "token": "tok"}) + self.window._on_session_rejected("服务器返回 401:登录状态已失效") + pump(_APP) + self.assertIn("登录已失效", + self.window.manager.server_login_badge.text()) + self.assertTrue(self.window.manager.banner.isVisible()) + self.assertEqual(self.window.manager.banner._button.text(), "重新登录…") + self.assertTrue(self.window.manager.banner._button.isVisible()) + + # 每个打开的插件都会撞上同一个 401:提示只发一次 + self.window.manager.clear_banner() + self.window._on_session_rejected("服务器返回 401:登录状态已失效") + pump(_APP) + self.assertFalse(self.window.manager.banner.isVisible()) + + # 重新登录成功(新的干净会话)→ 徽章恢复,允许再次提示 + self.window.set_session({"username": "admin", "token": "new"}) + self.window._on_session_rejected("又一次 401") + pump(_APP) + self.assertIn("登录已失效", + self.window.manager.server_login_badge.text()) + self.assertTrue(self.window.manager.banner.isVisible()) + + def test_check_client_update_notifies_and_opens_download(self): + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + self.window.set_base_url(base) + + # 服务器没提供 client/manifest.json → 静默(不是错误) + result = self._submit_and_wait("check_client_update", {}) + self.assertFalse(result["update"]) + self.assertFalse(result["checked"]) + self.window._on_client_update(result) + pump(_APP) + self.assertFalse(self.window.manager.banner.isVisible()) + + # 提供了更高版本 → 状态栏 + 横幅,动作按钮打开下载地址 + _ApiHandler.client_version = "99.0.0" + self.addCleanup(setattr, _ApiHandler, "client_version", "") + result = self._submit_and_wait("check_client_update", {}) + self.assertTrue(result["update"]) + self.assertEqual(result["latest"], "99.0.0") + self.window._on_client_update(result) + pump(_APP) + self.assertIn("99.0.0", self.window.status_label.text()) + self.assertTrue(self.window.manager.banner.isVisible()) + self.assertEqual(self.window.manager.banner._button.text(), + "下载新版本…") + + opened = [] + self.window._open_update_url = opened.append + self.window.manager.banner._button.click() + pump(_APP) + self.assertEqual(opened, ["https://example.invalid/SVFS-Client.zip"]) + + def test_banner_action_follows_current_message(self): + """横幅动作按钮只做"当前这条消息"的事(换消息不能沿用上一个动作)。""" + calls = [] + self.window.manager.show_message("info", "标题", "详情", + action_text="点我", + action=lambda: calls.append("a")) + self.window.manager.banner._button.click() + pump(_APP) + self.assertEqual(calls, ["a"]) + # 新的一条消息没带动作 → 按钮连显示都不该有 + self.window.manager.show_message("ok", "只有文字") + pump(_APP) + self.assertFalse(self.window.manager.banner._button.isVisible()) + + def test_plugin_notify_reaches_tray_and_status_bar(self): + """notify 权限名副其实:有托盘就发系统通知,同时界面内也留痕。""" + class _FakeTray: + def __init__(self): + self.calls = [] + + def showMessage(self, title, message, icon, msecs): # noqa: N802 + self.calls.append((title, message, msecs)) + + fake = _FakeTray() + original, self.window._tray = self.window._tray, fake + self.addCleanup(setattr, self.window, "_tray", original) + self.window._on_plugin_notify("打包完成") + pump(_APP) + self.assertEqual(len(fake.calls), 1) + self.assertEqual(fake.calls[0][1], "打包完成") + self.assertEqual(self.window.status_label.text(), "打包完成") + + def test_plugin_notify_without_tray_still_shows_in_window(self): + """没有系统托盘(容器/被策略禁用/offscreen)时降级为界面内提示。""" + original, self.window._tray = self.window._tray, None + self.addCleanup(setattr, self.window, "_tray", original) + self.window._on_plugin_notify("服务端返回 500") + pump(_APP) + self.assertEqual(self.window.status_label.text(), "服务端返回 500") + self.assertTrue(self.window.manager.banner.isVisible()) + + def test_diagnostics_log_collects_errors(self): + from svfs_client import diagnostics + + diagnostics.clear() + self.window._on_fail_silent("测试错误", "详情") + self.assertTrue(any("测试错误" in line + for line in diagnostics.recent())) + # 页面控制台消息也进日志(不要求 WebEngine:直接验证日志接口) + diagnostics.log("console 输出", "console:com.svfs.demo") + text = diagnostics.report({"数据目录": self.base}) + self.assertIn("console:com.svfs.demo", text) + diagnostics.clear() + + def test_install_debug_plugin_opens_new_page(self): + # 设置 → 桥调试台:装完自动打开(新装/升级都一样) + self.window._install_debug_plugin() + self.assertTrue( + pump(_APP, predicate=lambda: ( + client_sample.PLUGIN_ID in self.window._views + ), timeout=30), + "调试台安装后没有自动打开", + ) + self.assertIs( + self.window.stack.currentWidget(), + self.window._views[client_sample.PLUGIN_ID], + ) + # 再点一次走升级:视图因版本变化被收掉并重建,仍是当前页 + self.window._install_debug_plugin() + self.assertTrue(pump(_APP, predicate=lambda: ( + self.window.stack.currentWidget() is self.window._views.get( + client_sample.PLUGIN_ID) + ), timeout=30)) + + def test_settings_dialog_applies_base_url(self): + from svfs_client.dialogs import SettingsDialog + + dialog = SettingsDialog( + self.base, apply_base_url=self.window.set_base_url + ) + self.addCleanup(dialog.deleteLater) + # 合法地址:走 MainWindow.set_base_url(校验+落盘+同步界面) + dialog.server_edit.setText(" http://10.0.0.9:9000/ ") + dialog._apply_base_url_clicked() + self.assertFalse(dialog.server_error.isVisibleTo(dialog)) + self.assertEqual(dialog.server_status.text(), "已保存") + self.assertEqual( + client_settings.load(self.base)["base_url"], "http://10.0.0.9:9000" + ) + self.assertEqual( + self.window.manager.server_url_label.full_text(), + "http://10.0.0.9:9000", + ) + # 非法地址:报错、不落盘 + dialog.server_edit.setText("ftp://nope") + dialog._apply_base_url_clicked() + self.assertTrue(dialog.server_error.isVisibleTo(dialog)) + self.assertEqual( + client_settings.load(self.base)["base_url"], "http://10.0.0.9:9000" + ) + + def test_user_center_refresh_and_actions(self): + from svfs_client.dialogs import UserCenterDialog + + self.window.set_session({ + "token": "tok", "username": "admin", + "base_url": "http://10.0.0.9:9000", + "login_time": "2026-09-15T10:00:00", + }) + dialog = UserCenterDialog( + self.base, + session_provider=lambda: self.window._session, + on_relogin=self.window._relogin, + on_logout=self.window.logout, + ) + self.addCleanup(dialog.deleteLater) + # 已登录态:头像/用户名/徽章/按钮 + self.assertIn("admin", dialog.user_label.text()) + self.assertEqual(dialog.state_badge.text(), "已登录") + self.assertTrue(dialog.logout_button.isEnabled()) + self.assertTrue(dialog.relogin_button.objectName() != "Primary") + # 退出登录(真回调):会话清空、界面回到未登录态 + dialog.logout_button.click() + self.assertIsNone(self.window._session) + self.assertEqual(dialog.user_label.text(), "未登录") + self.assertFalse(dialog.logout_button.isEnabled()) + self.assertEqual(dialog.relogin_button.objectName(), "Primary") + self.assertEqual(dialog.state_badge.text(), "未登录") + # 侧栏头像同步回到「未登录」 + self.assertEqual(self.window.user_button.text(), "未登录") + + +if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0": + + class WebEngineSmokeTest(VFSTestCase): + """端到端冒烟:WebView 经 svfs:// 加载示例插件 + 桥可用。 + + WebEngine 对环境挑剔(无显卡的容器等),起不来时跳过而不是误报。 + """ + + def test_sample_plugin_loads_and_bridge_works(self): + base = client_paths.ensure_layout(self.path("data")) + with quiet(): + client_sample.build(self.path("sample.svfs")) + install_from_file(self.path("sample.svfs"), base=base) + window = MainWindow(base) + window.show() + try: + self.assertTrue(pump(_APP)) + window.open_plugin(client_sample.PLUGIN_ID) + view = window._views.get(client_sample.PLUGIN_ID) + self.assertIsNotNone(view) + # 首开白屏修复:页面底色必须是主题底色,而不是 WebEngine + # 默认的白色 + self.assertEqual( + view.page().backgroundColor().name(), client_theme.BG + ) + except Exception: + # WebEngine 的 profile/子进程起不来:此环境无法冒烟,跳过 + window._thread.quit() + window._thread.wait(5000) + window.deleteLater() + pump(_APP) + self.skipTest("此环境无法创建 WebEngine 视图(offscreen 冒烟被跳过)") + + try: + loaded = {} + view.page().loadFinished.connect( + lambda ok: loaded.update(ok=ok) + ) + if view.page().url().host(): + # 可能已经加载完了 + loaded.setdefault("ok", True) + self.assertTrue( + pump(_APP, predicate=lambda: loaded.get("ok"), timeout=40), + "页面在 40s 内没有加载完成——此环境可能无法运行 WebEngine", + ) + + # 桥已被注入且宿主信息渲染进 DOM + probe = {} + page = view.page() + + def ask(): + view.page().runJavaScript( + "typeof window.svfs === 'object' && typeof window.svfs.host.info === 'function'", + 0, + lambda r: probe.update(bridge=r), + ) + view.page().runJavaScript( + "document.querySelectorAll('#host-info dt').length > 0", + 0, + lambda r: probe.update(dom=r), + ) + return None + + def probe_ready(): + if probe.get("bridge") is None or probe.get("dom") is None: + ask() # 未就绪就继续探(runJavaScript 异步回调) + return (probe.get("bridge") is not None + and probe.get("dom") is not None) + + self.assertTrue( + pump(_APP, predicate=probe_ready, timeout=15), + f"桥探针没有拿到结果: {probe}", + ) + self.assertTrue(probe.get("bridge"), "window.svfs 桥没有注入成功") + self.assertTrue(probe.get("dom"), "host.info 的结果没有渲染进 DOM") + + # 滚动条默认样式已注入页面(宿主给所有插件页统一暗色滚动条) + page.runJavaScript( + "!!document.querySelector('style[data-svfs-scrollbar]')", + 0, lambda r: probe.update(scrollbar_css=r), + ) + self.assertTrue( + pump(_APP, predicate=lambda: probe.get("scrollbar_css") is not None), + "滚动条样式探针没有拿到结果", + ) + self.assertTrue( + probe.get("scrollbar_css"), + "宿主没有把滚动条默认样式注入插件页面", + ) + + # storage 桥端到端:页面里 set → Python 落盘 → get 回读 + page.runJavaScript( + "(function(){ window.__probe='pending';" + " window.svfs.storage.set('manual', 42).then(function(ok){" + " window.svfs.storage.get('manual').then(function(v){" + " window.__probe = JSON.stringify({ok: ok, v: v," + " count: document.getElementById('count').textContent});" + " }); }); })();", + 0, lambda r: None, + ) + storage = {} + + def storage_ready(): + page.runJavaScript("window.__probe", 0, + lambda r: storage.update(r=r)) + r = storage.get("r") + return r is not None and r != "pending" + + self.assertTrue( + pump(_APP, predicate=storage_ready, timeout=15), + f"storage 探针没有拿到结果: {storage}", + ) + result = json.loads(storage["r"]) + self.assertTrue(result["ok"]) + self.assertEqual(result["v"], 42) + + # 示例自己的访问计数是异步链(get → set → 渲染), + # 轮询到它变为 "1" 为止,避免和上面的探针抢时序 + def count_ready(): + page.runJavaScript("document.getElementById('count').textContent", + 0, lambda r: storage.update(count=r)) + return storage.get("count") == "1" + + self.assertTrue( + pump(_APP, predicate=count_ready, timeout=15), + f"访问计数没有变成 1: {storage.get('count')}", + ) + + # net 桥端到端(页面级):本地假服务器 + 页面里发起请求, + # Promise 必须落定——这是"接口一直在请求中"的回归探针 + server, base = start_api_server() + self.addCleanup(server.shutdown) + self.addCleanup(server.server_close) + window.set_base_url(base) + page.runJavaScript( + "(function(){ window.__net='pending';" + " if (!window.svfs.net) { window.__net='error:没有net'; return; }" + " window.svfs.net.request({method:'GET', path:'/api/ping'})" + " .then(function(r){ window.__net = JSON.stringify(" + " {status: r.status, body: (r.bodyText || '').slice(0, 100)}); })" + " .catch(function(e){ window.__net = 'error:' + e.message; }); })();", + 0, lambda r: None, + ) + net_probe = {} + + def net_ready(): + page.runJavaScript("window.__net", 0, + lambda r: net_probe.update(r=r)) + value = net_probe.get("r") + return value is not None and value != "pending" + + self.assertTrue( + pump(_APP, predicate=net_ready, timeout=20), + "页面级 net 请求 20s 内没有落定(一直停在请求中)", + ) + net_result = json.loads(net_probe["r"]) + self.assertEqual(net_result["status"], 200) + self.assertIn("pong", net_result["body"]) + self.assertIsNone(window.set_base_url("")) + + # 渲染进程崩溃 → 崩溃占位页(而不是永久白屏) + from svfs_client import diagnostics + + diagnostics.clear() + view._on_render_terminated(2, 139) # 2 = 进程崩溃 + crash_probe = {} + + def crash_ready(): + page.runJavaScript( + "document.body ? document.body.innerText : ''", 0, + lambda r: crash_probe.update(t=r)) + text = crash_probe.get("t") or "" + return "崩溃" in text + + self.assertTrue( + pump(_APP, predicate=crash_ready, timeout=15), + f"崩溃占位页没有渲染: {crash_probe}", + ) + self.assertTrue( + any("渲染进程退出" in line for line in diagnostics.recent()), + "崩溃事件没有进诊断日志", + ) + diagnostics.clear() + finally: + window._thread.quit() + window._thread.wait(5000) + window.deleteLater() + pump(_APP) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_server.py b/tests/test_plugin_server.py new file mode 100644 index 0000000..7695763 --- /dev/null +++ b/tests/test_plugin_server.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""examples/plugin_server.py 的最小验证(需要 fastapi + httpx,缺则整模块跳过)。 + +覆盖两条链路: + +* API 本身:登录换 token、/api/ping(需 Bearer token)、上传 .svfs → + 服务端 publish → 清单/对象可被 GET; +* **完整闭环**:经 HTTP 上传后,起一个真实 HTTP 服务吐插件目录, + 用客户端的 ``installer.install_from_url`` 从服务端装回本地。 +""" + +import importlib.util +import json +import os +import threading +import unittest +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +import helpers +from helpers import VFSTestCase, quiet + +try: + from fastapi.testclient import TestClient + + HAVE_FASTAPI = True +except ImportError: + HAVE_FASTAPI = False + +EXAMPLE_PATH = os.path.join(helpers.REPO_ROOT, "examples", "plugin_server.py") + +if HAVE_FASTAPI: + from svfs_client import installer, registry + from svfs_client import paths as client_paths + from svfs_client import sample as client_sample + + _load_seq = [0] + + def _load_example(plugins_dir): + """以指定插件目录加载示例模块(StaticFiles 在 import 时建 mount)。""" + _load_seq[0] += 1 + os.environ["SVFS_PLUGINS_DIR"] = plugins_dir + spec = importlib.util.spec_from_file_location( + f"plugin_server_example_{_load_seq[0]}", EXAMPLE_PATH + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +if HAVE_FASTAPI: + + class PluginServerTest(VFSTestCase): + def setUp(self): + super().setUp() + self.plugins_dir = self.path("plugins-root") + self.mod = _load_example(self.plugins_dir) + self.client = TestClient(self.mod.app) + + def _build_sample(self, name="sample.svfs"): + package = self.path(name) + with quiet(): + client_sample.build(package) + return package + + def _upload(self, plugin_id, pkg_path, token=None): + with open(pkg_path, "rb") as handle: + headers = {"X-SVFS-Token": token} if token is not None else {} + return self.client.post( + f"/plugins/{plugin_id}", + files={"file": ("plugin.svfs", handle.read())}, + headers=headers, + ) + + def _login(self, username="admin", password="admin123"): + return self.client.post( + "/api/login", json={"username": username, "password": password} + ) + + def test_index_open_ping_requires_auth(self): + # /api/* 需要登录;服务索引保持开放 + response = self.client.get("/api/ping") + self.assertEqual(response.status_code, 401) + + response = self.client.get("/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["plugins"], []) + + def test_login_then_ping(self): + response = self._login() + self.assertEqual(response.status_code, 200, response.text) + body = response.json() + self.assertEqual(body["username"], "admin") + self.assertTrue(body["token"]) + + headers = {"Authorization": f"Bearer {body['token']}"} + response = self.client.get("/api/ping", headers=headers) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["pong"]) + self.assertEqual(response.json()["user"], "admin") + + def test_login_rejects_bad_credentials(self): + response = self._login(password="wrong") + self.assertEqual(response.status_code, 401) + self.assertIn("不正确", response.json()["detail"]) + + def test_me_and_logout(self): + token = self._login().json()["token"] + headers = {"Authorization": f"Bearer {token}"} + + response = self.client.get("/api/me", headers=headers) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["username"], "admin") + + response = self.client.post("/api/logout", headers=headers) + self.assertEqual(response.status_code, 200) + + # token 已作废 + response = self.client.get("/api/me", headers=headers) + self.assertEqual(response.status_code, 401) + + def test_custom_users_env(self): + os.environ["SVFS_USERS"] = "alice:pw1, bob: pw2" + try: + response = self._login() # 默认演示账号被关闭 + self.assertEqual(response.status_code, 401) + response = self._login("alice", "pw1") + self.assertEqual(response.status_code, 200) + response = self._login("bob", "pw2") # 值两侧空白被容忍 + self.assertEqual(response.status_code, 200) + finally: + del os.environ["SVFS_USERS"] + + def test_upload_then_serve(self): + package = self._build_sample() + response = self._upload(client_sample.PLUGIN_ID, package) + self.assertEqual(response.status_code, 200, response.text) + body = response.json() + self.assertTrue(body["ok"]) + self.assertEqual(body["id"], client_sample.PLUGIN_ID) + self.assertEqual(body["version"], "1.0.0") + + # 服务列表与 manifest 可取 + self.assertEqual(self.client.get("/").json()["plugins"], + [client_sample.PLUGIN_ID]) + response = self.client.get(f"/{client_sample.PLUGIN_ID}/manifest.json") + self.assertEqual(response.status_code, 200) + manifest = response.json() + self.assertEqual(manifest["format"], "svfs-objects") + + # 任意一个 blob 也能按 hash 取到 + blob_hash = next( + entry["hash"] for entry in manifest["entries"].values() + if "hash" in entry + ) + response = self.client.get( + f"/{client_sample.PLUGIN_ID}/blobs/{blob_hash[:2]}/{blob_hash}" + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(len(response.content) > 0) + + def test_upload_rejects_mismatched_id(self): + package = self._build_sample() + response = self._upload("com.other.app", package) + self.assertEqual(response.status_code, 400) + self.assertIn("不一致", response.json()["detail"]) + + def test_upload_rejects_corrupt_package(self): + junk = self.path("junk.svfs") + with open(junk, "wb") as handle: + handle.write(b"not a sqlite file") + response = self._upload("com.other.app", junk) + self.assertEqual(response.status_code, 400) + + def test_upload_token(self): + package = self._build_sample() + os.environ["SVFS_UPLOAD_TOKEN"] = "secret" + try: + response = self._upload(client_sample.PLUGIN_ID, package) + self.assertEqual(response.status_code, 403) + response = self._upload(client_sample.PLUGIN_ID, package, + token="wrong") + self.assertEqual(response.status_code, 403) + response = self._upload(client_sample.PLUGIN_ID, package, + token="secret") + self.assertEqual(response.status_code, 200) + finally: + del os.environ["SVFS_UPLOAD_TOKEN"] + + class PluginServerEndToEndTest(VFSTestCase): + """上传 → 服务端 publish → 真实 HTTP 服务 → 客户端 install_from_url。""" + + def test_full_circle(self): + plugins_dir = self.path("plugins-root") + mod = _load_example(plugins_dir) + client = TestClient(mod.app) + + package = self.path("sample.svfs") + with quiet(): + client_sample.build(package) + with open(package, "rb") as handle: + response = client.post( + f"/plugins/{client_sample.PLUGIN_ID}", + files={"file": ("plugin.svfs", handle.read())}, + ) + self.assertEqual(response.status_code, 200, response.text) + + # 用真实 HTTP 服务吐出插件目录,走客户端完整的远程安装链路 + server = ThreadingHTTPServer( + ("127.0.0.1", 0), partial(SimpleHTTPRequestHandler, + directory=plugins_dir) + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.shutdown) + base = f"http://127.0.0.1:{server.server_address[1]}" + + data_base = client_paths.ensure_layout(self.path("data")) + with quiet(): + entry = installer.install_from_url( + f"{base}/{client_sample.PLUGIN_ID}/manifest.json", + base=data_base, + ) + self.assertEqual(entry["version"], "1.0.0") + self.assertEqual(entry["source"]["type"], "url") + self.assertEqual( + registry.load(data_base)["plugins"][client_sample.PLUGIN_ID]["version"], + "1.0.0", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_studio_logic.py b/tests/test_studio_logic.py new file mode 100644 index 0000000..2af8f06 --- /dev/null +++ b/tests/test_studio_logic.py @@ -0,0 +1,480 @@ +"""SVFS Studio 的逻辑层测试(不需要 PySide6)。 + +界面靠眼睛验收,但"大小算错、差异分错类、只读判断漏了写语句"这类问题不能靠眼睛, +所以这些纯逻辑都被钉在测试里。测试只 import ``svfs_studio.format`` / +``svfs_studio.analysis``,两个模块都是纯标准库的。 +""" + +import os +import sqlite3 +import unittest + +import helpers + +from sqlite_vfs.core import SQLiteVFS +from sqlite_vfs.folder_packer import FolderPacker + +from svfs_studio import analysis, format as fmt + + +class HumanFormatTest(unittest.TestCase): + def test_human_size(self): + self.assertEqual(fmt.human_size(None), "-") + self.assertEqual(fmt.human_size(0), "0 B") + self.assertEqual(fmt.human_size(512), "512 B") + self.assertEqual(fmt.human_size(1024), "1.00 KB") + self.assertEqual(fmt.human_size(1536), "1.50 KB") + self.assertEqual(fmt.human_size(1024 ** 2), "1.00 MB") + self.assertEqual(fmt.human_size(-2048), "-2.00 KB") + + def test_human_count(self): + self.assertEqual(fmt.human_count(1234567), "1,234,567") + self.assertEqual(fmt.human_count(None), "-") + + def test_perm_string(self): + self.assertEqual(fmt.perm_string(420), "rw-r--r--") # 420 == 0o644 + self.assertEqual(fmt.perm_string(493), "rwxr-xr-x") # 493 == 0o755 + self.assertEqual(fmt.perm_string(0), "---------") + self.assertEqual(fmt.perm_string(None), "-") + self.assertEqual(fmt.perm_octal(420), "644") + + def test_human_time_accepts_db_string_and_datetime(self): + self.assertEqual(fmt.human_time("2026-09-14 10:23:45"), "2026-09-14 10:23") + self.assertEqual(fmt.human_time(None), "-") + self.assertEqual(fmt.human_time(""), "-") + self.assertEqual(fmt.human_time("不是时间"), "不是时间") + + def test_ratio_and_percent(self): + self.assertEqual(fmt.ratio_text(50, 100), "50.0%") + self.assertEqual(fmt.ratio_text(0, 0), "-") + self.assertEqual(fmt.percent_text(25, 100), "25.0%") + self.assertEqual(fmt.percent_text(1, 0), "-") + + def test_sniff_text_decodes_utf8_then_gbk(self): + self.assertEqual(fmt.sniff_text("纯文本".encode("utf-8")), (False, "utf-8", "纯文本")) + # GBK 的字节不是合法 UTF-8,必须回退到 gbk 而不是显示乱码 + binary, encoding, text = fmt.sniff_text("中文内容测试".encode("gbk")) + self.assertFalse(binary) + self.assertEqual(encoding, "gbk") + self.assertEqual(text, "中文内容测试") + + def test_sniff_text_flags_binary(self): + binary, encoding, text = fmt.sniff_text(b"\x00\x01\x02\x03") + self.assertTrue(binary) + self.assertIsNone(text) + self.assertIsNone(encoding) + + def test_sniff_text_honours_bom(self): + binary, encoding, text = fmt.sniff_text(b"\xef\xbb\xbfhi") + self.assertFalse(binary) + self.assertEqual(encoding, "utf-8-sig") + + def test_hexdump_layout(self): + lines, truncated = fmt.hexdump(b"Hello, SVFS!", limit=8) + self.assertTrue(truncated) + self.assertEqual(len(lines), 1) + self.assertTrue(lines[0].startswith("00000000 48 65 6c 6c 6f 2c 20 53")) + self.assertTrue(lines[0].endswith("|Hello, S|")) + + def test_hexdump_full_line_alignment(self): + lines, truncated = fmt.hexdump(bytes(range(16))) + self.assertFalse(truncated) + self.assertEqual(len(lines), 1) + self.assertIn("|................|", lines[0]) + # 两行时 ASCII 列要对齐(同一列起) + lines, _ = fmt.hexdump(bytes(range(20))) + self.assertEqual(len(lines), 2) + self.assertEqual(lines[0].index("|"), lines[1].index("|")) + self.assertTrue(lines[1].startswith("00000010")) + + +class SnapshotDiffTest(unittest.TestCase): + @staticmethod + def row(path, is_directory=False, size=0, content_hash=None, mtime="2026-01-01 00:00:00", + permissions=420): + return { + "path": path, + "is_directory": is_directory, + "file_size": size, + "content_hash": content_hash, + "modified_time": mtime, + "permissions": permissions, + } + + def test_classifies_every_kind(self): + old = [ + self.row("keep.txt", size=3, content_hash="aaa"), + self.row("gone.txt", size=3, content_hash="bbb"), + self.row("edit.txt", size=3, content_hash="ccc"), + self.row("meta.txt", size=3, content_hash="ddd"), + self.row("swap", is_directory=False, size=1, content_hash="eee"), + self.row("dir", is_directory=True), + ] + new = [ + self.row("keep.txt", size=3, content_hash="aaa"), + self.row("edit.txt", size=5, content_hash="zzz"), + self.row("meta.txt", size=3, content_hash="ddd", mtime="2026-02-02 00:00:00"), + self.row("swap", is_directory=True), + self.row("dir", is_directory=True), + self.row("fresh.txt", size=9, content_hash="fff"), + ] + diff = analysis.snapshot_diff(old, new) + + self.assertEqual(diff["counts"]["added"], 1) + self.assertEqual(diff["added"][0]["path"], "fresh.txt") + self.assertEqual(diff["counts"]["removed"], 1) + self.assertEqual(diff["removed"][0]["path"], "gone.txt") + self.assertEqual([item["path"] for item in diff["content"]], ["edit.txt"]) + self.assertEqual(diff["content"][0]["size_delta"], 2) + self.assertEqual([item["path"] for item in diff["type"]], ["swap"]) + self.assertEqual([item["path"] for item in diff["meta"]], ["meta.txt"]) + self.assertEqual(diff["unchanged"], 2) # keep.txt / dir + self.assertEqual(diff["total_changed"], 5) + + def test_directory_mtime_is_ignored(self): + """往目录里加文件会改目录 mtime,那不算"变更"(否则差异视图全是噪音)。""" + old = [self.row("dir", is_directory=True, mtime="2026-01-01 00:00:00")] + new = [self.row("dir", is_directory=True, mtime="2026-03-03 12:00:00")] + diff = analysis.snapshot_diff(old, new) + self.assertEqual(diff["total_changed"], 0) + self.assertEqual(diff["unchanged"], 1) + + def test_same_size_different_hash_is_content_change(self): + old = [self.row("a.bin", size=1024, content_hash="old")] + new = [self.row("a.bin", size=1024, content_hash="new")] + diff = analysis.snapshot_diff(old, new) + self.assertEqual(diff["counts"]["content"], 1) + self.assertEqual(diff["content"][0]["size_delta"], 0) + + def test_permission_only_change_is_meta(self): + old = [self.row("x.sh", size=5, content_hash="h", permissions=420)] + new = [self.row("x.sh", size=5, content_hash="h", permissions=493)] + diff = analysis.snapshot_diff(old, new) + self.assertEqual(diff["counts"]["meta"], 1) + + def test_empty_inputs(self): + diff = analysis.snapshot_diff([], []) + self.assertEqual(diff["total_changed"], 0) + self.assertEqual(diff["unchanged"], 0) + + +class StorageAnalysisTest(unittest.TestCase): + def test_storage_summary_accounts_for_each_stage(self): + entries = [ + {"path": "a.txt", "is_directory": False, "file_size": 100, "content_hash": "h1"}, + {"path": "b.txt", "is_directory": False, "file_size": 100, "content_hash": "h1"}, + {"path": "dir", "is_directory": True, "file_size": 0, "content_hash": None}, + ] + blobs = [{"hash": "h1", "size": 100, "stored_size": 40, "compress_algo": "zlib"}] + summary = analysis.storage_summary(entries, blobs, db_file_size=12288) + + self.assertEqual(summary["total_files"], 2) + self.assertEqual(summary["total_dirs"], 1) + self.assertEqual(summary["logical_size"], 200) + self.assertEqual(summary["unique_bytes"], 100) + self.assertEqual(summary["dedup_saved"], 100) + self.assertEqual(summary["stored_bytes"], 40) + self.assertEqual(summary["compression_saved"], 60) + self.assertEqual(summary["db_overhead"], 12288 - 40) + self.assertEqual(summary["blob_count"], 1) + + def test_algo_distribution_sorted_by_stored_size(self): + blobs = [ + {"hash": "a", "size": 10, "stored_size": 10, "compress_algo": "none"}, + {"hash": "b", "size": 100, "stored_size": 30, "compress_algo": "zlib"}, + {"hash": "c", "size": 100, "stored_size": 20, "compress_algo": "zlib"}, + ] + distribution = analysis.algo_distribution(blobs) + self.assertEqual([item["algo"] for item in distribution], ["zlib", "none"]) + self.assertEqual(distribution[0]["stored_size"], 50) + self.assertEqual(distribution[0]["count"], 2) + + def test_duplicate_groups_waste(self): + entries = [ + {"path": "a/vendor.js", "is_directory": False, "content_hash": "h", "file_size": 60}, + {"path": "b/vendor.js", "is_directory": False, "content_hash": "h", "file_size": 60}, + {"path": "c/vendor.js", "is_directory": False, "content_hash": "h", "file_size": 60}, + {"path": "only.js", "is_directory": False, "content_hash": "k", "file_size": 5}, + {"path": "dir", "is_directory": True, "content_hash": None, "file_size": 0}, + ] + blobs = {"h": {"size": 60, "stored_size": 20, "compress_algo": "zlib"}} + groups = analysis.duplicate_groups(entries, blobs) + + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["refcount"], 3) + self.assertEqual(groups[0]["wasted_bytes"], 120) + self.assertEqual(groups[0]["paths"], ["a/vendor.js", "b/vendor.js", "c/vendor.js"]) + + def test_top_files_carries_blob_info(self): + entries = [ + {"path": "big.bin", "is_directory": False, "file_size": 900, "content_hash": "h"}, + {"path": "small.bin", "is_directory": False, "file_size": 9, "content_hash": "k"}, + ] + blobs = {"h": {"size": 900, "stored_size": 300, "compress_algo": "zlib"}} + top = analysis.top_files(entries, blobs, limit=1) + self.assertEqual(len(top), 1) + self.assertEqual(top[0]["path"], "big.bin") + self.assertEqual(top[0]["stored_size"], 300) + self.assertEqual(top[0]["algo"], "zlib") + + +class FakeCursor: + def __init__(self, rows): + self._rows = rows + + def fetchone(self): + return self._rows[0] + + +class NoDbstatConn: + """模拟没编译 dbstat 的 SQLite:只答得出 COUNT(*)。""" + + def execute(self, sql, *args): + if "dbstat" in sql.lower(): + raise sqlite3.OperationalError("no such table: dbstat") + return FakeCursor([(7,)]) + + +class SqliteInternalsTest(helpers.VFSTestCase): + def setUp(self): + super().setUp() + self.src = helpers.make_tree(self.path("src"), {"a.txt": b"aaa", "sub/b.txt": b"bbb"}) + self.db = self.path("p.svfs") + helpers.pack(self.src, self.db, compress=True) + + def test_read_header_parses_real_file(self): + header = analysis.read_header(self.db) + self.assertTrue(header["valid"]) + self.assertEqual(header["page_size"], 4096) + self.assertGreater(header["page_count"], 0) + self.assertEqual(header["text_encoding_name"], "UTF-8") + + def test_read_header_rejects_non_sqlite(self): + path = self.path("garbage.bin") + with open(path, "wb") as handle: + handle.write(b"x" * 200) + header = analysis.read_header(path) + self.assertFalse(header["valid"]) + self.assertIn("SQLite", header["error"]) + + def test_read_header_rejects_tiny_file(self): + path = self.path("tiny.bin") + with open(path, "wb") as handle: + handle.write(b"SQL") + self.assertFalse(analysis.read_header(path)["valid"]) + + def test_internals_reports_occupancy_and_objects(self): + vfs = SQLiteVFS(self.db, readonly=True) + try: + info = analysis.sqlite_internals(vfs.conn, self.db) + finally: + vfs.close() + + self.assertTrue(info["ok"], info.get("error")) + self.assertIn(info["occupancy_mode"], ("dbstat", "rowcount")) + names = {obj["name"] for obj in info["objects"]} + self.assertTrue({"files", "blobs", "snapshots", "signatures"} <= names) + self.assertEqual(info["total_bytes"], info["pragmas"]["page_size"] * info["pragmas"]["page_count"]) + self.assertEqual(info["total_bytes"] - info["free_bytes"], info["used_bytes"]) + + rows = {row["name"]: row for row in info["occupancy"]} + self.assertIsNotNone(rows["files"]["rows"]) + if info["occupancy_mode"] == "dbstat": + self.assertGreater(rows["files"]["bytes"], 0) + + def test_occupancy_falls_back_without_dbstat(self): + objects = [ + {"type": "table", "name": "files", "table": "files", "rootpage": 2, "sql": ""}, + {"type": "index", "name": "idx", "table": "files", "rootpage": 3, "sql": ""}, + ] + occupancy, mode = analysis._occupancy(NoDbstatConn(), objects) + self.assertEqual(mode, "rowcount") + self.assertEqual([row["name"] for row in occupancy], ["files"]) + self.assertEqual(occupancy[0]["rows"], 7) + self.assertIsNone(occupancy[0]["bytes"]) + + +class ReadonlySqlTest(unittest.TestCase): + def test_accepts_readonly_statements(self): + for sql in ( + "SELECT * FROM files", + "select count(*) from blobs", + " WITH x AS (SELECT 1) SELECT * FROM x ", + "PRAGMA page_count", + "EXPLAIN SELECT 1", + "SELECT 1;", + ): + ok, reason = analysis.check_readonly_sql(sql) + self.assertTrue(ok, f"{sql!r} 应被接受,却被拒绝:{reason}") + + def test_rejects_writes_and_multi_statements(self): + for sql in ( + "", + " ", + "INSERT INTO files (path) VALUES ('x')", + "DELETE FROM blobs", + "DROP TABLE files", + "UPDATE snapshots SET name = 'x'", + "SELECT 1; DELETE FROM files", + "VACUUM", + ): + ok, reason = analysis.check_readonly_sql(sql) + self.assertFalse(ok, f"{sql!r} 应被拒绝") + self.assertTrue(reason) + + +class SqlConsoleTest(helpers.VFSTestCase): + def setUp(self): + super().setUp() + self.src = helpers.make_tree( + self.path("src"), {"a.txt": b"aaa", "b.txt": b"aaa", "sub/c.txt": b"ccc"} + ) + self.db = self.path("q.svfs") + helpers.pack(self.src, self.db, compress=True) + + def test_query_and_truncation(self): + vfs = SQLiteVFS(self.db, readonly=True) + try: + columns, rows, truncated = analysis.run_readonly_query( + vfs.conn, "SELECT path, file_size FROM files ORDER BY path", limit=2 + ) + finally: + vfs.close() + self.assertEqual(columns, ["path", "file_size"]) + self.assertEqual(len(rows), 2) + self.assertTrue(truncated) + + def test_write_attempt_fails_at_sqlite_level(self): + """就算白名单被绕过,mode=ro 连接也会拒绝写——两道闸都要在。""" + vfs = SQLiteVFS(self.db, readonly=True) + try: + with self.assertRaises(sqlite3.OperationalError): + vfs.conn.execute("DELETE FROM files") + finally: + vfs.close() + + def test_presets_all_run(self): + vfs = SQLiteVFS(self.db, readonly=True) + try: + for title, sql in analysis.SQL_PRESETS: + columns, rows, _truncated = analysis.run_readonly_query(vfs.conn, sql) + self.assertTrue(columns, f"预设 {title} 没返回列") + finally: + vfs.close() + + def test_refcounts_and_orphans(self): + vfs = SQLiteVFS(self.db, readonly=True) + try: + counts = analysis.refcounts(vfs.conn) + hashes = {row["hash"] for row in vfs.list_blobs()} + # a.txt 与 b.txt 内容相同,共用一个 blob + self.assertTrue(any(value == 2 for value in counts.values())) + self.assertEqual(analysis.orphan_blobs(vfs.conn)["count"], 0) + self.assertEqual(set(counts), hashes) + finally: + vfs.close() + + def test_orphan_detection_on_stray_blob(self): + """手工塞一条没人引用的 blob,孤立检测要能抓到(GC 前的预览就靠它)。""" + vfs = SQLiteVFS(self.db) + try: + vfs.conn.execute( + "INSERT INTO blobs (hash, size, stored_size, content, freq_blob, compress_algo)" + " VALUES ('deadbeef', 123, 123, X'00', NULL, 'none')" + ) + vfs.conn.commit() + finally: + vfs.close() + + vfs = SQLiteVFS(self.db, readonly=True) + try: + orphans = analysis.orphan_blobs(vfs.conn) + self.assertEqual(orphans["count"], 1) + self.assertEqual(orphans["size"], 123) + finally: + vfs.close() + + def test_snapshot_stats_reports_shared_blobs(self): + vfs = SQLiteVFS(self.db, compress=True) + try: + parent = vfs._active_snapshot() + new_id = vfs.create_snapshot("v2", parent_id=parent, copy_entries=True) + stats = analysis.snapshot_stats(vfs, new_id) + finally: + vfs.close() + # 整份复制过去的新快照:所有内容都是"别处也在用"的 + self.assertEqual(stats["files"], 3) + self.assertEqual(stats["shared_blobs"], stats["blob_count"]) + self.assertGreater(stats["shared_bytes"], 0) + + +class LocalCompareTest(helpers.VFSTestCase): + def setUp(self): + super().setUp() + self.src = helpers.make_tree( + self.path("src"), {"a.txt": b"aaa", "b.txt": b"bbb", "sub/c.txt": b"ccc"} + ) + self.db = self.path("c.svfs") + helpers.pack(self.src, self.db, compress=True) + + def entries(self): + vfs = SQLiteVFS(self.db, readonly=True) + try: + return vfs.all_entries() + finally: + vfs.close() + + def read_pack(self, path): + vfs = SQLiteVFS(self.db, readonly=True) + try: + return vfs.read_file(path) + finally: + vfs.close() + + def test_identical_tree_matches(self): + result = analysis.local_vs_snapshot_diff(self.src, self.entries()) + self.assertTrue(result["ok"], result["counts"]) + self.assertEqual(result["counts"]["identical"], 3) + self.assertEqual(result["total_local"], 3) + + def test_detects_each_difference(self): + helpers.write_file(os.path.join(self.src, "a.txt"), b"AAA") # 内容不同 + helpers.write_file(os.path.join(self.src, "new.txt"), b"new") # 仅本地 + os.remove(os.path.join(self.src, "b.txt")) # 仅包内 + helpers.write_file(os.path.join(self.src, "sub", "c.txt"), b"ccc") # 内容相同 + os.utime(os.path.join(self.src, "sub", "c.txt"), (1, 1)) # 只改了 mtime + + result = analysis.local_vs_snapshot_diff( + self.src, self.entries(), read_content=self.read_pack + ) + self.assertFalse(result["ok"]) + self.assertEqual([item["path"] for item in result["content"]], ["a.txt"]) + self.assertEqual([item["path"] for item in result["only_local"]], ["new.txt"]) + self.assertEqual([item["path"] for item in result["only_pack"]], ["b.txt"]) + self.assertEqual([item["path"] for item in result["meta"]], ["sub/c.txt"]) + + def test_size_only_mode_needs_no_read_content(self): + helpers.write_file(os.path.join(self.src, "a.txt"), b"AAAA") + result = analysis.local_vs_snapshot_diff(self.src, self.entries()) + self.assertEqual([item["path"] for item in result["content"]], ["a.txt"]) + + def test_progress_reports_every_local_file(self): + seen = [] + analysis.local_vs_snapshot_diff( + self.src, + self.entries(), + progress=lambda done, total, path: seen.append((done, total, path)), + ) + self.assertEqual(len(seen), 3) + self.assertEqual([done for done, _total, _p in seen], [1, 2, 3]) + self.assertEqual({total for _d, total, _p in seen}, {3}) + + def test_skips_vcs_directories(self): + helpers.write_file(os.path.join(self.src, ".git", "config"), b"[core]") + result = analysis.local_vs_snapshot_diff(self.src, self.entries()) + self.assertEqual(result["counts"]["only_local"], 0) + self.assertTrue(result["ok"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_studio_ui.py b/tests/test_studio_ui.py new file mode 100644 index 0000000..150f760 --- /dev/null +++ b/tests/test_studio_ui.py @@ -0,0 +1,1347 @@ +"""SVFS Studio 的界面测试(offscreen 渲染,不需要显示器)。 + +界面好不好看没法断言,但这些都是可以钉死的: + +* 七个标签页都能渲染出来,没有 0 尺寸/被裁掉的文本; +* 主题确实是暗色(采样渲染结果的像素算亮度); +* 预览显示的内容与 ``SQLiteVFS.read_file()`` 读出来的字节一致; +* 旧格式包会给出横幅,而 SQL 控制台在旧格式包上照样能用; +* SQL 控制台拒绝写语句;搜索能过滤树;校验/签名能跑通。 + +没装 PySide6 时整个模块跳过(GUI 是可选 extra,这不影响库本身)。 +""" + +import os +import sys +import time +import unittest + +import helpers + +from svfs_studio.format import short_hash + +# 必须在创建 QApplication 之前设定,否则会去找真实显示设备 +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +SRC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + +try: + from PySide6.QtCore import QSettings + from PySide6.QtWidgets import QApplication, QLabel, QWidget + + from svfs_studio.app import create_app + from svfs_studio.sample import build_demo_folder + from svfs_studio.widgets.cards import ElidedLabel + from svfs_studio.widgets.shell import MainWindow + + HAVE_QT = True +except ImportError as exc: # pragma: no cover - 取决于环境 + HAVE_QT = False + IMPORT_ERROR = str(exc) + + +def pump(app, predicate=None, timeout=15.0, interval=0.02, settle=0.08): + """转事件循环。 + + * 给了 ``predicate``:一直转到它成立(或超时),返回它是否成立; + * 没给:只把事件循环转 ``settle`` 秒,让布局/定时器/排队信号落地。 + (早期版本这里会白等满 timeout,七个标签页就是七个 15 秒。) + """ + if predicate is None: + deadline = time.monotonic() + settle + while time.monotonic() < deadline: + app.processEvents() + time.sleep(interval / 4) + app.processEvents() + return True + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + app.processEvents() + if predicate(): + return True + time.sleep(interval) + app.processEvents() + return bool(predicate()) + + +def walk(widget): + yield widget + for child in widget.findChildren(QWidget): + yield child + + +def luminance(color): + return 0.2126 * color.red() + 0.7152 * color.green() + 0.0722 * color.blue() + + +@unittest.skipUnless(HAVE_QT, f"需要 PySide6:{'未安装' if not HAVE_QT else ''}") +class StudioUITestCase(helpers.VFSTestCase): + """所有界面测试的公共脚手架:一个共享的 QApplication + 每个用例一个窗口。""" + + @classmethod + def setUpClass(cls): + # 把 QSettings 关进临时目录:测试不该读用户真实的窗口几何/最近文件, + # 否则"上次把窗口拖得很窄"会让某个用例莫名其妙地失败,也不该往注册表里写东西。 + import tempfile + + settings_dir = tempfile.mkdtemp(prefix="svfs_studio_settings_") + QSettings.setDefaultFormat(QSettings.Format.IniFormat) + QSettings.setPath( + QSettings.Format.IniFormat, QSettings.Scope.UserScope, settings_dir + ) + cls.app = QApplication.instance() or create_app([sys.argv[0]]) + cls.app.setQuitOnLastWindowClosed(False) + + def setUp(self): + super().setUp() + self.window = MainWindow() + self.window.resize(1400, 900) + self.window.show() + pump(self.app, interval=0.005) + self.progress_events = [] + self.window.client.progress.connect( + lambda job, done, total, label: self.progress_events.append((job, done, total)) + ) + + def tearDown(self): + try: + self.window.close_package(quiet=True) + self.window._thread.quit() + self.window._thread.wait(5000) + self.window.close() + finally: + pump(self.app, interval=0.005) + super().tearDown() + + # ------------------------------------------------------------------ + + def demo_package(self, target=None): + """打一个演示包(与界面里「打个示例包看看」同一份数据)。""" + target = target or self.path("demo.svfs") + source = self.path("src_tree") + os.makedirs(source, exist_ok=True) + build_demo_folder(source) + helpers.pack(source, target, compress=True) + return target, source + + def load(self, path): + """通过真实路径(后台线程 + 信号)打开一个包,并等到界面拿到数据。""" + self.window.open_package(path) + ok = pump(self.app, lambda: self.window.payload is not None, timeout=20) + self.assertTrue(ok, "打开包超时") + return self.window.payload + + def run_op(self, op, payload=None, timeout=30): + """在真实的事件循环里跑一个后台操作,返回结果。 + + 先等界面空闲再提交——独占车道一次只跑一个任务,界面上刚触发的"刷新"之类 + 还没结束时提交会被明确拒绝(这是刻意设计的,不是 bug)。 + """ + pump(self.app, lambda: not self.window.client.busy, timeout=20) + box = {} + + def done(result): + box["result"] = result + + def fail(title, detail): + box["error"] = f"{title}:{detail}" + + self.window.client.submit(op, payload or {}, on_done=done, on_fail=fail, label=op) + pump(self.app, lambda: "result" in box or "error" in box, timeout=timeout) + self.assertNotIn("error", box, box.get("error")) + return box["result"] + + def assert_rendered_cleanly(self, label): + """渲染自查:没有 0 尺寸部件、没有该省略却没省略的文本、整体是暗色。""" + image = self.window.grab().toImage() + self.assertFalse(image.isNull(), f"{label}: 渲染结果为空") + + # 背景必须是暗的(左上角是侧栏,右下角是内容区边缘) + for name, (x, y) in ( + ("左上", (4, 4)), + ("右下", (image.width() - 4, image.height() - 4)), + ): + value = luminance(image.pixelColor(x, y)) + self.assertLess(value, 110, f"{label}: {name}像素亮度 {value:.0f},暗色主题没生效") + + broken = [ + widget + for widget in walk(self.window) + if widget.isVisible() + and (widget.width() <= 0 or widget.height() <= 0) + # 空的表头/滚动视口在 Qt 里本来就是 0 尺寸 + and not widget.inherits("QHeaderView") + and widget.objectName() != "qt_scrollarea_viewport" + ] + self.assertEqual( + [(type(w).__name__, w.objectName()) for w in broken], [], f"{label}: 有部件被压成 0 尺寸" + ) + + clipped = [] + for widget in walk(self.window): + if not isinstance(widget, QLabel) or isinstance(widget, ElidedLabel): + continue + if not widget.isVisible() or not widget.text() or widget.wordWrap(): + continue + if widget.sizeHint().width() > widget.width() + 2: + clipped.append(widget.text()[:30]) + self.assertEqual(clipped, [], f"{label}: 这些文本被裁掉了") + + +class RenderTest(StudioUITestCase): + def test_recent_list_remembers_opened_packages(self): + target, _source = self.demo_package() + self.load(target) + self.assertEqual(self.window._recent_paths()[:1], [os.path.abspath(target)]) + # 重复打开不该在列表里出现两次 + self.load(target) + self.assertEqual(self.window._recent_paths().count(os.path.abspath(target)), 1) + self.window._fill_recent_menu() + labels = [ + action.text() for action in self.window.recent_menu.actions() if action.text() + ] + self.assertTrue(any(os.path.basename(target) in text for text in labels), labels) + + def test_all_tabs_render_cleanly(self): + target, _source = self.demo_package() + self.load(target) + tabs = self.window.tabs + self.assertEqual(tabs.count(), 7) + for index in range(tabs.count()): + tabs.setCurrentIndex(index) + pump(self.app, interval=0.02) + self.assert_rendered_cleanly(tabs.tabText(index)) + + def test_empty_state_renders_cleanly(self): + self.assert_rendered_cleanly("空状态页") + + def test_overview_numbers_match_library(self): + target, _source = self.demo_package() + payload = self.load(target) + overview = self.window._views["overview"] + summary = payload["summary"] + + self.assertIn(str(summary["total_files"]), overview.card_files._value.text()) + self.assertIn("份", overview.card_unique._value.text()) + # 演示目录里 assets/vendor.js 与 admin/vendor.js 内容相同 → 必然有去重收益 + self.assertGreater(summary["dedup_saved"], 0) + self.assertGreater(summary["compression_saved"], 0) + segments = overview.savings._segments + self.assertEqual(len(segments), 3) + total = sum(value for _label, value, _color in segments) + self.assertEqual(total, summary["logical_size"], "堆叠条三段之和必须等于逻辑大小") + + def test_storage_tab_lists_every_blob(self): + target, _source = self.demo_package() + payload = self.load(target) + self.window.goto_tab("storage") + pump(self.app, interval=0.02) + storage = self.window._views["storage"] + self.assertEqual(storage.table.table.rowCount(), len(payload["blobs"])) + self.assertEqual(len(payload["duplicates"]), 1) + self.assertEqual(payload["duplicates"][0]["refcount"], 2) + + +class BrowserTest(StudioUITestCase): + def test_preview_content_matches_library(self): + target, _source = self.demo_package() + payload = self.load(target) + browser = self.window._views["browser"] + self.window.goto_tab("browser") + + path = "assets/vendor.js" + browser.select_path(path) + pump(self.app, lambda: bool(browser.text_view.toPlainText()), timeout=15) + self.assertTrue(pump(self.app, lambda: "解码" in browser.preview_note.text(), timeout=15)) + + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + expected = vfs.read_file(path) + finally: + vfs.close() + shown = browser.text_view.toPlainText() + self.assertTrue(expected.startswith(shown.encode("utf-8")) or shown.startswith("export const")) + self.assertEqual(shown, expected.decode("utf-8")[: len(shown)]) + self.assertIn(path, browser.path_label.text()) + + def test_detail_card_lists_duplicate_paths(self): + target, _source = self.demo_package() + self.load(target) + browser = self.window._views["browser"] + # 必须切到这一页:标签页没被选中的话整棵子树 isVisible() 都是 False + self.window.goto_tab("browser") + browser.select_path("assets/vendor.js") + self.assertTrue( + pump(self.app, lambda: browser.other_card.isVisible(), timeout=15), + "同内容的其它路径没有显示出来", + ) + texts = [ + browser.other_list.itemAt(index).widget().text() + for index in range(browser.other_list.count()) + if browser.other_list.itemAt(index).widget() is not None + ] + self.assertIn("admin/vendor.js", texts) + + def test_hex_view_renders_bytes(self): + target, _source = self.demo_package() + self.load(target) + browser = self.window._views["browser"] + browser.select_path("index.html") + pump(self.app, lambda: bool(browser.text_view.toPlainText()), timeout=15) + browser.mode_box.setCurrentIndex(1) # 十六进制 + pump(self.app, lambda: bool(browser.hex_view.toPlainText()), timeout=15) + first = browser.hex_view.toPlainText().splitlines()[0] + self.assertTrue(first.startswith("00000000"), first) + self.assertIn("|", first) + + def test_search_filters_the_tree(self): + target, _source = self.demo_package() + self.load(target) + browser = self.window._views["browser"] + before = browser.proxy.rowCount() + browser.search.setText("vendor") + pump(self.app, interval=0.02) + self.assertLess(browser.proxy.rowCount(), before) + self.assertGreater(browser.proxy.rowCount(), 0) + browser.search.setText("") + pump(self.app, interval=0.02) + self.assertEqual(browser.proxy.rowCount(), before) + + +class SnapshotViewTest(StudioUITestCase): + def two_snapshot_package(self): + """造一个有两版历史的包:v2 里改一个文件、删一个文件、加一个文件。""" + from sqlite_vfs.core import SQLiteVFS + + source = self.path("tree") + helpers.make_tree( + source, {"keep.txt": b"keep", "edit.txt": b"old", "gone.txt": b"gone"} + ) + db = self.path("snap.svfs") + helpers.pack(source, db, compress=True) + + vfs = SQLiteVFS(db, compress=True) + try: + parent = vfs._active_snapshot() + vfs.create_snapshot("v2", parent_id=parent, copy_entries=True) + helpers.write_file(os.path.join(source, "edit.txt"), b"new-content") + helpers.write_file(os.path.join(source, "fresh.txt"), b"fresh") + os.remove(os.path.join(source, "gone.txt")) + with helpers.quiet(): + from sqlite_vfs.folder_packer import FolderPacker + + FolderPacker(vfs, fs_name="snap").pack_folder( + source, incremental=True, rehash=True + ) + ids = [row["id"] for row in vfs.list_snapshots()] + finally: + vfs.close() + return db, ids + + def test_snapshot_list_and_diff(self): + db, ids = self.two_snapshot_package() + self.load(db) + view = self.window._views["snapshots"] + self.window.goto_tab("snapshots") + pump(self.app, interval=0.02) + + self.assertEqual(view.tree.topLevelItemCount(), len(ids)) + # 默认比较"最新两版" + index_a = view.combo_a.findData(ids[0]) + index_b = view.combo_b.findData(ids[1]) + self.assertGreaterEqual(index_a, 0) + self.assertGreaterEqual(index_b, 0) + view.combo_a.setCurrentIndex(index_a) + view.combo_b.setCurrentIndex(index_b) + self.assertTrue( + pump(self.app, lambda: bool(view.diff_tree.topLevelItemCount()), timeout=20), + "差异树没有渲染出来", + ) + + counts = view._diff["counts"] + self.assertEqual(counts["added"], 1) # fresh.txt + self.assertEqual(counts["removed"], 1) # gone.txt + self.assertEqual(counts["content"], 1) # edit.txt + self.assertEqual(view._diff["unchanged"], 1) # keep.txt + self.assertIn("3 处变化", view.diff_badge.text()) + + def test_selecting_snapshot_switches_the_tree(self): + db, ids = self.two_snapshot_package() + self.load(db) + self.assertEqual(self.window.payload["snapshot_id"], ids[1]) + self.window.select_snapshot(ids[0]) + self.assertTrue( + pump(self.app, lambda: self.window.payload["snapshot_id"] == ids[0], timeout=20) + ) + browser = self.window._views["browser"] + # v1 里 gone.txt 还在,v2 里没有 + self.assertTrue(browser.model.find("gone.txt").isValid()) + self.assertFalse(browser.model.find("fresh.txt").isValid()) + + +class IntegrityViewTest(StudioUITestCase): + def test_verify_and_sign_roundtrip(self): + target, _source = self.demo_package() + self.load(target) + view = self.window._views["integrity"] + self.window.goto_tab("integrity") + + result = self.run_op("verify", {"check_content": True}) + self.assertTrue(result["report"]["ok"], result["report"]["errors"]) + view._on_verify(result) + pump(self.app, interval=0.02) + self.assertEqual(view.result_badge.text(), "通过") + self.assertIn("逐文件重算哈希 6", view.result_detail.text()) + + # 签名 → 验签 + view.key_input.setText("测试密钥") + view.key_id_input.setText("ci") + view._sign() + self.assertTrue( + pump(self.app, lambda: bool(view.sign_note.text()), timeout=20) + ) + self.assertIn("已签名", view.sign_note.text()) + verify = self.run_op( + "verify_signature", + {"key": "测试密钥", "key_id": "ci"}, + ) + self.assertTrue(verify["report"]["ok"], verify["report"]["errors"]) + wrong = self.run_op("verify_signature", {"key": "别的密钥"}) + self.assertFalse(wrong["report"]["ok"]) + + def test_signature_shows_up_in_table_after_refresh(self): + target, _source = self.demo_package() + self.load(target) + self.run_op("sign", {"key": "k", "key_id": "id-1"}) + self.window.refresh() + self.assertTrue( + pump(self.app, lambda: bool(self.window.payload.get("signatures")), timeout=20) + ) + view = self.window._views["integrity"] + view._fill_signatures(self.window.payload) + self.assertEqual(view.signature_table.table.rowCount(), 1) + self.assertEqual(view.signature_table.table.item(0, 1).text(), "id-1") + + +class InternalsViewTest(StudioUITestCase): + def test_internals_tables_populated(self): + target, _source = self.demo_package() + self.load(target) + self.window.goto_tab("internals") + info = self.run_op("internals", {}) + view = self.window._views["internals"] + view._on_internals(info) + pump(self.app, interval=0.02) + + self.assertTrue(info["header"]["valid"]) + self.assertEqual(info["header"]["page_size"], 4096) + self.assertEqual(info["occupancy_mode"], "dbstat") + self.assertIn("SQLite format 3", view.header_badge.text()) + self.assertGreater(view.table.table.rowCount(), 3) + names = { + view.table.table.item(row, 1).text() + for row in range(view.table.table.rowCount()) + } + self.assertTrue({"files", "blobs", "snapshots"} <= names) + + def test_sql_console_runs_select_and_rejects_write(self): + target, _source = self.demo_package() + self.load(target) + view = self.window._views["internals"] + + result = self.run_op("run_sql", {"sql": "SELECT count(*) AS n FROM files"}) + view._on_result(result) + pump(self.app, interval=0.02) + self.assertEqual(result["columns"], ["n"]) + self.assertEqual(view.result_table.rowCount(), 1) + self.assertIn("返回 1 行", view.sql_note.text()) + + view.sql_edit.setPlainText("DELETE FROM files") + view._run_sql() + pump(self.app, lambda: "只读" in view.sql_note.text(), timeout=10) + self.assertIn("只读控制台只允许", view.sql_note.text()) + # 白名单挡不住的话,mode=ro 连接也会挡——两层都要在 + view.sql_edit.setPlainText("SELECT * FROM files") + view._run_sql() + pump(self.app, lambda: "返回" in view.sql_note.text(), timeout=10) + + +class LegacyPackageTest(StudioUITestCase): + def legacy_path(self): + candidate = os.path.join( + os.path.dirname(SRC_DIR), "appcation.svfs" + ) + if not os.path.exists(candidate): + self.skipTest("仓库里没有 appcation.svfs") + return candidate + + def test_legacy_package_shows_banner_but_sql_still_works(self): + path = self.legacy_path() + payload = self.load(path) + self.assertEqual(payload.get("legacy"), 1) + self.assertTrue(self.window._banner_wrap.isVisible()) + self.assertIn("schema v1", self.window.banner._title.text()) + self.assertIn("去迁移", self.window.banner._button.text()) + + # 旧格式下 SQL 与内部结构仍然可用 + result = self.run_op("run_sql", {"sql": "SELECT count(*) AS n FROM files"}) + self.assertEqual(result["rows"][0][0], 39) + info = self.run_op("internals", {}) + self.assertTrue(info["header"]["valid"]) + + # 而需要 v3 结构的操作会明确报错,而不是静默返回空 + with self.assertRaises(Exception) as caught: + self.window.client.call_blocking("entries", {}) + self.assertIn("旧格式", str(caught.exception)) + + def test_migrate_from_banner_entry(self): + path = self.legacy_path() + self.load(path) + self.window._open_migrate_for_current() + pump(self.app, interval=0.02) + ops = self.window._views["ops"] + self.assertEqual(ops.stack.currentIndex(), 2) + self.assertEqual(ops.migrate_source.text(), path) + self.assertIn("v1", ops.migrate_detect.text()) + + output = self.path("migrated.svfs") + result = self.run_op( + "migrate", {"path": path, "backup": False, "output": output}, timeout=60 + ) + self.assertEqual(result["from_version"], 1) + self.assertEqual(result["to_version"], 3) + self.assertTrue(os.path.exists(output)) + ops._on_migrate_done(result) + # 迁移完会自动打开新包;要等到界面真的切换到新包为止 + # (原来的旧格式 payload 还在,所以不能用 "payload 不是 None" 当判据) + self.assertTrue( + pump( + self.app, + lambda: self.window.payload.get("legacy") is None + and self.window.payload.get("path") == output, + timeout=20, + ), + f"没等到界面切到迁移后的包:{self.window.payload.get('path')}", + ) + self.assertEqual(self.window.payload["stats"]["schema_version"], 3) + + +class OpsViewTest(StudioUITestCase): + def test_pack_through_the_ui_reports_progress(self): + source = self.path("tree") + helpers.make_tree(source, {"a.txt": b"a" * 5000, "sub/b.txt": b"b" * 5000}) + target = self.path("packed.svfs") + ops = self.window._views["ops"] + ops.pack_source.setText(source) + ops.pack_target.setText(target) + ops.pack_compress.setChecked(True) + ops._run_pack() + self.assertTrue(pump(self.app, lambda: not self.window.client.busy, timeout=30)) + self.assertTrue(os.path.exists(target)) + + jobs = {job for job, _done, _total in self.progress_events} + self.assertIn("打包", jobs) + self.assertTrue( + any(total and done == total for _job, done, total in self.progress_events), + f"进度没有走完:{self.progress_events[-3:]}", + ) + # 打完自动打开,界面上应该看到这两个文件 + self.assertTrue(pump(self.app, lambda: self.window.payload is not None, timeout=20)) + browser = self.window._views["browser"] + self.assertTrue(browser.model.find("a.txt").isValid()) + self.assertTrue(browser.model.find("sub/b.txt").isValid()) + + def test_unpack_then_compare_reports_identical(self): + target, source = self.demo_package() + self.load(target) + output = self.path("exported") + result = self.run_op("unpack", {"output": output}, timeout=30) + self.assertEqual(result["kind"], "filesystem") + self.assertGreater(result["count"], 0) + + payload = self.run_op("compare_local", {"local_root": source}, timeout=30) + self.assertTrue(payload["result"]["ok"], payload["result"]["counts"]) + + def test_compare_detects_a_changed_file(self): + target, source = self.demo_package() + self.load(target) + helpers.write_file(os.path.join(source, "app.js"), b"console.log('changed');\n") + payload = self.run_op("compare_local", {"local_root": source}, timeout=30) + counts = payload["result"]["counts"] + self.assertEqual(counts["content"], 1) + self.assertTrue(counts["only_pack"] + counts["identical"] >= 4) + + def test_gc_and_recount_via_ops(self): + target, _source = self.demo_package() + self.load(target) + # 造一份没人引用的内容 + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target) + try: + vfs.conn.execute( + "INSERT INTO blobs (hash, size, stored_size, content, freq_blob, compress_algo)" + " VALUES ('deadbeef', 100, 100, X'00', NULL, 'none')" + ) + vfs.conn.commit() + finally: + vfs.close() + self.window.refresh() + self.assertTrue(pump(self.app, lambda: self.window.payload["orphans"]["count"] == 1, timeout=20)) + + result = self.run_op("gc", {}) + self.assertEqual(result["removed"], 1) + self.assertEqual(result["after"]["count"], 0) + recount = self.run_op("recount", {}) + self.assertEqual(recount["recount"]["files"], 6) + + def test_publish_and_fetch_roundtrip(self): + target, source = self.demo_package() + self.load(target) + out_dir = self.path("public") + published = self.run_op( + "publish", {"out_dir": out_dir, "shard": 2}, timeout=30 + ) + self.assertGreater(published["objects"], 0) + self.assertTrue(os.path.exists(published["manifest"])) + # 清单必须覆盖当前快照里的每一个条目(文件 + 目录) + self.assertEqual(len(published["entries"]), len(self.window.payload["entries"])) + + fetched = self.run_op( + "fetch", {"manifest": published["manifest"], "out_dir": self.path("back")}, timeout=60 + ) + self.assertEqual(fetched["files"], 6) + self.assertEqual(fetched["verified"], published["objects"]) + + def test_fetch_result_matches_source_bytes(self): + target, source = self.demo_package() + self.load(target) + out_dir = self.path("public") + published = self.run_op("publish", {"out_dir": out_dir}, timeout=30) + back = self.path("back") + self.run_op("fetch", {"manifest": published["manifest"], "out_dir": back}, timeout=60) + helpers.assert_tree_equal(self, helpers.read_tree(source), helpers.read_tree(back)) + + +class OddFileTest(StudioUITestCase): + """打开"不是 SVFS 包"的文件:既不能崩,也不能假装打开成功。""" + + def test_plain_sqlite_db_degrades_to_internals(self): + import sqlite3 + + path = self.path("plain.db") + connection = sqlite3.connect(path) + connection.execute("CREATE TABLE t (a)") + connection.execute("INSERT INTO t VALUES (1)") + connection.commit() + connection.close() + + payload = self.load(path) + self.assertIsNone(payload.get("legacy"), "普通 SQLite 库不该被当成旧格式 SVFS") + self.assertTrue(self.window._banner_wrap.isVisible()) + self.assertIn("不是", self.window.banner._title.text()) + # 内部结构与 SQL 依然可用——正好用来看清"这到底是什么文件" + result = self.run_op("run_sql", {"sql": "SELECT name FROM sqlite_master"}) + self.assertEqual(result["rows"], [("t",)]) + info = self.run_op("internals", {}) + self.assertTrue(info["header"]["valid"]) + + def test_non_database_file_reports_clearly(self): + path = self.path("notes.txt") + helpers.write_file(path, b"hello, definitely not a database\n" * 10) + + self.window.open_package(path) + self.assertTrue( + pump(self.app, lambda: self.window._banner_wrap.isVisible(), timeout=20) + ) + self.assertIn("无法打开", self.window.banner._title.text()) + self.assertIn("也不是能读的 SQLite 数据库", self.window.banner._detail.text()) + # 失败之后不能留下上一个包的影子数据 + self.assertIsNone(self.window.payload) + self.assertEqual(self.window.pages.currentIndex(), 0) + + def test_failed_open_clears_previous_package(self): + target, _source = self.demo_package() + self.load(target) + self.assertIsNotNone(self.window.payload) + bad = self.path("bad.bin") + helpers.write_file(bad, b"\x00\x01\x02 not a db") + self.window.open_package(bad) + self.assertTrue(pump(self.app, lambda: self.window.payload is None, timeout=20)) + overview = self.window._views["overview"] + self.assertEqual(overview.info._row, 0, "概览页还留着上一个包的信息") + + +class NewPackageTest(StudioUITestCase): + """新建空包,再往里增量打文件——这是"从零开始用这个格式"的最短路径。""" + + def test_new_package_is_a_valid_empty_v3_package(self): + target = self.path("fresh.svfs") + self.assertFalse(os.path.exists(target)) + payload = self.run_op("new_package", {"path": target, "name": "从零开始"}) + self.assertTrue(payload.get("created")) + self.assertEqual(payload["stats"]["name"], "从零开始") + self.assertEqual(payload["stats"]["schema_version"], 3) + self.assertEqual(payload["summary"]["total_files"], 0) + self.assertEqual(payload["entries"], []) + self.assertEqual(payload["blobs"], []) + self.assertEqual(len(payload["snapshots"]), 1) + + # 空快照也要有摘要,而且校验要通过(不是"未计算摘要"那种半成品) + self.assertTrue(payload["stats"]["digest"]) + report = self.run_op("verify", {}) + self.assertTrue(report["report"]["ok"], report["report"]["errors"]) + + def test_ui_flow_create_then_pack_into_it(self): + target = self.path("collect.svfs") + self.window.new_package(target) + self.assertTrue( + pump(self.app, lambda: self.window.payload is not None, timeout=20) + ) + self.assertEqual(self.window.payload["summary"]["total_files"], 0) + self.assertIn("空包已建好", self.window.banner._title.text()) + self.assertTrue(self.window._banner_wrap.isVisible()) + + # 从横幅的入口跳到工具箱:应该预填目标文件并勾好增量 + self.window._focus_pack_into(target) + pump(self.app, interval=0.02) + ops = self.window._views["ops"] + self.assertEqual(self.window.tabs.currentIndex(), self.window._tab_index["ops"]) + self.assertEqual(ops.stack.currentIndex(), 0) + self.assertEqual(ops.pack_target.text(), target) + self.assertTrue(ops.pack_incremental.isChecked()) + + # 真打一轮进去:空包 → 有内容的包 + source = self.path("drop") + helpers.make_tree(source, {"hello.txt": b"hi", "sub/two.txt": b"two"}) + ops.pack_source.setText(source) + ops.pack_name.setText("从零开始") + ops._run_pack() + self.assertTrue( + pump( + self.app, + lambda: self.window.payload is not None + and self.window.payload["summary"]["total_files"] == 2, + timeout=30, + ), + f"文件没打进空包:{(self.window.payload or {}).get('summary')}", + ) + browser = self.window._views["browser"] + self.window.goto_tab("browser") + self.assertTrue(browser.model.find("hello.txt").isValid()) + self.assertTrue(browser.model.find("sub/two.txt").isValid()) + self.assertEqual(self.window.payload["stats"]["name"], "从零开始") + + # 再打一轮,这次填上快照名:增量 + 快照名 = 新开一版历史(只写 delta) + helpers.write_file(os.path.join(source, "third.txt"), b"three") + ops.pack_snapshot.setText("v2") + ops._run_pack() + self.assertTrue( + pump( + self.app, + lambda: len((self.window.payload or {}).get("snapshots") or []) == 2 + and (self.window.payload or {}).get("summary", {}).get("total_files") == 3, + timeout=30, + ), + "带快照名的增量打包没有产生第二版历史", + ) + # 第一版的快照名沿用建包时的默认名(取自文件名 collect.svfs → "collect"); + # 第二版用了显式的快照名 v2。断言"多了一版、且名字对得上"。 + names = {row["name"] for row in self.window.payload["snapshots"]} + self.assertEqual(names, {"collect", "v2"}) + # 新版是基于旧版复制出来的:旧版内容还在,切换回去能看到 + default_id = next( + row["id"] for row in self.window.payload["snapshots"] if row["name"] == "collect" + ) + self.window.select_snapshot(default_id) + self.assertTrue( + pump( + self.app, + lambda: (self.window.payload or {}).get("summary", {}).get("total_files") == 2, + timeout=20, + ), + "切回第一版应该还是 2 个文件(历史没有被动过)", + ) + # 回到最新版,第三个文件在那里 + self.window.select_snapshot( + max(row["id"] for row in self.window.payload["snapshots"]) + ) + self.assertTrue( + pump( + self.app, + lambda: (self.window.payload or {}).get("summary", {}).get("total_files") == 3, + timeout=20, + ), + "最新版应该有 3 个文件", + ) + + def test_new_package_refuses_to_overwrite_content(self): + target, _source = self.demo_package() + before = os.path.getsize(target) + with self.assertRaises(Exception) as caught: + self.window.client.call_blocking("new_package", {"path": target}) + self.assertIn("已经存在", str(caught.exception)) + self.assertEqual(os.path.getsize(target), before, "已有包被改动了") + + def test_rename_package(self): + target, _source = self.demo_package() + self.load(target) + payload = self.run_op("rename_package", {"name": "改名后的包"}) + self.assertEqual(payload["stats"]["name"], "改名后的包") + # 只动名字,条目与内容不能受影响 + self.assertEqual(payload["summary"]["total_files"], 6) + self.assertEqual(len(payload["snapshots"]), 1) + self.assertEqual(payload["summary"]["stored_bytes"], self.window.payload["summary"]["stored_bytes"]) + + ops = self.window._views["ops"] + ops._on_rename_done(payload) + self.assertIn("改名后的包", self.window.payload["stats"]["name"]) + + with self.assertRaises(Exception) as caught: + self.window.client.call_blocking("rename_package", {"name": " "}) + self.assertIn("不能为空", str(caught.exception)) + + def test_pack_full_into_existing_package_refuses_new_snapshot(self): + """全量打包会清空整库,所以"在新快照里累加"这种组合必须被拒绝而不是静默清库。""" + target, source = self.demo_package() + self.load(target) + with self.assertRaises(Exception) as caught: + self.window.client.call_blocking( + "pack", + { + "source": source, + "target": target, + "incremental": False, + "snapshot_name": "v2", + }, + ) + self.assertIn("增量打包", str(caught.exception)) + + +class EditTest(StudioUITestCase): + """包内编辑:建目录 / 加文件 / 改名 / 删除。 + + 这些是写路径,断言两条:动作生效了,而且没有偷偷改到别的东西(引用计数、快照数)。 + """ + + def browser(self, target): + self.load(target) + self.window.goto_tab("browser") + pump(self.app, interval=0.02) + return self.window._views["browser"] + + def wait_entries(self, count): + """等到当前快照的条目数变成 count。""" + return pump( + self.app, + lambda: (self.window.payload or {}).get("summary", {}).get("total_entries") == count, + timeout=20, + ) + + def test_edit_buttons_follow_package_state(self): + browser = self.window._views["browser"] + self.assertFalse(browser.add_button.isEnabled(), "没打开包时不该能编辑") + target, _source = self.demo_package() + browser = self.browser(target) + self.assertTrue(browser.add_button.isEnabled()) + self.assertTrue(browser.mkdir_button.isEnabled()) + # 没选中任何条目时,改名/删除不可用 + self.assertFalse(browser.rename_button.isEnabled()) + self.assertFalse(browser.delete_button.isEnabled()) + + def test_edit_keeps_package_verifiable(self): + """改完库要能立刻校验通过:否则用户会以为库坏了。 + + 靠的是编辑动作结束时调一次 recount()(刷新快照统计 + 摘要), + 与打包结束时做的是同一件事。 + """ + target, _source = self.demo_package() + browser = self.browser(target) + self.assertTrue(self.run_op("verify", {"check_content": True})["report"]["ok"]) + + local = self.path("extra.txt") + helpers.write_file(local, "新增内容\n".encode("utf-8")) + before = self.window.payload["summary"]["total_entries"] + browser.add_local([local], dest="", replace=False) + self.assertTrue(self.wait_entries(before + 1)) + + report = self.run_op("verify", {"check_content": True})["report"] + self.assertTrue(report["ok"], report["errors"]) + self.assertEqual(report["files_checked"], 7) + # 快照行上的统计也同步了(不是只更新了界面) + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + self.assertEqual(vfs.get_stats()["total_files"], 7) + finally: + vfs.close() + + def test_edit_invalidates_existing_signature_and_says_so(self): + target, _source = self.demo_package() + browser = self.browser(target) + self.run_op("sign", {"key": "k1", "key_id": "before-edit"}) + self.assertTrue(self.run_op("verify_signature", {"key": "k1"})["report"]["ok"]) + + local = self.path("after.txt") + helpers.write_file(local, b"after") + before = self.window.payload["summary"]["total_entries"] + browser.add_local([local], dest="", replace=False) + self.assertTrue(self.wait_entries(before + 1)) + + # 摘要变了,旧签名必须验不过——这是设计如此,界面上也会提示 + self.assertFalse( + self.run_op("verify_signature", {"key": "k1"})["report"]["ok"], + "改动之后旧签名不该还能验过", + ) + browser._after_edit(self.window.payload, "已加入 after.txt") + self.assertIn("签名已失效", self.window.status_label.full_text()) + + def test_create_add_rename_delete_roundtrip(self): + target, _source = self.demo_package() + browser = self.browser(target) + before = self.window.payload["summary"]["total_entries"] # 10 + + browser.create_directory("notes", parent="") + self.assertTrue(self.wait_entries(before + 1), "建目录没生效") + self.assertTrue(browser.model.find("notes").isValid()) + + local = self.path("note.txt") + helpers.write_file(local, "编辑器加进来的内容\n".encode("utf-8")) + browser.add_local([local], dest="notes", replace=False) + self.assertTrue(self.wait_entries(before + 2), "加文件没生效") + self.assertTrue(browser.model.find("notes/note.txt").isValid()) + + browser.rename_to("notes/note.txt", "renamed.txt") + # 改名不改变条目数,所以要等"新路径出现"而不是等计数 + self.assertTrue( + pump( + self.app, + lambda: browser.model.find("notes/renamed.txt").isValid() + and not browser.model.find("notes/note.txt").isValid(), + timeout=20, + ), + "改名没生效", + ) + + browser.delete_path("notes/renamed.txt") + self.assertTrue(self.wait_entries(before + 1), "删除没生效") + self.assertFalse(browser.model.find("notes/renamed.txt").isValid()) + + # 建目录再接上:删除目录会把子项一起带走 + browser.delete_path("notes") + self.assertTrue(self.wait_entries(before), "删目录没生效") + self.assertFalse(browser.model.find("notes").isValid()) + + def test_added_file_content_matches_disk(self): + target, _source = self.demo_package() + browser = self.browser(target) + local = self.path("payload.bin") + data = bytes(range(256)) * 8 + helpers.write_file(local, data) + browser.add_local([local], dest="", replace=False) + self.assertTrue(pump(self.app, lambda: browser.model.find("payload.bin").isValid(), timeout=20)) + + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + self.assertEqual(vfs.read_file("payload.bin"), data) + finally: + vfs.close() + + def test_adding_a_folder_brings_the_whole_tree(self): + target, _source = self.demo_package() + browser = self.browser(target) + folder = self.path("drop") + helpers.make_tree( + folder, {"a.txt": b"a", "sub/b.txt": b"b", "sub/deep/c.txt": b"c"} + ) + browser.add_local([folder], dest="incoming", replace=False) + self.assertTrue( + pump( + self.app, + lambda: browser.model.find("incoming/drop/sub/deep/c.txt").isValid(), + timeout=20, + ), + "整棵目录没加进去", + ) + paths = {row["path"] for row in self.window.payload["entries"]} + self.assertTrue( + { + "incoming/drop", + "incoming/drop/a.txt", + "incoming/drop/sub/b.txt", + "incoming/drop/sub/deep/c.txt", + } + <= paths + ) + + def test_name_conflict_is_reported_and_can_be_replaced(self): + target, _source = self.demo_package() + browser = self.browser(target) + local = self.path("app.js") + helpers.write_file(local, "console.log('替换后的内容');\n".encode("utf-8")) + + # 不替换:路径撞上包里的 app.js,结果应该是"跳过了" + payload = self.run_op( + "add_paths", + {"local_paths": [local], "dest_dir": "", "replace_existing": False}, + ) + self.assertEqual(payload["conflicts"], ["app.js"]) + self.assertEqual(payload["added_files"], []) + # 冲突检测在 GUI 侧也要能提前算出来(按钮路径用它来决定要不要问) + self.assertEqual(browser._conflicting_paths([local], ""), ["app.js"]) + + # 替换:内容真的换了,而且条目数不变(还是覆盖那个路径) + before = len(self.window.payload["entries"]) + payload = self.run_op( + "add_paths", + {"local_paths": [local], "dest_dir": "", "replace_existing": True}, + ) + self.assertEqual(payload["added_files"], ["app.js"]) + self.assertEqual(len(payload["entries"]), before) + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + self.assertIn(b"\xe6\x9b\xbf\xe6\x8d\xa2", vfs.read_file("app.js")) + finally: + vfs.close() + + def test_delete_directory_takes_children_and_collects_orphans(self): + target, _source = self.demo_package() + browser = self.browser(target) + before = self.window.payload["summary"]["total_entries"] + + browser.delete_path("assets") + self.assertTrue(pump(self.app, lambda: not browser.model.find("assets").isValid(), timeout=20)) + # assets 下有 logo.svg 与 vendor.js,连带目录行一共少 3 个条目 + self.assertEqual(self.window.payload["summary"]["total_entries"], before - 3) + # vendor.js 的内容还被 admin/vendor.js 引用着,所以那份 blob 必须留着 + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + hashes = {row["hash"] for row in vfs.list_blobs()} + self.assertIn( + vfs.get_file_info("admin/vendor.js")["content_hash"], + hashes, + "还有路径引用这份内容,不该被回收", + ) + finally: + vfs.close() + self.assertEqual(self.window.payload["orphans"]["count"], 0) + + def test_rename_rejects_collision_and_slash(self): + target, _source = self.demo_package() + browser = self.browser(target) + self.assertIsNone(browser.rename_to("app.js", "index.html"), "重名不该放行") + self.assertIsNone(browser.rename_to("app.js", "sub/app.js"), "带斜杠的名字不该放行") + with self.assertRaises(Exception) as caught: + self.run_op("rename_path", {"source": "app.js", "dest": "index.html"}) + self.assertIn("已存在", str(caught.exception)) + + def test_edit_ops_refuse_legacy_package(self): + legacy = os.path.join(os.path.dirname(SRC_DIR), "appcation.svfs") + if not os.path.exists(legacy): + self.skipTest("仓库里没有 appcation.svfs") + self.load(legacy) + for op, payload in ( + ("mkdir", {"path": "x"}), + ("rename_path", {"source": "a", "dest": "b"}), + ("remove_paths", {"paths": ["a"]}), + ("add_paths", {"local_paths": [__file__], "dest_dir": ""}), + ): + with self.assertRaises(Exception) as caught: + self.window.client.call_blocking(op, payload) + self.assertIn("旧格式", str(caught.exception), f"{op} 应该拒绝旧格式包") + + def test_mkdir_requires_existing_parent(self): + target, _source = self.demo_package() + self.load(target) + with self.assertRaises(Exception) as caught: + self.window.client.call_blocking("mkdir", {"path": "nope/deep"}) + self.assertIn("父目录", str(caught.exception)) + + +class TextEditTest(StudioUITestCase): + """就地编辑文件内容:进入 → 改 → 保存 → 包内字节真的变了,且库仍可校验。""" + + def browser(self, target): + self.load(target) + self.window.goto_tab("browser") + pump(self.app, interval=0.02) + return self.window._views["browser"] + + def wait_badge(self, browser, content_hash): + return pump( + self.app, + lambda: browser.hash_badge.text().endswith(short_hash(content_hash, 10)), + timeout=15, + ) + + def test_edit_roundtrip_updates_package(self): + from sqlite_vfs.core import SQLiteVFS + + target, _source = self.demo_package() + browser = self.browser(target) + browser.select_path("app.js") + self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=15)) + old_badge = browser.hash_badge.text() + + browser.edit_button.setChecked(True) + self.assertTrue( + pump( + self.app, + lambda: browser._editing and not browser.text_view.isReadOnly(), + timeout=15, + ), + "没进入编辑模式", + ) + new_text = browser.text_view.toPlainText() + "\n// 由界面编辑器追加" + browser.text_view.setPlainText(new_text) + self.assertTrue(browser._edit_dirty) + self.assertTrue(browser.save_button.isVisible() and browser.save_button.isEnabled()) + + browser.save_edit() + self.assertTrue( + pump(self.app, lambda: (self.window.payload or {}).get("saved"), timeout=20), + "保存没有完成", + ) + saved = self.window.payload["saved"] + self.assertEqual(saved["path"], "app.js") + + # 包内的字节与编辑器里的一致 + vfs = SQLiteVFS(target, readonly=True) + try: + self.assertEqual(vfs.read_file("app.js").decode("utf-8"), new_text) + finally: + vfs.close() + + # 详情面板的哈希徽章刷新成了新内容哈希 + self.assertTrue(self.wait_badge(browser, saved["hash"]), browser.hash_badge.text()) + self.assertNotEqual(old_badge, browser.hash_badge.text()) + + # 库仍然可校验(编辑结束的 recount 刷新了摘要) + report = self.run_op("verify", {"check_content": True})["report"] + self.assertTrue(report["ok"], report["errors"]) + + # 退出编辑回到只读 + browser.edit_button.setChecked(False) + self.assertTrue(pump(self.app, lambda: browser.text_view.isReadOnly(), timeout=10)) + + def test_edit_gbk_file_keeps_encoding(self): + from sqlite_vfs.core import SQLiteVFS + + target, _source = self.demo_package() + browser = self.browser(target) + local = self.path("gbk.txt") + original = "这是 GBK 编码的内容" + helpers.write_file(local, original.encode("gbk")) + payload = self.run_op( + "add_paths", {"local_paths": [local], "dest_dir": "", "replace_existing": False} + ) + self.window.apply_payload(payload) + + browser.select_path("gbk.txt") + self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=15)) + browser.edit_button.setChecked(True) + # _editing 在内容装载前就置位了;要等编辑器可写、编码嗅探完成 + self.assertTrue( + pump(self.app, lambda: browser._editing and not browser.text_view.isReadOnly(), timeout=15) + ) + self.assertEqual(browser._edit_encoding, "gbk", "GBK 文件应按 GBK 编辑") + + edited = browser.text_view.toPlainText() + "\n追加一行中文" + browser.text_view.setPlainText(edited) + browser.save_edit() + self.assertTrue(pump(self.app, lambda: (self.window.payload or {}).get("saved"), timeout=20)) + + vfs = SQLiteVFS(target, readonly=True) + try: + raw = vfs.read_file("gbk.txt") + finally: + vfs.close() + self.assertEqual(raw, edited.encode("gbk"), "保存必须保留 GBK 编码") + self.assertEqual(self.window.payload["saved"]["encoding"], "gbk") + + def test_edit_guards(self): + target, _source = self.demo_package() + browser = self.browser(target) + + # 目录不可编辑 + browser.select_path("docs") + self.assertTrue(pump(self.app, lambda: b"_x" or browser._current == "docs", timeout=10)) + self.assertTrue(pump(self.app, lambda: not browser.edit_button.isEnabled(), timeout=10)) + + # 二进制不可编辑(PNG 魔数 + NUL) + binary = self.path("logo.bin") + helpers.write_file(binary, b"\x89PNG\r\n\x1a\n" + b"\x00\x01" * 40) + payload = self.run_op( + "add_paths", {"local_paths": [binary], "dest_dir": "", "replace_existing": False} + ) + self.window.apply_payload(payload) + browser.select_path("logo.bin") + self.assertTrue(pump(self.app, lambda: browser._current == "logo.bin", timeout=10)) + self.assertTrue(pump(self.app, lambda: not browser.edit_button.isEnabled(), timeout=10)) + + # 超过上限不可编辑 + big = self.path("big.txt") + helpers.write_file(big, b"x" * (1024 * 1024 + 100)) + payload = self.run_op( + "add_paths", {"local_paths": [big], "dest_dir": "", "replace_existing": False} + ) + self.window.apply_payload(payload) + browser.select_path("big.txt") + self.assertTrue(pump(self.app, lambda: browser._current == "big.txt", timeout=10)) + self.assertTrue(pump(self.app, lambda: not browser.edit_button.isEnabled(), timeout=10)) + self.assertIn("上限", browser.edit_button.toolTip()) + + # 空文件可以编辑(往里写内容是正常需求) + empty = self.path("empty.txt") + helpers.write_file(empty, b"") + payload = self.run_op( + "add_paths", {"local_paths": [empty], "dest_dir": "", "replace_existing": False} + ) + self.window.apply_payload(payload) + browser.select_path("empty.txt") + self.assertTrue(pump(self.app, lambda: browser._current == "empty.txt", timeout=10)) + self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=10)) + + def test_save_rejects_unencodable_text(self): + """latin-1 文件里输入中文:编码失败要报人话,而不是半个坏文件进库。""" + target, _source = self.demo_package() + browser = self.browser(target) + local = self.path("latin.txt") + helpers.write_file(local, "caf\xe9".encode("latin-1")) + payload = self.run_op( + "add_paths", {"local_paths": [local], "dest_dir": "", "replace_existing": False} + ) + self.window.apply_payload(payload) + browser.select_path("latin.txt") + self.assertTrue(pump(self.app, lambda: browser.edit_button.isEnabled(), timeout=15)) + browser.edit_button.setChecked(True) + self.assertTrue( + pump(self.app, lambda: browser._editing and not browser.text_view.isReadOnly(), timeout=15) + ) + self.assertEqual(browser._edit_encoding, "latin-1") + + box = {} + self.window.client.submit( + "save_text", + {"path": "latin.txt", "text": "这里换成中文", "encoding": "latin-1"}, + on_done=lambda r: box.setdefault("ok", r), + on_fail=lambda t, d: box.setdefault("err", (t, d)), + label="保存", + ) + self.assertTrue( + pump(self.app, lambda: "ok" in box or "err" in box, timeout=20), + "保存既没成功也没失败", + ) + self.assertIn("err", box) + self.assertIn("编码", box["err"][1]) + + # 包里的内容保持原样(没有半写状态) + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + self.assertEqual(vfs.read_file("latin.txt"), "caf\xe9".encode("latin-1")) + finally: + vfs.close() + + def test_oversize_save_refused_by_service(self): + """服务层兜底:对超过上限的文件做文本保存必须拒绝——编辑器装不下它, + 任何"保存"都意味着把没显示的部分截掉。(往小文件里粘贴一大段是合法增长,不拦)""" + target, _source = self.demo_package() + self.load(target) + big = self.path("big.txt") + helpers.write_file(big, b"x" * (1024 * 1024 + 100)) + payload = self.run_op( + "add_paths", {"local_paths": [big], "dest_dir": "", "replace_existing": False} + ) + self.window.apply_payload(payload) + + box = {} + self.window.client.submit( + "save_text", + {"path": "big.txt", "text": "替换", "encoding": "utf-8"}, + on_done=lambda r: box.setdefault("ok", r), + on_fail=lambda t, d: box.setdefault("err", (t, d)), + label="保存", + ) + self.assertTrue( + pump(self.app, lambda: "ok" in box or "err" in box, timeout=20), + "超限保存既没成功也没失败", + ) + self.assertIn("err", box) + self.assertIn("上限", box["err"][1]) + + # 包里的内容原封不动 + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + self.assertEqual(len(vfs.read_file("big.txt")), 1024 * 1024 + 100) + finally: + vfs.close() + + +class CancelTest(StudioUITestCase): + """中断语义:库里包在进度回调里抛异常,异常会一路抛回服务层。 + + 这是"中断按钮不是装饰"的证据——顺带说明为什么只有带进度回调的任务才给中断按钮。 + """ + + def test_cancel_interrupts_packing(self): + source = self.path("big") + helpers.make_tree( + source, {f"f{index}.bin": bytes(40000 + index) for index in range(60)} + ) + target = self.path("big.svfs") + + ticks = [] + + def on_progress(job, done, total, label): + if job != "打包": + return + ticks.append(done) + if len(ticks) >= 3: + # 在第三个进度点请求中断 → 下一个进度点抛 TaskCancelled + self.window.client.cancel() + + self.window.client.progress.connect(on_progress) + box = {} + self.window.client.submit( + "pack", + {"source": source, "target": target, "compress": True}, + on_done=lambda result: box.setdefault("result", result), + on_fail=lambda title, detail: box.setdefault("error", (title, detail)), + label="打包", + ) + self.assertTrue( + pump(self.app, lambda: "result" in box or "error" in box, timeout=60), + "任务既没成功也没失败", + ) + self.assertIn("error", box, f"中断没有生效:{box.get('result')}") + self.assertEqual(box["error"][0], "已中断") + self.assertLess(len(ticks), 60, "进度点比条目数还多,说明循环没被打断") + + # 被打断的包是个部分结果:库能打开,但条目数明显不全 + from sqlite_vfs.core import SQLiteVFS + + vfs = SQLiteVFS(target, readonly=True) + try: + self.assertLess(len(vfs.all_entries()), 60) + finally: + vfs.close() + + +if __name__ == "__main__": + unittest.main()