From b491b6a146508d5da20ba2e2c9f61c24e2697d0a Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Wed, 1 Jul 2026 10:53:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=80=99=E9=80=89=EF=BC=9A?= =?UTF-8?q?=E6=95=B4=E5=90=88=E6=A1=8C=E9=9D=A2=E7=AB=AF=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=E5=8F=91=E5=B8=83=E7=A8=B3=E5=AE=9A=E5=8C=96=E9=87=8C?= =?UTF-8?q?=E7=A8=8B=E7=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/client-quality-gates.yml | 159 + .gitignore | 5 + AGENTS.md | 56 + README.md | 10 + .../20260630_02_add_desktop_phase_2_state.py | 74 + backend/app/api/v1/attachments.py | 5 +- backend/app/api/v1/auth.py | 4 +- backend/app/api/v1/desktop_notifications.py | 86 + backend/app/api/v1/permission_monitoring.py | 20 +- backend/app/api/v1/router.py | 3 +- backend/app/core/config.py | 12 + backend/app/db/base.py | 4 + backend/app/main.py | 20 +- backend/app/models/desktop_notification.py | 44 + backend/app/models/security_access_log.py | 5 + backend/app/schemas/notification.py | 28 + .../services/desktop_notification_service.py | 194 + backend/app/services/document_service.py | 19 +- .../services/security_access_log_writer.py | 5 + backend/tests/test_registration.py | 16 + docker-compose.dev.yaml | 9 +- docs/README.md | 4 + ...desktop-release-stabilization-checklist.md | 127 + docs/audits/storage-persistence-audit.md | 19 +- docs/branch-governance.md | 30 + docs/desktop-phase-1-design.md | 331 + docs/desktop-phase-2-design.md | 160 + docs/desktop-project-plan.md | 144 + docs/guides/branch-maintenance-sop-zh.md | 493 ++ docs/guides/client-release.md | 177 + frontend/.env.example | 3 + frontend/package-lock.json | 294 + frontend/package.json | 18 + frontend/scripts/client-version.mjs | 122 + frontend/scripts/verify-desktop-release.mjs | 218 + .../scripts/verify-desktop-update-feed.mjs | 98 + frontend/scripts/verify-release-build-env.mjs | 66 + frontend/scripts/verify-runtime-boundary.mjs | 41 + frontend/scripts/verify-ui-contract.mjs | 77 +- frontend/src-tauri/Cargo.lock | 5737 +++++++++++++++++ frontend/src-tauri/Cargo.toml | 37 + frontend/src-tauri/build.rs | 3 + frontend/src-tauri/capabilities/default.json | 27 + frontend/src-tauri/icons/128x128.png | Bin 0 -> 293 bytes frontend/src-tauri/icons/128x128@2x.png | Bin 0 -> 659 bytes frontend/src-tauri/icons/32x32.png | Bin 0 -> 99 bytes frontend/src-tauri/icons/64x64.png | Bin 0 -> 154 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 233 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 322 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 341 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 756 bytes frontend/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 96 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 846 bytes frontend/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 114 bytes frontend/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 166 bytes frontend/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 194 bytes frontend/src-tauri/icons/StoreLogo.png | Bin 0 -> 123 bytes frontend/src-tauri/icons/icon.icns | Bin 0 -> 12615 bytes frontend/src-tauri/icons/icon.ico | Bin 0 -> 1956 bytes frontend/src-tauri/icons/icon.png | Bin 0 -> 1809 bytes frontend/src-tauri/src/credentials.rs | 114 + frontend/src-tauri/src/lib.rs | 152 + frontend/src-tauri/src/main.rs | 5 + frontend/src-tauri/src/updates.rs | 116 + frontend/src-tauri/tauri.conf.json | 37 + frontend/src/App.vue | 8 + frontend/src/api/attachments.ts | 5 + frontend/src/api/auth.ts | 8 + frontend/src/api/authClient.ts | 17 +- frontend/src/api/axios.ts | 19 +- frontend/src/api/desktopNotifications.ts | 45 + frontend/src/api/projectPermissions.ts | 2 + .../src/audit/export/auditExportService.ts | 13 +- .../src/components/DesktopCommandPalette.vue | 212 + frontend/src/components/DesktopLayout.vue | 1667 +++++ .../src/components/Layout.desktop.test.ts | 37 + frontend/src/components/Layout.vue | 2042 +----- .../src/components/PermissionAccessLogs.vue | 13 +- .../src/components/SessionTimeoutPrompt.vue | 2 +- frontend/src/components/ThreadComposer.vue | 22 +- frontend/src/components/ThreadList.vue | 40 +- frontend/src/components/WebLayout.vue | 2535 ++++++++ .../components/attachments/AttachmentList.vue | 106 +- frontend/src/components/layout/navigation.ts | 298 + frontend/src/composables/useDesktopRefresh.ts | 23 + .../composables/useDesktopShortcuts.test.ts | 21 + .../src/composables/useDesktopShortcuts.ts | 58 + frontend/src/env.d.ts | 9 + frontend/src/main.ts | 41 +- frontend/src/router/desktopGuard.ts | 19 + frontend/src/router/index.ts | 16 +- frontend/src/runtime/apiBaseUrl.ts | 7 + frontend/src/runtime/appMetadata.test.ts | 56 + frontend/src/runtime/appMetadata.ts | 41 + frontend/src/runtime/clientRuntime.ts | 57 + frontend/src/runtime/desktopMenu.ts | 23 + .../src/runtime/desktopServerConfig.test.ts | 80 + frontend/src/runtime/desktopServerConfig.ts | 80 + .../src/runtime/desktopUiPreferences.test.ts | 71 + frontend/src/runtime/desktopUiPreferences.ts | 113 + frontend/src/runtime/files.ts | 145 + frontend/src/runtime/index.ts | 61 + frontend/src/runtime/notifications.ts | 25 + frontend/src/runtime/platform.ts | 21 + frontend/src/runtime/secureSessionStorage.ts | 117 + frontend/src/runtime/updates.ts | 39 + .../src/session/desktopNotificationManager.ts | 76 + frontend/src/session/desktopUpdateManager.ts | 122 + frontend/src/session/sessionManager.ts | 28 +- frontend/src/store/auth.test.ts | 2 +- frontend/src/store/auth.ts | 6 +- frontend/src/styles/main.css | 132 + frontend/src/types/api.ts | 5 + frontend/src/types/desktopCommands.test.ts | 32 + frontend/src/types/desktopCommands.ts | 22 + frontend/src/types/notifications.ts | 4 + frontend/src/utils/auth.ts | 12 +- frontend/src/views/DesktopPreferences.vue | 429 ++ frontend/src/views/DesktopServerSettings.vue | 231 + frontend/src/views/Login.test.ts | 12 + frontend/src/views/Login.vue | 114 +- frontend/src/views/ProfileSettings.vue | 259 +- frontend/src/views/Register.vue | 8 +- frontend/src/views/StudyHome.vue | 11 +- frontend/src/views/admin/ProjectDetail.vue | 16 +- frontend/src/views/admin/Projects.test.ts | 6 +- .../src/views/documents/DocumentDetail.vue | 65 +- frontend/src/views/documents/DocumentList.vue | 12 +- .../views/ia/RiskIssueMonitoringVisits.vue | 48 +- .../src/views/ia/SubjectManagement.test.ts | 12 + frontend/src/views/ia/SubjectManagement.vue | 382 +- frontend/vite.config.ts | 24 +- 132 files changed, 17337 insertions(+), 2375 deletions(-) create mode 100644 .github/workflows/client-quality-gates.yml create mode 100644 AGENTS.md create mode 100644 backend/alembic/versions/20260630_02_add_desktop_phase_2_state.py create mode 100644 backend/app/api/v1/desktop_notifications.py create mode 100644 backend/app/models/desktop_notification.py create mode 100644 backend/app/services/desktop_notification_service.py create mode 100644 docs/audits/desktop-release-stabilization-checklist.md create mode 100644 docs/desktop-phase-1-design.md create mode 100644 docs/desktop-phase-2-design.md create mode 100644 docs/desktop-project-plan.md create mode 100644 docs/guides/branch-maintenance-sop-zh.md create mode 100644 docs/guides/client-release.md create mode 100644 frontend/scripts/client-version.mjs create mode 100644 frontend/scripts/verify-desktop-release.mjs create mode 100644 frontend/scripts/verify-desktop-update-feed.mjs create mode 100644 frontend/scripts/verify-release-build-env.mjs create mode 100644 frontend/scripts/verify-runtime-boundary.mjs create mode 100644 frontend/src-tauri/Cargo.lock create mode 100644 frontend/src-tauri/Cargo.toml create mode 100644 frontend/src-tauri/build.rs create mode 100644 frontend/src-tauri/capabilities/default.json create mode 100644 frontend/src-tauri/icons/128x128.png create mode 100644 frontend/src-tauri/icons/128x128@2x.png create mode 100644 frontend/src-tauri/icons/32x32.png create mode 100644 frontend/src-tauri/icons/64x64.png create mode 100644 frontend/src-tauri/icons/Square107x107Logo.png create mode 100644 frontend/src-tauri/icons/Square142x142Logo.png create mode 100644 frontend/src-tauri/icons/Square150x150Logo.png create mode 100644 frontend/src-tauri/icons/Square284x284Logo.png create mode 100644 frontend/src-tauri/icons/Square30x30Logo.png create mode 100644 frontend/src-tauri/icons/Square310x310Logo.png create mode 100644 frontend/src-tauri/icons/Square44x44Logo.png create mode 100644 frontend/src-tauri/icons/Square71x71Logo.png create mode 100644 frontend/src-tauri/icons/Square89x89Logo.png create mode 100644 frontend/src-tauri/icons/StoreLogo.png create mode 100644 frontend/src-tauri/icons/icon.icns create mode 100644 frontend/src-tauri/icons/icon.ico create mode 100644 frontend/src-tauri/icons/icon.png create mode 100644 frontend/src-tauri/src/credentials.rs create mode 100644 frontend/src-tauri/src/lib.rs create mode 100644 frontend/src-tauri/src/main.rs create mode 100644 frontend/src-tauri/src/updates.rs create mode 100644 frontend/src-tauri/tauri.conf.json create mode 100644 frontend/src/api/desktopNotifications.ts create mode 100644 frontend/src/components/DesktopCommandPalette.vue create mode 100644 frontend/src/components/DesktopLayout.vue create mode 100644 frontend/src/components/Layout.desktop.test.ts create mode 100644 frontend/src/components/WebLayout.vue create mode 100644 frontend/src/components/layout/navigation.ts create mode 100644 frontend/src/composables/useDesktopRefresh.ts create mode 100644 frontend/src/composables/useDesktopShortcuts.test.ts create mode 100644 frontend/src/composables/useDesktopShortcuts.ts create mode 100644 frontend/src/router/desktopGuard.ts create mode 100644 frontend/src/runtime/apiBaseUrl.ts create mode 100644 frontend/src/runtime/appMetadata.test.ts create mode 100644 frontend/src/runtime/appMetadata.ts create mode 100644 frontend/src/runtime/clientRuntime.ts create mode 100644 frontend/src/runtime/desktopMenu.ts create mode 100644 frontend/src/runtime/desktopServerConfig.test.ts create mode 100644 frontend/src/runtime/desktopServerConfig.ts create mode 100644 frontend/src/runtime/desktopUiPreferences.test.ts create mode 100644 frontend/src/runtime/desktopUiPreferences.ts create mode 100644 frontend/src/runtime/files.ts create mode 100644 frontend/src/runtime/index.ts create mode 100644 frontend/src/runtime/notifications.ts create mode 100644 frontend/src/runtime/platform.ts create mode 100644 frontend/src/runtime/secureSessionStorage.ts create mode 100644 frontend/src/runtime/updates.ts create mode 100644 frontend/src/session/desktopNotificationManager.ts create mode 100644 frontend/src/session/desktopUpdateManager.ts create mode 100644 frontend/src/types/desktopCommands.test.ts create mode 100644 frontend/src/types/desktopCommands.ts create mode 100644 frontend/src/views/DesktopPreferences.vue create mode 100644 frontend/src/views/DesktopServerSettings.vue diff --git a/.github/workflows/client-quality-gates.yml b/.github/workflows/client-quality-gates.yml new file mode 100644 index 00000000..539a335f --- /dev/null +++ b/.github/workflows/client-quality-gates.yml @@ -0,0 +1,159 @@ +name: Client Quality Gates + +on: + pull_request: + paths: + - "AGENTS.md" + - "frontend/**" + - ".github/workflows/client-quality-gates.yml" + - "docs/branch-governance.md" + - "docs/desktop-project-plan.md" + - "docs/desktop-phase-1-design.md" + - "docs/desktop-phase-2-design.md" + - "docs/audits/desktop-release-stabilization-checklist.md" + - "docs/guides/client-release.md" + - "docs/guides/branch-maintenance-sop-zh.md" + push: + branches: + - dev + - main + - release + tags: + - "v*" + paths: + - "AGENTS.md" + - "frontend/**" + - ".github/workflows/client-quality-gates.yml" + - "docs/branch-governance.md" + - "docs/desktop-project-plan.md" + - "docs/desktop-phase-1-design.md" + - "docs/desktop-phase-2-design.md" + - "docs/audits/desktop-release-stabilization-checklist.md" + - "docs/guides/client-release.md" + - "docs/guides/branch-maintenance-sop-zh.md" + +jobs: + web: + name: Shared client and Web + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Resolve build metadata + id: build-metadata + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + channel="release" + elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then + channel="${GITHUB_REF_NAME}" + else + channel="dev" + fi + echo "channel=${channel}" >> "${GITHUB_OUTPUT}" + echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + + - name: Check release build metadata + run: npm run release:env:check + env: + VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }} + VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }} + + - name: Check synchronized client version + run: npm run version:check + + - name: Check runtime boundary + run: npm run runtime:check + + - name: Check desktop release and security gate + run: npm run desktop:release:check + + - name: Check UI contract + run: npm run ui:contract + + - name: Type check + run: npm run type-check + + - name: Unit tests + run: npm run test:unit + + - name: Build Web artifact + run: npm run build + env: + VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }} + VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }} + + desktop: + name: macOS Desktop + runs-on: macos-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: npm ci + + - name: Resolve build metadata + id: build-metadata + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + channel="release" + elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then + channel="${GITHUB_REF_NAME}" + else + channel="dev" + fi + echo "channel=${channel}" >> "${GITHUB_OUTPUT}" + echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + + - name: Check release build metadata + run: npm run release:env:check + env: + VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }} + VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }} + + - name: Check synchronized client version + run: npm run version:check + + - name: Check runtime boundary + run: npm run runtime:check + + - name: Check desktop release and security gate + run: npm run desktop:release:check + + - name: Check UI contract + run: npm run ui:contract + + - name: Build unsigned macOS application smoke artifact + run: npm run desktop:build:app + env: + VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }} + VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }} diff --git a/.gitignore b/.gitignore index 1d7c50f5..eb8d1fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,10 @@ pyrightconfig.json frontend/node_modules/ frontend/dist/ frontend/.vite/ +frontend/src-tauri/target/ +frontend/src-tauri/gen/schemas/ +frontend/src-tauri/icons/android/ +frontend/src-tauri/icons/ios/ npm-debug.log* yarn-debug.log* yarn-error.log* @@ -82,4 +86,5 @@ backend/app/uploads/ # Git worktrees .worktrees/ +worktrees/ .install-logs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c9c85ffa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Agent Instructions + +处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知和桌面端安全边界。 + +桌面端当前阶段边界以 `docs/desktop-project-plan.md` 为准。除非先明确修改计划书,否则不要实现离线功能、本地业务数据存储、内嵌后端服务、离线同步或新的阶段性桌面产品线。 + +`docs/desktop-project-plan.md` 是当前桌面端进展和工作边界的事实来源。本文件只保留执行约束,不重复维护审查结论或优化方向;不得重新按第一阶段空白项目初始化 Tauri,也不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。如实际代码状态与计划书不一致,先更新计划书再实现。 + +处理前端或桌面端实现时,优先保持以下边界: + +- 共享业务代码通过 `frontend/src/runtime/index.ts` 获取平台能力。 +- Tauri API 仅允许出现在 `frontend/src/runtime/`、`frontend/src-tauri/` 或有明确记录的窄入口中。 +- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。 +- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。 +- Windows 仍只作为第二阶段兼容性验证目标;未获明确批准前不发布正式 Windows 安装包。 + +## 分支与发布治理 + +处理代码提交、分支同步、版本晋级、正式发布或生产热修复前,必须先阅读: + +- `docs/guides/branch-maintenance-sop-zh.md` +- `docs/branch-governance.md` +- 涉及网页端或桌面端客户端发布时,还需阅读 `docs/guides/client-release.md` + +必须遵守以下规则: + +- CTMS 网页端和桌面端属于同一个产品,共用 `dev`、`main`、`release` 分支,不创建 `web-dev`、`desktop-dev`、`web-release`、`desktop-release` 等长期平行分支。 +- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`。 +- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。 +- `codex/ctms-desktop` 是历史桌面端临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。 +- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main` 和 `dev`。 +- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set` 和 `version:check` 命令。 +- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。 +- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。 +- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。 +- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行任何分支切换、提交、推送或变基前必须先说明目标基线,并等待用户明确指令。 +- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`。 + +## 常用质量门禁 + +前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑: + +```bash +cd frontend +npm run version:check +npm run release:env:check +npm run runtime:check +npm run desktop:release:check +npm run ui:contract +npm run type-check +npm run test:unit +npm run build +npm run desktop:build:app +``` + +正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。 diff --git a/README.md b/README.md index eec935b1..3b3310d6 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,18 @@ - 后端 API:同域 `/api/v1/*` - `nginx` 负责托管前端静态资源,并将 `/api` 与 `/health` 转发到 `backend` +## macOS 桌面端开发 +- 桌面端遵循 `docs/desktop-project-plan.md` 第一阶段边界:Tauri 在线客户端,不内嵌后端、不保存本地业务数据、不做离线同步。 +- 开发启动:进入 `frontend/` 后执行 `npm run desktop:dev`。 +- 生产构建:进入 `frontend/` 后执行 `npm run desktop:build`;DMG 构建执行 `npm run desktop:bundle:dmg`。 +- 首次启动桌面端会要求配置 CTMS 服务端地址,并在保存前检查 `${serverUrl}/health`。 +- 生产或非本地服务地址必须使用 HTTPS;本地开发允许 `http://localhost` 或 `http://127.0.0.1`。 +- Web 与桌面端共用产品版本;执行 `npm run version:set -- ` 统一升级,执行 `npm run version:check` 检查漂移。 +- 桌面端与 Web 端从同一发布标签和 Git 提交构建,具体流程见 `docs/guides/client-release.md`。 + ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` +- 分支维护中文 SOP:`docs/guides/branch-maintenance-sop-zh.md` - 分支环境安装配置:`docs/guides/branch-environment-installation.md` - 发布检查清单:`docs/guides/release-checklist.md` diff --git a/backend/alembic/versions/20260630_02_add_desktop_phase_2_state.py b/backend/alembic/versions/20260630_02_add_desktop_phase_2_state.py new file mode 100644 index 00000000..bda7fe6d --- /dev/null +++ b/backend/alembic/versions/20260630_02_add_desktop_phase_2_state.py @@ -0,0 +1,74 @@ +"""add desktop phase 2 notification and client metadata state + +Revision ID: 20260630_02 +Revises: 20260630_01 +Create Date: 2026-06-30 21:30:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "20260630_02" +down_revision: Union[str, None] = "20260630_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("security_access_logs", sa.Column("client_type", sa.String(16), nullable=True)) + op.add_column("security_access_logs", sa.Column("client_version", sa.String(32), nullable=True)) + op.add_column("security_access_logs", sa.Column("client_platform", sa.String(16), nullable=True)) + op.add_column("security_access_logs", sa.Column("build_channel", sa.String(16), nullable=True)) + op.add_column("security_access_logs", sa.Column("build_commit", sa.String(64), nullable=True)) + op.create_index( + "ix_security_log_client_created", + "security_access_logs", + ["client_type", "client_version", "created_at"], + ) + + op.create_table( + "desktop_notification_subscriptions", + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("enabled", sa.Boolean(), server_default=sa.false(), nullable=False), + sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("user_id"), + ) + op.create_table( + "desktop_notification_deliveries", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("distribution_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("claim_token", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["distribution_id"], ["distributions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "distribution_id", + name="uq_desktop_notification_user_distribution", + ), + ) + op.create_index( + "ix_desktop_notification_claim", + "desktop_notification_deliveries", + ["user_id", "delivered_at", "claimed_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_desktop_notification_claim", table_name="desktop_notification_deliveries") + op.drop_table("desktop_notification_deliveries") + op.drop_table("desktop_notification_subscriptions") + op.drop_index("ix_security_log_client_created", table_name="security_access_logs") + for column in ("build_commit", "build_channel", "client_platform", "client_version", "client_type"): + op.drop_column("security_access_logs", column) diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index ce1d393e..05e98136 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -346,13 +346,10 @@ async def preview_attachment( async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.UUID): - token = None auth_header = request.headers.get("Authorization") if auth_header and auth_header.lower().startswith("bearer "): token = auth_header.split(" ", 1)[1] - if not token: - token = request.query_params.get("token") - if not token: + else: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") payload = decode_token(token) user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index f1e6a76b..7dd9450c 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import File, UploadFile from pydantic import BaseModel, EmailStr, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -130,8 +130,10 @@ def ensure_user_active(db_user) -> None: @router.get("/email-domains", response_model=EmailDomainsResponse) async def read_email_domains( + response: Response, db: AsyncSession = Depends(get_db_session), ) -> EmailDomainsResponse: + response.headers["Cache-Control"] = "no-store" rows = await email_service.list_email_settings(db) return EmailDomainsResponse(items=[row.register_domain for row in rows]) diff --git a/backend/app/api/v1/desktop_notifications.py b/backend/app/api/v1/desktop_notifications.py new file mode 100644 index 00000000..b1940e84 --- /dev/null +++ b/backend/app/api/v1/desktop_notifications.py @@ -0,0 +1,86 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session +from app.schemas.notification import ( + DesktopNotificationAckRequest, + DesktopNotificationClaimRequest, + DesktopNotificationClaimResponse, + DesktopNotificationSubscriptionRead, + DesktopNotificationSubscriptionUpdate, +) +from app.services import desktop_notification_service + +router = APIRouter() + + +@router.get("/subscription", response_model=DesktopNotificationSubscriptionRead) +async def read_subscription( + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DesktopNotificationSubscriptionRead: + subscription = await desktop_notification_service.get_subscription(db, current_user.id) + return DesktopNotificationSubscriptionRead( + enabled=bool(subscription and subscription.enabled), + enabled_at=subscription.enabled_at if subscription else None, + ) + + +@router.put("/subscription", response_model=DesktopNotificationSubscriptionRead) +async def update_subscription( + payload: DesktopNotificationSubscriptionUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DesktopNotificationSubscriptionRead: + subscription = await desktop_notification_service.set_subscription( + db, current_user.id, payload.enabled + ) + return DesktopNotificationSubscriptionRead( + enabled=subscription.enabled, + enabled_at=subscription.enabled_at, + ) + + +@router.post("/claim", response_model=DesktopNotificationClaimResponse) +async def claim_notifications( + payload: DesktopNotificationClaimRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> DesktopNotificationClaimResponse: + if payload.limit < 1 or payload.limit > 50: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="limit 必须在 1 到 50 之间") + claim_token, lease_expires_at, items = await desktop_notification_service.claim_notifications( + db, current_user.id, payload.limit + ) + return DesktopNotificationClaimResponse( + claim_token=claim_token, + lease_expires_at=lease_expires_at, + items=items, + ) + + +@router.post("/ack", status_code=status.HTTP_204_NO_CONTENT) +async def acknowledge_notifications( + payload: DesktopNotificationAckRequest, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + await desktop_notification_service.acknowledge_notifications( + db, + current_user.id, + payload.claim_token, + payload.delivered_ids, + ) + + +@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT) +async def mark_notification_read( + distribution_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + await desktop_notification_service.mark_notification_read( + db, current_user.id, distribution_id + ) diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index 29a2d667..6e78242b 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, desc from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session, is_system_admin +from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids from app.core.permission_monitor import ( CACHE_HIT_RATE_HEALTH_MIN_ACCESSES, CACHE_METRICS_WINDOW_SECONDS, @@ -43,6 +43,11 @@ class MonitoringScope: async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope: if is_system_admin(current_user): return MonitoringScope(is_admin=True, study_ids=set()) + user_id = getattr(current_user, "id", None) + if user_id: + study_ids = await list_active_pm_study_ids(db, user_id) + if study_ids: + return MonitoringScope(is_admin=False, study_ids=study_ids) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") @@ -479,6 +484,8 @@ async def get_security_access_logs( _=Depends(get_current_user), status_min: Optional[int] = Query(None, ge=100, le=599), auth_status: Optional[str] = Query(None), + client_type: Optional[str] = Query(None), + client_version: Optional[str] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ) -> dict: @@ -486,11 +493,17 @@ async def get_security_access_logs( scope = await resolve_monitoring_scope(db, _) if not scope.is_admin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + client_type = client_type if isinstance(client_type, str) else None + client_version = client_version if isinstance(client_version, str) else None conditions = [] if status_min is not None: conditions.append(SecurityAccessLog.status_code >= status_min) if auth_status: conditions.append(SecurityAccessLog.auth_status == auth_status) + if client_type: + conditions.append(SecurityAccessLog.client_type == client_type) + if client_version: + conditions.append(SecurityAccessLog.client_version == client_version) query = select(SecurityAccessLog) count_query = select(func.count()).select_from(SecurityAccessLog) @@ -545,6 +558,11 @@ async def get_security_access_logs( "ip_city": ip_location.city, "ip_isp": ip_location.isp, "user_agent": log.user_agent, + "client_type": log.client_type, + "client_version": log.client_version, + "client_platform": log.client_platform, + "build_channel": log.build_channel, + "build_commit": log.build_commit, "auth_status": log.auth_status, "user_identifier": log.user_identifier, "account_label": _security_account_label(log.auth_status, log.user_identifier, user_names), diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index d4db06b1..5db13f67 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles +from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles api_router = APIRouter() @@ -10,6 +10,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(studies.router, prefix="/studies", tags=["studies"]) api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"]) api_router.include_router(notifications.router, prefix="/studies/{study_id}", tags=["notifications"]) +api_router.include_router(desktop_notifications.router, prefix="/desktop-notifications", tags=["desktop-notifications"]) api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"]) api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"]) api_router.include_router(api_permissions.router, tags=["api-permissions"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d19e199d..4a565b7d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,6 +26,10 @@ class Settings(BaseSettings): LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000 SETTINGS_ENCRYPTION_KEY: Optional[str] = None FRONTEND_PUBLIC_URL: str = "http://localhost:8888" + CORS_ALLOWED_ORIGINS: str = ( + "http://localhost:8888,http://localhost:5173," + "tauri://localhost,http://tauri.localhost" + ) IP2REGION_XDB_PATH: Optional[str] = None IP2REGION_IPV6_XDB_PATH: Optional[str] = None @@ -36,3 +40,11 @@ def get_settings() -> Settings: settings = get_settings() + + +def get_cors_allowed_origins() -> list[str]: + return [ + origin.strip() + for origin in settings.CORS_ALLOWED_ORIGINS.split(",") + if origin.strip() + ] diff --git a/backend/app/db/base.py b/backend/app/db/base.py index d54c1982..dac30b5b 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -42,4 +42,8 @@ from app.models.permission_access_log import PermissionAccessLog # noqa: F401 from app.models.permission_metric_snapshot import PermissionMetricSnapshot # noqa: F401 from app.models.permission_template import PermissionTemplate, PermissionTemplateVersion # noqa: F401 from app.models.security_access_log import SecurityAccessLog # noqa: F401 +from app.models.desktop_notification import ( # noqa: F401 + DesktopNotificationDelivery, + DesktopNotificationSubscription, +) from app.models.email_settings import EmailVerificationCode, SystemEmailSettings # noqa: F401 diff --git a/backend/app/main.py b/backend/app/main.py index 57fcd1bc..9ef74f6a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import text from app.api.v1.router import api_router -from app.core.config import settings +from app.core.config import get_cors_allowed_origins, settings from app.core.exceptions import register_exception_handlers from app.core.login_crypto import validate_login_crypto_configuration from app.crud.user import ensure_admin_exists @@ -116,10 +116,19 @@ def create_app() -> FastAPI: app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=get_cors_allowed_origins(), allow_credentials=True, allow_methods=["*"], - allow_headers=["*"], + allow_headers=[ + "Accept", + "Authorization", + "Content-Type", + "X-CTMS-Client-Type", + "X-CTMS-Client-Version", + "X-CTMS-Client-Platform", + "X-CTMS-Build-Channel", + "X-CTMS-Build-Commit", + ], ) @app.middleware("http") @@ -269,6 +278,11 @@ def _enqueue_security_access_log(request, path: str, status_code: int, started_a "elapsed_ms": round((time.perf_counter() - started_at) * 1000, 2), "client_ip": _resolve_client_ip(request), "user_agent": request.headers.get("user-agent"), + "client_type": request.headers.get("x-ctms-client-type"), + "client_version": request.headers.get("x-ctms-client-version"), + "client_platform": request.headers.get("x-ctms-client-platform"), + "build_channel": request.headers.get("x-ctms-build-channel"), + "build_commit": request.headers.get("x-ctms-build-commit"), "auth_status": auth_status, "user_identifier": user_identifier, } diff --git a/backend/app/models/desktop_notification.py b/backend/app/models/desktop_notification.py new file mode 100644 index 00000000..28e083fd --- /dev/null +++ b/backend/app/models/desktop_notification.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class DesktopNotificationSubscription(Base): + __tablename__ = "desktop_notification_subscriptions" + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class DesktopNotificationDelivery(Base): + __tablename__ = "desktop_notification_deliveries" + __table_args__ = ( + UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"), + Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + distribution_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False + ) + claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/security_access_log.py b/backend/app/models/security_access_log.py index 4de4dc6d..c52217ee 100644 --- a/backend/app/models/security_access_log.py +++ b/backend/app/models/security_access_log.py @@ -30,6 +30,11 @@ class SecurityAccessLog(Base): elapsed_ms: Mapped[float] = mapped_column(Float, nullable=False) client_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + client_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + client_version: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) + client_platform: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + build_channel: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) + build_commit: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) auth_status: Mapped[str] = mapped_column(String(30), nullable=False) user_identifier: Mapped[Optional[str]] = mapped_column(String(80), nullable=True) created_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index af6b8912..45f52062 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -16,5 +16,33 @@ class NotificationItem(BaseModel): change_summary: str | None = None effective_at: datetime | None = None created_at: datetime + study_id: uuid.UUID | None = None + study_name: str | None = None + delivered_at: datetime | None = None + read_at: datetime | None = None model_config = ConfigDict(from_attributes=True) + + +class DesktopNotificationSubscriptionRead(BaseModel): + enabled: bool + enabled_at: datetime | None = None + + +class DesktopNotificationSubscriptionUpdate(BaseModel): + enabled: bool + + +class DesktopNotificationClaimRequest(BaseModel): + limit: int = 20 + + +class DesktopNotificationClaimResponse(BaseModel): + claim_token: uuid.UUID | None = None + lease_expires_at: datetime | None = None + items: list[NotificationItem] + + +class DesktopNotificationAckRequest(BaseModel): + claim_token: uuid.UUID + delivered_ids: list[uuid.UUID] diff --git a/backend/app/services/desktop_notification_service.py b/backend/app/services/desktop_notification_service.py new file mode 100644 index 00000000..3171178e --- /dev/null +++ b/backend/app/services/desktop_notification_service.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import and_, exists, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.desktop_notification import ( + DesktopNotificationDelivery, + DesktopNotificationSubscription, +) +from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType +from app.models.document import Document +from app.models.document_version import DocumentVersion +from app.models.study import Study +from app.models.study_member import StudyMember +from app.schemas.notification import NotificationItem + +CLAIM_LEASE = timedelta(minutes=5) + + +async def get_subscription(db: AsyncSession, user_id: uuid.UUID) -> DesktopNotificationSubscription | None: + return await db.get(DesktopNotificationSubscription, user_id) + + +async def set_subscription( + db: AsyncSession, + user_id: uuid.UUID, + enabled: bool, +) -> DesktopNotificationSubscription: + subscription = await get_subscription(db, user_id) + now = datetime.now(timezone.utc) + if subscription is None: + subscription = DesktopNotificationSubscription( + user_id=user_id, + enabled=enabled, + enabled_at=now if enabled else None, + ) + db.add(subscription) + elif subscription.enabled != enabled: + subscription.enabled = enabled + subscription.enabled_at = now if enabled else None + await db.commit() + await db.refresh(subscription) + return subscription + + +def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime): + role_target_exists = exists( + select(StudyMember.id).where( + StudyMember.study_id == Document.trial_id, + StudyMember.user_id == user_id, + StudyMember.is_active.is_(True), + StudyMember.role_in_study == Distribution.target_id, + ) + ) + target_matches = or_( + and_( + Distribution.target_type == DistributionTargetType.USER, + Distribution.target_id == str(user_id), + ), + and_( + Distribution.target_type == DistributionTargetType.ROLE, + role_target_exists, + ), + ) + return ( + select( + Distribution, + Document, + DocumentVersion, + Study, + DesktopNotificationDelivery, + ) + .join(Document, Distribution.document_id == Document.id) + .join(DocumentVersion, Distribution.version_id == DocumentVersion.id) + .join(Study, Document.trial_id == Study.id) + .outerjoin( + DesktopNotificationDelivery, + and_( + DesktopNotificationDelivery.distribution_id == Distribution.id, + DesktopNotificationDelivery.user_id == user_id, + ), + ) + .where( + Distribution.status == DistributionStatus.ACTIVE, + Distribution.created_at >= enabled_at, + target_matches, + or_( + DesktopNotificationDelivery.id.is_(None), + and_( + DesktopNotificationDelivery.delivered_at.is_(None), + or_( + DesktopNotificationDelivery.claimed_at.is_(None), + DesktopNotificationDelivery.claimed_at < lease_cutoff, + ), + ), + ), + ) + .order_by(Distribution.created_at.asc()) + .with_for_update(of=Distribution, skip_locked=True) + ) + + +async def claim_notifications( + db: AsyncSession, + user_id: uuid.UUID, + limit: int, +) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]: + subscription = await get_subscription(db, user_id) + if not subscription or not subscription.enabled or not subscription.enabled_at: + return None, None, [] + + now = datetime.now(timezone.utc) + token = uuid.uuid4() + rows = ( + await db.execute( + _eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50))) + ) + ).all() + items: list[NotificationItem] = [] + for distribution, document, version, study, delivery in rows: + if delivery is None: + delivery = DesktopNotificationDelivery( + user_id=user_id, + distribution_id=distribution.id, + ) + db.add(delivery) + delivery.claim_token = token + delivery.claimed_at = now + items.append( + NotificationItem( + id=distribution.id, + document_id=document.id, + version_id=version.id, + document_title=document.title, + document_no=document.doc_no, + version_no=version.version_no, + change_summary=version.change_summary, + effective_at=version.effective_at, + created_at=distribution.created_at, + study_id=study.id, + study_name=study.name, + delivered_at=delivery.delivered_at, + read_at=delivery.read_at, + ) + ) + await db.commit() + if not items: + return None, None, [] + return token, now + CLAIM_LEASE, items + + +async def acknowledge_notifications( + db: AsyncSession, + user_id: uuid.UUID, + claim_token: uuid.UUID, + delivered_ids: list[uuid.UUID], +) -> None: + if delivered_ids: + await db.execute( + update(DesktopNotificationDelivery) + .where( + DesktopNotificationDelivery.user_id == user_id, + DesktopNotificationDelivery.claim_token == claim_token, + DesktopNotificationDelivery.distribution_id.in_(delivered_ids), + ) + .values(delivered_at=datetime.now(timezone.utc)) + ) + await db.commit() + + +async def mark_notification_read( + db: AsyncSession, + user_id: uuid.UUID, + distribution_id: uuid.UUID, +) -> None: + delivery = ( + await db.execute( + select(DesktopNotificationDelivery).where( + DesktopNotificationDelivery.user_id == user_id, + DesktopNotificationDelivery.distribution_id == distribution_id, + ) + ) + ).scalar_one_or_none() + if delivery is None: + delivery = DesktopNotificationDelivery( + user_id=user_id, + distribution_id=distribution_id, + ) + db.add(delivery) + delivery.read_at = datetime.now(timezone.utc) + await db.commit() diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 8c459631..0d7fd6d9 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -10,7 +10,7 @@ from typing import Iterable import aiofiles from fastapi import HTTPException, UploadFile, status from fastapi.responses import FileResponse -from sqlalchemy import delete as sa_delete, or_, select, update as sa_update +from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope @@ -30,6 +30,8 @@ from app.models.audit_log import AuditLog from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType from app.models.document import Document, DocumentScopeType, DocumentStatus from app.models.document_version import DocumentVersion, DocumentVersionStatus +from app.models.desktop_notification import DesktopNotificationDelivery +from app.models.study import Study from app.schemas.acknowledgement import AcknowledgementCreate from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary, DocumentUpdate @@ -778,9 +780,20 @@ async def list_distribution_notifications( DocumentVersion.version_no, DocumentVersion.change_summary, DocumentVersion.effective_at, + Study.name.label("study_name"), + DesktopNotificationDelivery.delivered_at, + DesktopNotificationDelivery.read_at, ) .join(DocumentVersion, Distribution.version_id == DocumentVersion.id) .join(Document, Distribution.document_id == Document.id) + .join(Study, Document.trial_id == Study.id) + .outerjoin( + DesktopNotificationDelivery, + and_( + DesktopNotificationDelivery.distribution_id == Distribution.id, + DesktopNotificationDelivery.user_id == current_user.id, + ), + ) .where( Document.trial_id == study_id, Distribution.status == DistributionStatus.ACTIVE, @@ -804,6 +817,10 @@ async def list_distribution_notifications( change_summary=row.change_summary, effective_at=row.effective_at, created_at=row.created_at, + study_id=study_id, + study_name=row.study_name, + delivered_at=row.delivered_at, + read_at=row.read_at, ) ) return items diff --git a/backend/app/services/security_access_log_writer.py b/backend/app/services/security_access_log_writer.py index 23354645..da25c487 100644 --- a/backend/app/services/security_access_log_writer.py +++ b/backend/app/services/security_access_log_writer.py @@ -76,6 +76,11 @@ class SecurityAccessLogWriter: elapsed_ms=entry["elapsed_ms"], client_ip=entry.get("client_ip"), user_agent=entry.get("user_agent"), + client_type=entry.get("client_type"), + client_version=entry.get("client_version"), + client_platform=entry.get("client_platform"), + build_channel=entry.get("build_channel"), + build_commit=entry.get("build_commit"), auth_status=entry["auth_status"], user_identifier=entry.get("user_identifier"), created_at=entry.get("created_at", datetime.now(timezone.utc)), diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index c8356ae8..4801b30a 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -70,6 +70,22 @@ async def add_email_settings(SessionLocal, domain: str) -> None: await session.commit() +@pytest.mark.asyncio +async def test_email_domains_come_only_from_email_service_settings(client_and_db): + client, SessionLocal = client_and_db + + empty_response = await client.get("/api/v1/auth/email-domains") + assert empty_response.status_code == 200 + assert empty_response.json() == {"items": []} + assert empty_response.headers["cache-control"] == "no-store" + + await add_email_settings(SessionLocal, "example.com") + configured_response = await client.get("/api/v1/auth/email-domains") + assert configured_response.status_code == 200 + assert configured_response.json() == {"items": ["example.com"]} + assert configured_response.headers["cache-control"] == "no-store" + + async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict: key_resp = await client.get("/api/v1/auth/login-key") assert key_resp.status_code == 200 diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 7f637013..80b45dba 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -26,7 +26,13 @@ services: set -e lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')" marker="node_modules/.ctms-package-lock.sha256" - if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ]; then + missing_runtime_deps=0 + for dep in @tauri-apps/api @tauri-apps/plugin-dialog @tauri-apps/plugin-fs @tauri-apps/plugin-opener; do + if [ ! -d "node_modules/$$dep" ]; then + missing_runtime_deps=1 + fi + done + if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ] || [ "$$missing_runtime_deps" = "1" ]; then npm ci printf '%s' "$$lock_hash" > "$$marker" fi @@ -35,6 +41,7 @@ services: NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com} VITE_RUNTIME_ENV: ${ENV:-development} VITE_ALLOW_INSECURE_DEV_LOGIN: ${VITE_ALLOW_INSECURE_DEV_LOGIN:-true} + VITE_HMR_CLIENT_PORT: ${VITE_HMR_CLIENT_PORT:-8888} healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5173/"] interval: 2s diff --git a/docs/README.md b/docs/README.md index c389c695..b5282827 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,8 +4,12 @@ CTMS 文档入口只展示当前仍会影响开发、发布和运维决策的内 ## 当前约束 +- [`desktop-project-plan.md`](desktop-project-plan.md): 桌面端 Tauri 项目边界、阶段计划与必读约束 +- [`desktop-phase-1-design.md`](desktop-phase-1-design.md): 桌面端第一阶段 macOS 在线客户端详细方案 - [`branch-governance.md`](branch-governance.md): 长期分支治理规则 - [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁 +- [`guides/client-release.md`](guides/client-release.md): Web/桌面端统一版本、构建与发布流程 +- [`guides/branch-maintenance-sop-zh.md`](guides/branch-maintenance-sop-zh.md): 分支维护、版本晋级、发布和热修复中文标准操作规程 - [`audits/storage-persistence-governance.md`](audits/storage-persistence-governance.md): 重要数据落库治理基线 - [`audits/module-level-permissions-transition.md`](audits/module-level-permissions-transition.md): 模块级权限迁移状态与约束 diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md new file mode 100644 index 00000000..5a4448d2 --- /dev/null +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -0,0 +1,127 @@ +# CTMS Desktop Release Stabilization Checklist + +状态: `active` +适用范围: Web 与 macOS Desktop 统一客户端发布 +最后更新: `2026-07-01` + +本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。 + +## 1. 发布链路门禁 + +发布候选提交必须从同一 Git 提交构建 Web 与 Desktop 制品,并完成以下检查: + +```bash +cd frontend +npm ci +npm run version:check +npm run release:env:check +npm run runtime:check +npm run desktop:release:check +npm run ui:contract +npm run type-check +npm run test:unit +npm run build +npm run desktop:build:app +``` + +正式发布还必须确认: + +- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。 +- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=` 由 CI 注入,且 `npm run release:env:check` 通过。 +- [ ] macOS app 已签名和公证。 +- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。 +- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build -- --bundles app`。 +- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed --artifacts-dir `。 +- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。 +- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。 + +## 2. 安全边界复审 + +自动门禁 `npm run desktop:release:check` 覆盖以下静态约束: + +- [ ] Tauri bundle 启用 `app`、`dmg` 和 updater artifacts。 +- [ ] updater public key 已配置。 +- [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。 +- [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。 +- [ ] 文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。 +- [ ] 单实例插件先于其他桌面插件注册。 +- [ ] Tauri command 白名单仅包含凭据和更新命令。 +- [ ] 前端源码不通过 query string 传递 token。 +- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 +- [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。 +- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。 + +人工复审还必须确认: + +- [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。 +- [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。 +- [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。 +- [ ] Web 运行时不直接导入 Tauri API。 + +## 3. 端到端回归矩阵 + +| 场景 | Web | macOS Desktop | 预期 | +| --- | --- | --- | --- | +| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | +| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | +| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | +| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 | +| 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 | +| 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` | +| 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` | +| 系统通知开启 | 不适用 | 必测 | 用户主动开启后请求 OS 权限并创建订阅 | +| 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 | +| 通知领取与 ack | 不适用 | 必测 | 显示成功后 ack;失败等待租约重试 | +| 单实例重复启动 | 不适用 | 必测 | 恢复、显示并聚焦主窗口 | +| 自动更新检查 | 不适用 | 必测 | release 通道按当前 CTMS origin 派生清单 | +| 更新稍后提醒 | 不适用 | 必测 | 同版本 24 小时内不重复提示 | +| 更新安装失败 | 不适用 | 必测 | 不打断业务录入,显示可排障错误 | + +## 4. 桌面体验验收 + +- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。 +- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。 +- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。 +- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。 +- [ ] 通知开关显示 OS 权限状态。 +- [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。 +- [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。 +- [ ] 更新弹窗只显示版本、发布日期和通用 release notes,不展示 token、下载链接或业务详情。 + +## 5. 不允许项 + +- [ ] 不实现离线登录、离线浏览、离线队列或离线同步。 +- [ ] 不在桌面端保存 CTMS 业务数据副本。 +- [ ] 不内嵌 FastAPI、PostgreSQL、SQLite 或本地业务 API 镜像。 +- [ ] 不绕过后端做本地权限裁决或本地审计回放。 + +## 6. 2026-07-01 收尾验证记录 + +本轮收尾验证在 `/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop` 的 detached HEAD `c923f887` 上执行,包含当前工作区文档与 CI 门禁调整。 + +已通过的自动门禁: + +- `cd frontend && npm run version:check` +- `cd frontend && npm run release:env:check` +- `cd frontend && npm run runtime:check` +- `cd frontend && npm run desktop:release:check` +- `cd frontend && npm run ui:contract` +- `cd frontend && npm run type-check` +- `cd frontend && npm run test:unit` +- `cd frontend && npm run build` +- `cd frontend && npm run desktop:build:app` +- `cd frontend && node --check scripts/verify-desktop-update-feed.mjs` + +验证结论: + +- Tauri 运行时边界、release 静态安全门禁、构建元数据预检、版本一致性和 UI 合约均通过。 +- Web 生产构建和未签名 macOS `.app` smoke 构建均可重复执行。 +- 当前 CI 已补齐 `npm run release:env:check` 和 `npm run ui:contract`,tag 构建会将 `VITE_BUILD_CHANNEL` 规范为 `release` 并校验 tag 与版本号一致。 +- updater feed 校验脚本已完成语法检查;正式 `latest.json` 需要在签名 updater artifacts 生成后执行实物校验。 + +仍需正式发布前人工确认: + +- macOS 签名、公证、Apple Developer 凭据和组织 updater 私钥。 +- 签名后的 updater artifacts、`.sig`、checksum manifest 和 `latest.json` 在真实发布目录内通过 `npm run desktop:update-feed:check`。 +- 不可变制品上传完成后,再原子替换线上 `latest.json`。 +- Desktop 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。 diff --git a/docs/audits/storage-persistence-audit.md b/docs/audits/storage-persistence-audit.md index 7b3ea913..e0d39122 100644 --- a/docs/audits/storage-persistence-audit.md +++ b/docs/audits/storage-persistence-audit.md @@ -2,11 +2,14 @@ 状态: `snapshot` 适用范围: `storage-persistence` -最后更新: `2026-02-27` +最后更新: `2026-06-30` - 扫描目录: `/Users/zcc/MyCTMS/ctms-project/frontend/src` -- 总发现数: `46` -- 高风险: `4` / 中风险: `0` / 低风险: `42` +- 总发现数: `36` +- 高风险: `4` / 中风险: `0` / 低风险: `32` + +> 2026-06-30 第二阶段更新:认证 token 已迁移到 `frontend/src/runtime/secureSessionStorage.ts`; +> 业务入口不再直接从 `localStorage` 读取 `ctms_token`,附件下载不再使用 query-token。 ## 明细 @@ -18,13 +21,6 @@ | high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2984 | `localStorage.removeItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 | | low | ui-preference | `frontend/src/components/Layout.vue` | 227 | `localStorage.getItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | | low | ui-preference | `frontend/src/components/Layout.vue` | 390 | `localStorage.setItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | -| low | auth-session | `frontend/src/components/ThreadList.vue` | 72 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 132 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 137 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 168 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 169 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 174 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 223 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/session/sessionManager.ts` | 33 | `localStorage.setItem` | `"ctms_auth_broadcast"` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/session/sessionManager.ts` | 181 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/session/sessionManager.ts` | 184 | `sessionStorage.setItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | @@ -49,9 +45,6 @@ | low | ui-preference | `frontend/src/store/study.ts` | 167 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 | | low | ui-preference | `frontend/src/store/study.ts` | 174 | `localStorage.setItem` | `SITE_KEY` | UI偏好/上下文缓存 | | low | ui-preference | `frontend/src/store/study.ts` | 176 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 | -| low | auth-session | `frontend/src/utils/auth.ts` | 4 | `localStorage.getItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/utils/auth.ts` | 7 | `localStorage.setItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | -| low | auth-session | `frontend/src/utils/auth.ts` | 11 | `localStorage.removeItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/utils/auth.ts` | 17 | `localStorage.setItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/utils/auth.ts` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | | low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | diff --git a/docs/branch-governance.md b/docs/branch-governance.md index 78b2a386..29d9eacb 100644 --- a/docs/branch-governance.md +++ b/docs/branch-governance.md @@ -78,6 +78,29 @@ Meaning: Direct promotion that skips stages is discouraged and must be justified in writing. +## 3.1 Unified Web and Desktop Mainline + +CTMS Web and Desktop are two delivery targets of the same product version. They +share the Vue application, API contract, and product branches. + +Rules: + +- Do not create long-lived `web-dev`, `desktop-dev`, `web-release`, or + `desktop-release` branches. +- Web and Desktop changes both follow `feature/*` -> `dev` -> `main` -> + `release`. +- A platform-specific feature or agent task branch is allowed while work is in + progress, for example `feature/desktop-file-picker` or + `codex/desktop-menu-polish`, but it must merge back into `dev`. +- `codex/ctms-desktop` was the temporary desktop integration branch for the + Tauri baseline. It is no longer a current desktop mainline. Do not commit, + rebase, or push new desktop work to it unless explicitly cleaning up the + historical branch after its accepted changes are present on `dev`. +- Platform differences belong behind `frontend/src/runtime/`. Shared business + modules must not import Tauri APIs directly. +- A release tag identifies one product source state. Web and Desktop artifacts + for that release must be built from the same tag and Git commit. + ## 4. Branch Entry Rules ### Changes allowed into `dev` @@ -214,6 +237,13 @@ Rules: - tags are created on `release`, not on `dev` - a tag must point to the exact production release commit - patch hotfixes on `release` should increment the patch version +- Web and Desktop use the same semantic version. Do not add a separate Desktop + product version. +- Desktop packaging-only rebuilds may add build metadata to the artifact name, + but must retain the product version and record the source commit. +- Before creating a tag, run `cd frontend && npm run version:check`. +- Change the shared client version with + `cd frontend && npm run version:set -- `. ## 7. Hotfix Back-Merge Rules diff --git a/docs/desktop-phase-1-design.md b/docs/desktop-phase-1-design.md new file mode 100644 index 00000000..f7067341 --- /dev/null +++ b/docs/desktop-phase-1-design.md @@ -0,0 +1,331 @@ +# CTMS 桌面端第一阶段详细方案 + +日期:2026-06-30 + +## 目标 + +第一阶段只交付 macOS 在线桌面客户端。桌面端使用 Tauri 承载现有 Vue/Vite 前端,连接已有 CTMS 服务端,不内嵌后端、不内嵌数据库、不做离线能力。 + +本阶段结束后,应能在 macOS 上启动 CTMS 桌面 App,配置 CTMS 服务端地址,完成登录,并执行现有 Web 端的常规在线业务流程。Web 端构建和现有 Docker/nginx 部署必须继续可用。 + +## 范围边界 + +本阶段必须做: + +- 在 `frontend/` 内加入 Tauri 工程结构。 +- 复用现有 Vue 3、Vite、Element Plus、Pinia、Vue Router、Axios 前端代码。 +- 让桌面端连接已有 CTMS 后端公开入口,推荐连接 nginx 入口而不是直接连接后端容器。 +- 提供桌面端服务端地址配置能力。 +- 抽出最小运行时适配层,避免业务页面直接依赖 Tauri API。 +- 保持认证、权限、审计和业务数据持久化由 FastAPI 后端裁决。 +- 产出 macOS 开发构建流程和后续签名、公证、DMG 发布路径。 + +本阶段明确不做: + +- 离线登录、离线浏览、离线草稿、离线队列、离线同步。 +- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。 +- 内嵌 Python/FastAPI 后端服务。 +- 重写 CTMS 业务页面。 +- Windows 安装包交付。 +- 自动更新实现。 +- token/session 安全存储迁移;该事项进入第二阶段。 + +## 当前代码影响点 + +现状: + +- `frontend/src/api/axios.ts` 和 `frontend/src/api/authClient.ts` 的 `baseURL` 都是 `/`。 +- API 调用路径均为 `/api/v1/...`。 +- Web 端依赖 nginx 同域反代 `/api`。 +- Tauri 打包后页面运行在桌面 WebView 中,不能继续假设 `/api` 一定代表 CTMS 服务端。 +- `frontend/src/utils/auth.ts` 当前使用 `localStorage` 保存 token。 +- `frontend/src/session/sessionManager.ts` 依赖 `localStorage`、`sessionStorage` 和 `BroadcastChannel`。 + +第一阶段的核心改动是先解决“桌面端如何知道并访问服务端地址”,同时尽量不触碰业务页面。 + +## 目标架构 + +```mermaid +flowchart LR + A["Tauri macOS App"] --> B["Bundled Vue/Vite Frontend"] + B --> C["Runtime Adapters"] + C --> D["Configured CTMS Server URL"] + D --> E["nginx / HTTPS entry"] + E --> F["FastAPI backend"] + F --> G["PostgreSQL"] +``` + +桌面端只负责本地窗口、运行时配置和少量平台能力。业务数据仍走服务端 API,权限和审计仍由后端完成。 + +## Tauri 工程方案 + +Tauri 目录放在现有前端工程内: + +```text +frontend/ + src-tauri/ + tauri.conf.json + Cargo.toml + src/ + main.rs + capabilities/ + default.json +``` + +建议新增脚本: + +```json +{ + "scripts": { + "tauri": "tauri", + "desktop:dev": "tauri dev", + "desktop:build": "tauri build", + "desktop:bundle:dmg": "tauri build -- --bundles dmg" + } +} +``` + +Tauri 配置方向: + +- `build.beforeDevCommand`: `npm run dev` +- `build.beforeBuildCommand`: `npm run build` +- `build.devUrl`: `http://localhost:5173` +- `build.frontendDist`: `../dist` +- 主窗口标题:`CTMS` +- 主窗口初始尺寸建议:`1440x900` +- 最小窗口尺寸建议:`1180x760` +- bundle identifier 待最终确认,临时建议:`cn.huapont.ctms.desktop` + +第一阶段不加载远程 Web UI。Tauri 只加载本地打包出的前端资源,远程访问仅限 Axios 访问配置好的 CTMS 服务端。 + +## Vite 调整方案 + +当前 `vite.config.ts` 为 Docker 开发做了固定代理: + +- dev server 端口:`5173` +- HMR client port:`8888` +- `/api` 代理目标:`http://backend:8000` + +为兼容 Web、Docker 和 Tauri,建议改成环境变量驱动: + +- `VITE_DEV_API_PROXY_TARGET`:默认 `http://backend:8000`。 +- `VITE_HMR_CLIENT_PORT`:Docker/nginx 开发时设为 `8888`,桌面端开发不设置。 +- `TAURI_DEV_HOST`:按 Tauri/Vite 推荐方式兼容 Tauri dev。 + +Vite 配置需要补充: + +- `server.strictPort: true`,避免 Tauri devUrl 与 Vite 实际端口不一致。 +- `server.watch.ignored: ["**/src-tauri/**"]`,避免 Rust 目录变动触发不必要的前端监听。 +- `clearScreen: false`,避免 Rust 编译错误被 Vite 清屏隐藏。 +- `build.target` 按 Tauri 平台设置:Windows 使用 Chromium 目标,macOS 使用 Safari/WebKit 目标。 +- `envPrefix` 保留 `VITE_`,并允许 `TAURI_ENV_*`。 + +## 服务端地址配置 + +新增运行时适配层: + +```text +frontend/src/runtime/ + platform.ts + apiBaseUrl.ts + desktopServerConfig.ts +``` + +职责: + +- `platform.ts` + - 判断当前是否运行在 Tauri。 + - 判断目标平台是 Web、macOS 桌面端还是 Windows 桌面端。 + - 业务页面不得直接读取 Tauri 全局对象。 + +- `desktopServerConfig.ts` + - 保存和读取桌面端服务端地址。 + - 第一阶段可继续使用 `localStorage` 保存服务器地址;这不是业务数据,也不是离线能力。 + - key 建议:`ctms_desktop_server_url`。 + - 仅接受 `http://localhost`、`http://127.0.0.1` 或 `https://...`。非本地生产服务不接受明文 HTTP。 + +- `apiBaseUrl.ts` + - Web 端返回 `/`,保持现有同域 `/api` 行为。 + - 桌面端返回配置的服务端 origin,例如 `https://ctms.example.com/`。 + - 统一规范尾斜杠,避免拼接出错。 + +Axios 调整: + +- `frontend/src/api/axios.ts` 使用 `resolveApiBaseUrl()` 初始化 `baseURL`。 +- `frontend/src/api/authClient.ts` 同步使用同一 baseURL。 +- 当桌面端服务端地址变化时,需要更新两个 Axios 实例的 `defaults.baseURL`。 +- 现有 API path 继续保持 `/api/v1/...`,不修改业务 API 文件。 + +## 服务端配置入口 + +第一阶段需要一个轻量的桌面端服务端设置入口。 + +建议实现方式: + +- 桌面端启动时,如果没有服务端地址,则在登录页前展示服务端设置界面。 +- 登录页提供“服务器设置”入口,允许修改当前服务端地址。 +- 保存前调用 `${serverUrl}/health` 做连通性检查。 +- 连通性检查失败时允许用户重新输入,不自动降级到离线模式。 +- Web 端不显示桌面端服务器设置入口。 + +建议新增文件: + +```text +frontend/src/views/DesktopServerSettings.vue +frontend/src/router/desktopGuard.ts +frontend/src/runtime/desktopServerConfig.ts +``` + +路由策略: + +- 保持现有 Web 路由结构。 +- 桌面端未配置服务端时,将用户导向 `/desktop/server-settings`。 +- `/login` 页面中允许打开服务器设置。 +- Web 端访问 `/desktop/server-settings` 时重定向到 `/login` 或显示不可用状态。 + +## 认证和会话策略 + +第一阶段保持现有认证机制: + +- 登录仍使用后端登录公钥和加密登录流程。 +- token 仍通过 `frontend/src/utils/auth.ts` 存储在 `localStorage`。 +- 会话超时、token keep-alive、401 refresh 仍沿用现有逻辑。 + +本阶段只允许做为 Tauri 接入所必需的最小改动: + +- API baseURL 可切换。 +- 服务端地址变化时清理当前 token 和项目上下文,要求重新登录。 +- 不引入桌面安全存储;该事项归入第二阶段。 + +## Tauri 权限策略 + +第一阶段不需要文件系统、Shell、系统通知、自动更新或单实例插件。 + +权限原则: + +- 只启用主窗口运行所需的最小 core capability。 +- 不开放宽泛文件系统权限。 +- 不开放 shell 执行能力。 +- 不开放远程页面访问 Tauri command 的能力。 +- 如确实需要读取 App 版本或平台信息,优先通过窄适配层处理,并明确 capability。 + +第一阶段建议不新增自定义 Tauri command。服务端地址配置可以先由前端 `localStorage` 完成。 + +## macOS 打包路径 + +开发构建: + +```bash +cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend +npm run desktop:dev +``` + +生产构建: + +```bash +cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend +npm run desktop:build +``` + +DMG 构建: + +```bash +cd /Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop/frontend +npm run desktop:bundle:dmg +``` + +正式分发前需要: + +- Apple Developer 账号。 +- macOS 代码签名证书。 +- notarization 所需 App Store Connect API 或 Apple ID 凭据。 +- 明确是否分发 DMG,第一阶段推荐 DMG。 + +第一阶段可以先产出未签名或 ad-hoc 签名的内部开发构建,但不能把它描述为正式可分发版本。 + +## 实施步骤 + +1. 初始化 Tauri + - 安装 `@tauri-apps/cli`。 + - 在 `frontend/` 下生成 `src-tauri/`。 + - 固定基础配置:app name、window title、bundle identifier、devUrl、frontendDist。 + +2. 调整 Vite + - 引入 Tauri 兼容配置。 + - 保持 Docker/nginx 开发代理不破坏。 + - 使用环境变量控制代理和 HMR。 + +3. 新增运行时适配层 + - 添加 `platform.ts`。 + - 添加 `apiBaseUrl.ts`。 + - 添加 `desktopServerConfig.ts`。 + - 新增单元测试覆盖 URL 规范化、Web/Tauri 分支和非法 URL 拒绝。 + +4. 改造 API client + - `axios.ts` 使用统一 baseURL。 + - `authClient.ts` 使用统一 baseURL。 + - 服务端地址变化后刷新 Axios baseURL。 + - 保持 API path 和业务模块不变。 + +5. 添加桌面服务端设置界面 + - 桌面端未配置服务端时拦截到设置页。 + - 保存时校验 URL 和 `/health`。 + - 修改服务端地址时清理当前登录态和项目上下文。 + +6. 最小 Tauri 权限 + - 检查 `capabilities/default.json`。 + - 移除第一阶段不需要的插件和权限。 + - 不新增自定义 command,除非实现过程证明必要。 + +7. macOS 验证和文档 + - 运行 Web 构建、类型检查、单元测试。 + - 运行 Tauri dev。 + - 验证 macOS 打包。 + - 补充桌面端运行说明。 + +## 验收清单 + +功能验收: + +- macOS 桌面 App 可以启动。 +- 首次启动未配置服务端时进入服务器设置。 +- 服务端地址保存前会校验 `/health`。 +- 配置有效服务端后可以登录。 +- 登录后能进入项目列表和项目工作区。 +- 现有权限控制、401 refresh、会话超时行为与 Web 端一致。 +- 切换服务端地址会清理当前登录态。 + +工程验收: + +- `npm run build` 通过。 +- `npm run type-check` 通过。 +- `npm run test:unit` 通过,或明确记录失败原因。 +- `npm run desktop:dev` 可启动 macOS 窗口。 +- `npm run desktop:build` 可完成构建。 +- Web 端 Docker/nginx 访问不回归。 + +边界验收: + +- 没有新增本地业务数据缓存。 +- 没有内嵌后端服务。 +- 没有本地数据库。 +- 没有离线队列或同步机制。 +- Tauri API 没有散落在业务页面中。 +- Tauri capability 没有开放第一阶段不需要的文件系统或 shell 权限。 + +## 风险和决策点 + +- bundle identifier 需要正式确认,建议在第一阶段实现前由产品或组织负责人定稿。 +- 桌面端连接公开 CTMS 服务时,生产环境应使用 HTTPS;本地开发可允许 localhost HTTP。 +- 当前后端 CORS 是宽松配置。第一阶段可先不改后端,但正式分发前应评估是否收紧允许来源。 +- token 第一阶段仍在 `localStorage`,这是有意识的阶段性选择;安全存储迁移必须排入第二阶段。 +- macOS 正式分发需要 Apple Developer、签名和 notarization,不应等到最后一天处理。 + +## 参考资料 + +- Tauri 创建项目与向现有前端加入 Tauri:https://tauri.app/start/create-project/ +- Tauri + Vite 配置:https://tauri.app/start/frontend/vite/ +- Tauri capabilities:https://tauri.app/security/capabilities/ +- Tauri DMG 分发:https://tauri.app/distribute/dmg/ +- Tauri macOS 签名与公证:https://tauri.app/distribute/sign/macos/ +- Tauri updater 资料,供第二阶段使用:https://tauri.app/plugin/updater/ diff --git a/docs/desktop-phase-2-design.md b/docs/desktop-phase-2-design.md new file mode 100644 index 00000000..02b318d5 --- /dev/null +++ b/docs/desktop-phase-2-design.md @@ -0,0 +1,160 @@ +# CTMS 桌面端第二阶段执行设计 + +## 范围与边界 + +第二阶段基于已合入 `dev` 的 Tauri 基线推进。每个评审单元从最新 `dev` 创建短期 `codex/*` 分支,合入后删除,不建立长期桌面主线。 + +本阶段只增强在线桌面客户端能力: + +- 安全运行时与诊断。 +- 原生文件选择、保存、打开。 +- 服务端持久系统通知。 +- 签名更新、发布流水线与 Windows 打包准备。 + +本阶段明确不实现离线登录、离线缓存、本地业务数据库、本地业务队列、后台业务同步或本地权限裁决。所有业务数据、权限、审计和认证判定仍以 FastAPI 后端为准。 + +## 运行时边界 + +共享业务模块只能通过 `frontend/src/runtime/` 使用平台能力。非 runtime 模块不得直接导入 Tauri API。 + +`ClientRuntime` 第二阶段能力包括: + +- `secureSessionStorage` +- `files` +- `notifications` +- `updates` +- `capabilities.secureSessionStorage/nativeFiles/systemNotifications/automaticUpdates` + +能力标志只在对应运行时可用或初始化成功后开启。Web 端继续使用浏览器能力,不申请系统通知权限,不启动桌面通知轮询,也不触发 updater。 + +## 安全会话存储 + +桌面端 token 存入系统凭据库: + +- macOS:Keychain。 +- Windows:Credential Manager。 + +Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。 + +应用挂载前异步初始化 token: + +1. Web 端继续读取 `localStorage.ctms_token`。 +2. 桌面端先删除 legacy `localStorage.ctms_token`。 +3. 若 legacy token 仍有效,则迁移到系统凭据库。 +4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。 + +登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。 + +## 原生文件能力 + +公共接口固定为: + +```ts +pickFiles(options): Promise +saveFile({ suggestedName, mimeType, data }): Promise<"saved" | "cancelled"> +openFile({ suggestedName, mimeType, data }): Promise +``` + +桌面端使用 Tauri dialog、fs、opener 插件。权限范围只覆盖用户当次选择的文件路径和 `$TEMP/ctms-desktop/**`,不启用 persisted-scope,不开放目录遍历或 shell。外部打开文件时写入随机临时目录,并使用净化后的文件名;启动和退出时清理临时目录,用户主动保存的文件不自动删除。 + +下载与预览统一先通过 Axios Bearer 请求取得 Blob,再交给文件适配器。附件全局下载接口只接受 `Authorization` header,不再接受 `?token=`。 + +## 系统通知 + +通知订阅由用户在个人设置中主动开启。开启时才请求 OS 权限并创建服务端订阅;`enabled_at` 设为当前时间,不补发历史分发记录。关闭后客户端停止领取通知,重新开启时重新设定起点。 + +后端新增: + +- `desktop_notification_subscriptions` +- `desktop_notification_deliveries` + +API: + +- `GET/PUT /api/v1/desktop-notifications/subscription` +- `POST /api/v1/desktop-notifications/claim` +- `POST /api/v1/desktop-notifications/ack` +- `POST /api/v1/desktop-notifications/{distribution_id}/read` + +claim 跨有效项目查询匹配当前用户或角色的活动文件分发,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。 + +系统通知正文只显示通用内容: + +- 标题:`CTMS 文件更新` +- 正文:`有新的文件版本待查看` + +项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。 + +## 诊断元数据与 CORS + +Axios 请求附加: + +- `X-CTMS-Client-Type` +- `X-CTMS-Client-Version` +- `X-CTMS-Client-Platform` +- `X-CTMS-Build-Channel` +- `X-CTMS-Build-Commit` + +后端安全访问日志保存这些 nullable 字段,并支持按客户端类型/版本筛选。这些字段只用于诊断与排障,不参与授权。 + +CORS origin 由环境变量白名单控制,并显式允许桌面 origin、开发 origin 和上述请求头。 + +Tauri CSP 禁止远程脚本和 shell 入口,仅允许 HTTPS API、本地开发地址、blob 预览与必要资源。 + +## 单实例 + +单实例插件必须最先注册。重复启动时只恢复、显示并聚焦主窗口,不处理命令行参数、深链或业务动作。 + +## 自动更新与发布 + +正式 release 构建启用 updater。客户端从当前 CTMS origin 派生固定清单路径: + +```text +/desktop-updates/stable/latest.json +``` + +生产只允许 HTTPS;本地测试只允许 localhost HTTP。 + +更新检查策略: + +- 启动延迟 30 秒检查。 +- 之后每 6 小时检查。 +- 发现更新时展示版本和发布说明。 +- 用户确认后下载、验签、安装并重启。 +- 用户选择“稍后”后,同版本 24 小时内不再提示。 +- 不做静默安装,不中断正在录入的业务流程。 + +Tauri 配置必须嵌入 updater 公钥并生成 updater artifacts。生产私钥只能存放在组织密钥库或 CI secret,不进仓库。macOS 更新制品为 `.app.tar.gz` 与 `.sig`。 + +release tag 流水线应从同一提交构建 Web 与桌面端: + +1. 校验 `frontend/package.json`、Tauri 配置、Cargo manifest/lock 版本一致。 +2. 构建 macOS Universal。 +3. 完成 Apple 签名和公证。 +4. 使用 updater 私钥签名更新包。 +5. 生成 DMG、更新包、签名、`latest.json` 与校验清单。 +6. 先上传不可变制品,最后原子替换 `latest.json`。 + +`latest.json` 同时提供 `darwin-aarch64` 和 `darwin-x86_64`,指向同一个 Universal 更新制品。 + +## Windows 准备 + +第二阶段只验证 Windows x64 NSIS 构建兼容,不发布正式 Windows 安装包。 + +验证范围: + +- Windows Credential Manager。 +- 路径净化与临时目录限制。 +- 系统通知编译兼容。 +- updater 编译兼容。 +- WebView2 前置条件。 +- 用户级安装假设。 +- 后续代码签名要求。 + +## 验收重点 + +- `localStorage`、URL、日志和系统通知正文不包含 token。 +- 附件下载不再接受 query-token。 +- 业务模块不直接导入 Tauri API。 +- Web 构建、类型检查和单测不回归。 +- macOS `.app` 构建可重复。 +- Keychain 会话、文件入口、通知权限、重复启动聚焦和签名更新链路完成端到端验证。 diff --git a/docs/desktop-project-plan.md b/docs/desktop-project-plan.md new file mode 100644 index 00000000..6b1c5e8a --- /dev/null +++ b/docs/desktop-project-plan.md @@ -0,0 +1,144 @@ +# CTMS 桌面端项目计划书 + +## 目的 + +本文档是 CTMS 桌面端工作的长期方向约束。每次开始任何桌面端相关任务前,都必须先阅读本文档,包括 Tauri 初始化、macOS 打包、Windows 适配、桌面端存储、文件集成、系统通知、安全边界和发布流程。 + +桌面端必须服务于现有 CTMS Web 应用和后端架构。目标是为当前 CTMS 服务提供原生桌面入口,而不是创建一个独立的离线产品。 + +## 不可突破的边界 + +- 技术路线固定为 Tauri。 +- 第一开发目标是 macOS 桌面端。 +- Windows 仍只作为第二阶段兼容性验证目标,未获明确批准前不发布正式安装包。 +- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口和发布准备,不新增第三阶段能力。 +- 不做离线功能。 +- 不在桌面 App 内嵌本地后端服务。 +- 不在桌面 App 内嵌或分发本地数据库来保存 CTMS 业务数据。 +- 不实现离线同步、冲突解决、本地业务数据队列、本地优先工作流。 +- 不把 CTMS 业务 UI 拆成一套独立的桌面端产品;除非桌面能力确实需要小范围适配层。 +- FastAPI 后端仍是业务权威来源。权限裁决、审计判断、认证、业务数据持久化都保持在服务端。 + +## 当前技术基线 + +桌面端工作基于当前 CTMS Web 技术栈: + +- 前端:Vue 3、Vite、TypeScript、Element Plus、Pinia、Vue Router、Axios、ECharts。 +- 后端:FastAPI、Uvicorn、SQLAlchemy、Alembic。 +- 数据库:PostgreSQL。 +- 当前部署:Docker Compose、nginx 托管前端静态资源、nginx 反代 `/api`。 + +桌面端应复用现有前端代码和 API 契约。任何共享适配层都应保持小而明确,并可测试。 + +## 已完成阶段边界 + +第一阶段 macOS 在线桌面壳和第二阶段原生能力主体改造已经形成。详细历史方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md) 和 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。 + +后续不再按第一阶段空白项目初始化 Tauri,也不再扩展第二阶段以外的新桌面产品能力。当前允许推进的工作仅包括: + +- 修复既有 Tauri、运行时适配层、文件、通知、凭据、更新、菜单/快捷键和打包问题。 +- 稳定 Web 与 Desktop 共用业务代码,确保平台差异继续收敛在 `frontend/src/runtime/` 后面。 +- 完成 macOS 正式发布前的签名、公证、updater 签名、制品发布、CI 门禁和人工回归。 +- 做 Windows 第二阶段兼容性验证,但不发布正式 Windows 安装包。 + +仍然不允许: + +- 离线登录、离线浏览、离线队列、离线同步或本地优先工作流。 +- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。 +- 内嵌 Python/FastAPI 后端服务。 +- 绕过后端做本地权限裁决、本地审计缓存或审计回放。 +- 为桌面端复制或重写一套独立业务 UI。 + +## 架构方向 + +使用运行时适配层,不在业务页面里散落平台判断。 + +当前已采用并必须继续保持的适配层边界: + +- `platform`:识别 Web、macOS 桌面端、Windows 桌面端。 +- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。 +- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。 +- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。 +- `files`:隔离浏览器上传下载与原生文件能力。 +- `notifications`:隔离 Web 通知与桌面系统通知。 +- `updates`:隔离桌面自动更新检查与安装入口。 +- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。 +- `desktopMenu` 和 `desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。 +- `clientRuntime`:作为业务侧获取平台能力的聚合入口。 + +业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。 + +## 安全与合规方向 + +- 将桌面端视为受监管业务系统的在线客户端。 +- 非本地服务连接优先使用 HTTPS。 +- 认证与授权决策保留在后端。 +- 审计敏感决策保留在后端。 +- 敏感凭据必须继续使用明确批准的安全存储方案。 +- 不向前端暴露宽泛文件系统访问权限。 +- Tauri 权限保持最小化,并按功能精确授权。 +- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。 + +## 分支与工作区 + +桌面端工作在以下位置开发: + +- Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop` +- 短期分支:Agent 默认使用 `codex/<任务名称>`,也可按任务类型使用 `feature/*`、`fix/*`、`docs/*` 或 `release-prep/*`;所有普通桌面端工作都必须从最新 `dev` 创建 + +当前代码状态:Tauri 基线和第二阶段原生能力已经形成,后续桌面端工作默认是第二阶段范围内的修复、稳定化、体验收口和发布准备。`codex/ctms-desktop` 是历史临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。 + +## 2026-07-01 审查结论与后续方向 + +本轮审查结论:桌面端已经完成从 Tauri 接入基线到第二阶段原生能力的主体改造,不再按空白桌面项目推进。当前重点不是扩展离线或本地业务能力,而是围绕既有在线桌面客户端做准发布稳定化。 + +已形成的能力边界: + +- Tauri 工程、macOS App/DMG/updater artifacts 配置已经存在。 +- `frontend/src/runtime/` 已作为平台能力统一入口,业务代码不得绕过该适配层直接使用 Tauri API。 +- 桌面服务器地址、API baseURL、客户端元数据请求头、安全 session 存储、原生文件能力、系统通知、单实例、菜单/快捷键和自动更新入口已经形成。 +- 后端已经包含桌面通知订阅/投递状态、相关 API 和客户端诊断请求头支持。 +- 已有 `npm run version:check`、`npm run runtime:check`、`npm run desktop:release:check` 等门禁用于约束版本、运行时边界和桌面发布安全边界。 + +后续优化优先级: + +1. 发布稳定化:补齐 macOS 签名、公证、组织 updater 私钥签名、release tag 构建变量注入、不可变制品上传和 `latest.json` 原子替换。 +2. 端到端回归:按 `docs/audits/desktop-release-stabilization-checklist.md` 覆盖服务器配置、服务器切换清会话、Keychain/凭据库、附件上传下载、系统通知、单实例和自动更新失败恢复。 +3. 安全复审:持续确认 token 不进入 URL、日志、系统通知正文、下载链接或明文持久化;Tauri command、capability、CSP 和 updater 改动必须同步评估发布门禁。 +4. 桌面体验收口:重点检查登录、服务器设置、个人中心诊断信息、通知开关、更新弹窗和最小窗口 `1180x760` 下的布局稳定性。 +5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。 +6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。 + +如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。 + +## 当前质量门禁 + +前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑: + +```bash +cd frontend +npm run version:check +npm run runtime:check +npm run desktop:release:check +npm run ui:contract +npm run type-check +npm run test:unit +npm run build +npm run desktop:build:app +``` + +正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。文档-only 变更可以不执行完整代码门禁,但必须在结果说明中明确未运行。 + +## 每次开发前必须执行的检查 + +开始任何桌面端相关任务前: + +- 先阅读本文档。 +- 确认任务属于第一阶段或第二阶段。 +- 确认任务不会引入离线能力。 +- 确认实现不会破坏 Web 运行时。 +- 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。 +- 确认不会重复初始化 Tauri 或绕过既有 `frontend/src/runtime/` 运行时边界。 +- 涉及 Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。 + +如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。 diff --git a/docs/guides/branch-maintenance-sop-zh.md b/docs/guides/branch-maintenance-sop-zh.md new file mode 100644 index 00000000..fccf2236 --- /dev/null +++ b/docs/guides/branch-maintenance-sop-zh.md @@ -0,0 +1,493 @@ +# CTMS 分支维护与版本更新标准操作规程 + +## 一、目的 + +本规程用于统一 CTMS 网页端和桌面端的代码提交、分支维护、版本晋级、正式发布及生产热修复流程。 + +CTMS 网页端和桌面端属于同一个产品,必须共用: + +- 同一个代码仓库 +- 同一套业务核心代码 +- 同一条版本晋级链路 +- 同一个语义化版本号 +- 同一个正式发布标签 +- 同一个源代码提交 + +不得为网页端和桌面端分别建立长期开发、测试或发布分支。 + +## 二、长期分支职责 + +| 分支 | 职责 | 允许进入的内容 | 稳定性要求 | +| --- | --- | --- | --- | +| `dev` | 日常开发与集成 | 已评审的功能、修复和重构 | 可持续集成 | +| `main` | 下一正式版本候选 | 从 `dev` 晋级的完整版本范围、候选版本修复 | 原则上可部署 | +| `release` | 当前生产稳定版本 | 从 `main` 验收通过的正式版本、生产热修复 | 最高 | + +默认晋级方向: + +```text +功能分支 -> dev -> main -> release -> 正式版本标签 +``` + +禁止以下长期分支: + +```text +web-dev +desktop-dev +web-release +desktop-release +macos-main +windows-main +``` + +桌面端差异必须放在 `frontend/src/runtime/` 适配层之后,不通过长期分支保存平台差异。 + +## 三、临时分支命名 + +| 类型 | 命名格式 | 示例 | +| --- | --- | --- | +| 新功能 | `feature/<功能名称>` | `feature/desktop-file-picker` | +| 缺陷修复 | `fix/<问题名称>` | `fix/session-timeout` | +| 生产热修复 | `hotfix/<问题名称>` | `hotfix/login-loop` | +| 文档调整 | `docs/<文档名称>` | `docs/release-sop` | +| 发布准备 | `release-prep/<版本号>` | `release-prep/v1.2.0` | +| Agent 临时任务 | `codex/<任务名称>` | `codex/desktop-menu-polish` | + +分支名称使用小写英文和连字符,不使用个人姓名、日期或模糊名称。 + +## 四、日常功能开发流程 + +### 1. 从最新 `dev` 创建分支 + +```bash +git fetch origin +git switch dev +git pull --ff-only origin dev +git switch -c feature/<功能名称> +``` + +不得从旧功能分支、`main` 或 `release` 创建普通功能分支。 + +### 2. 开发过程中同步 `dev` + +短期分支优先使用变基保持提交清晰: + +```bash +git fetch origin +git rebase origin/dev +``` + +已经由多人共同使用的分支,不得擅自强制推送。此时可使用合并: + +```bash +git fetch origin +git merge origin/dev +``` + +### 3. 提交前检查 + +前端或桌面端改动至少执行: + +```bash +cd frontend +npm run version:check +npm run runtime:check +npm run desktop:release:check +npm run ui:contract +npm run type-check +npm run test:unit +npm run build +npm run desktop:build:app +``` + +涉及 Tauri、macOS 打包或桌面适配层时,还必须在 macOS 执行: + +```bash +npm run desktop:build:app +``` + +正式桌面发布构建仍必须设置 updater 签名私钥后执行 +`npm run desktop:build -- --bundles app`。 + +后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。 + +### 4. 创建提交 + +只暂存本次任务相关文件: + +```bash +git status +git add <本次任务相关文件> +git diff --cached +git diff --cached --check +git commit -m "<类型>(<范围>): <变更说明>" +``` + +推荐提交类型: + +| 类型 | 用途 | +| --- | --- | +| `feat` | 新功能 | +| `fix` | 缺陷修复 | +| `refactor` | 不改变业务行为的重构 | +| `test` | 测试调整 | +| `docs` | 文档调整 | +| `build` | 构建和依赖调整 | +| `ci` | 持续集成调整 | + +示例: + +```text +feat(desktop): 增加原生文件选择适配器 +fix(auth): 修复会话超时后的重复跳转 +refactor(client): 统一网页端和桌面端运行时入口 +``` + +一次提交只处理一个明确目的。不得将无关格式化、个人配置或临时产物混入提交。 + +### 5. 推送并创建合并请求 + +```bash +git push -u origin feature/<功能名称> +``` + +创建: + +```text +feature/<功能名称> -> dev +``` + +合并要求: + +- 代码评审通过 +- 必要测试通过 +- 客户端质量门禁通过 +- 没有误提交密钥、环境文件或构建产物 +- 桌面能力符合第一阶段或第二阶段边界 + +功能分支进入 `dev` 可使用合并请求合并或变基合并。提交过于零散时应先整理。 + +### 6. 合并后清理 + +确认改动已经进入远程 `dev` 后删除临时分支: + +```bash +git switch dev +git pull --ff-only origin dev +git branch -d feature/<功能名称> +git push origin --delete feature/<功能名称> +``` + +工作树正在使用的分支不能直接删除,应先切换分支或移除对应工作树。 + +## 五、历史桌面集成分支约束 + +`codex/ctms-desktop` 是第一阶段 Tauri 基线使用过的历史临时集成分支,不作为当前桌面端工作线,也不作为长期桌面主线。 + +当前约束: + +- 不得继续向 `codex/ctms-desktop` 提交、变基或推送新的桌面端工作。 +- 如本地或远程仍保留该分支,只能用于追溯历史或在确认已合入 `dev` 后删除。 +- 后续桌面功能、修复、稳定化和发布准备必须从最新 `dev` 创建短期 `feature/*`、`fix/*`、`docs/*`、`release-prep/*` 或 `codex/*` 分支。 +- Agent 创建分支默认使用 `codex/<任务名称>`,并在任务合入 `dev` 后删除。 +- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行分支切换、提交、推送或变基前必须先确认目标基线和处理方式。 + +## 六、从 `dev` 晋级到 `main` + +当一个版本范围在 `dev` 完成集成后,创建: + +```text +dev -> main +``` + +进入 `main` 前必须确认: + +- 本版本范围已经冻结 +- 未完成功能已经排除或关闭入口 +- 前后端测试通过 +- 网页端构建通过 +- macOS 桌面端构建通过 +- 数据库迁移经过验证 +- 已知风险和回滚方式已记录 + +正式创建晋级合并请求前,应按照第七节完成统一版本号更新,并确保版本提交已经进入 `dev`。 + +按照当前仓库治理规则,`dev` 进入 `main` 使用压缩合并,并使用版本候选级提交说明: + +```text +release(main): 准备 v1.2.0 候选版本 +``` + +不得从功能分支直接跳过 `dev` 合并到 `main`。 + +## 七、统一更新客户端版本 + +网页端和桌面端只能使用同一个产品版本号。 + +从最新 `dev` 创建发布准备分支: + +```bash +git fetch origin +git switch dev +git pull --ff-only origin dev +git switch -c release-prep/v1.2.0 +``` + +统一更新版本并提交: + +```bash +cd frontend +npm run version:set -- 1.2.0 +npm run version:check +cd .. +git add frontend/package.json frontend/package-lock.json frontend/src-tauri/tauri.conf.json +git add frontend/src-tauri/Cargo.toml frontend/src-tauri/Cargo.lock +git commit -m "build(release): 更新客户端版本至 v1.2.0" +git push -u origin release-prep/v1.2.0 +``` + +创建 `release-prep/v1.2.0 -> dev` 合并请求。合并后再执行 `dev -> main` 的版本晋级。 + +该命令同步更新: + +- `frontend/package.json` +- `frontend/package-lock.json` +- `frontend/src-tauri/tauri.conf.json` +- `frontend/src-tauri/Cargo.toml` +- `frontend/src-tauri/Cargo.lock` + +版本号遵循: + +| 类型 | 示例 | 使用场景 | +| --- | --- | --- | +| 主版本 | `2.0.0` | 不兼容变更或重大架构调整 | +| 次版本 | `1.3.0` | 向后兼容的新功能 | +| 修订版本 | `1.2.1` | 向后兼容的缺陷修复 | + +禁止单独设置桌面端版本号。 + +## 八、从 `main` 发布到 `release` + +候选版本验收通过后,创建: + +```text +main -> release +``` + +按照当前仓库治理规则,使用普通合并提交,保留候选版本与生产版本之间的关系。 + +合并前必须确认: + +- 回归测试通过 +- 数据库迁移和回滚方案确认 +- 网页端生产构建通过 +- 桌面端生产构建通过 +- 发布说明完成 +- 生产配置和密钥不在仓库中 +- 正式版本号已经统一 + +合并后立即在 `release` 的准确提交上创建标签: + +```bash +git switch release +git pull --ff-only origin release +git tag -a v1.2.0 -m "CTMS v1.2.0" +git push origin v1.2.0 +``` + +网页端和桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。 + +发布记录至少包含: + +- 产品版本号 +- Git 标签 +- 完整提交编号 +- 网页端制品编号 +- 桌面端制品编号 +- 数据库迁移版本 +- 发布日期和负责人 + +## 九、生产热修复流程 + +### 1. 从 `release` 创建热修复分支 + +```bash +git fetch origin +git switch release +git pull --ff-only origin release +git switch -c hotfix/<问题名称> +``` + +热修复只能包含解决生产问题所需的最小改动,不得顺带加入新功能或大规模重构。 + +### 2. 更新修订版本 + +例如从 `1.2.0` 更新到 `1.2.1`: + +```bash +cd frontend +npm run version:set -- 1.2.1 +npm run version:check +cd .. +``` + +### 3. 验证并提交 + +```bash +git add <热修复相关文件和版本文件> +git diff --cached --check +git commit -m "fix(<范围>): <生产问题说明>" +git push -u origin hotfix/<问题名称> +``` + +创建: + +```text +hotfix/<问题名称> -> release +``` + +### 4. 合并并创建标签 + +热修复合并到 `release` 并验证后创建 `v1.2.1` 标签。 + +### 5. 强制回合并 + +生产热修复必须立即回合并: + +```text +release -> main -> dev +``` + +不得假设 `main` 或 `dev` 已经包含相同修复。发生冲突时必须立即解决并在合并请求中记录原因。 + +## 十、冲突处理规则 + +发生冲突时: + +1. 先确认冲突两侧的业务意图。 +2. 不使用整文件覆盖方式跳过判断。 +3. 保留双方仍然有效的修改。 +4. 重新执行受影响测试。 +5. 在合并请求中记录冲突文件和处理结果。 + +禁止使用以下方式处理普通同步冲突: + +```bash +git reset --hard +git checkout -- <文件> +``` + +除非已经明确确认可以丢弃本地修改,否则不得执行破坏性命令。 + +## 十一、分支保护建议 + +### `release` + +- 禁止直接推送 +- 必须通过合并请求 +- 至少一名评审人批准 +- 必须通过状态检查 +- 正式标签只由发布负责人创建 + +### `main` + +- 禁止直接推送 +- 必须通过合并请求 +- 必须完成回归和构建检查 +- 只接收版本候选内容 + +### `dev` + +- 优先通过合并请求 +- 必须通过相关测试 +- 禁止提交密钥和本地配置 +- 禁止合入明确不可构建的代码 + +## 十二、每周分支维护 + +每周至少执行一次: + +```bash +git fetch --prune origin +git branch -vv +git log --oneline --decorate --graph --all -30 +``` + +检查事项: + +- 已合并临时分支是否删除 +- 是否出现未经批准的长期平台分支 +- `dev`、`main`、`release` 是否符合各自职责 +- 生产热修复是否已回合并到 `main` 和 `dev` +- 版本文件是否一致 +- 正式标签是否准确指向 `release` +- 持续集成门禁是否持续通过 + +## 十三、禁止事项 + +- 禁止长期维护网页端和桌面端平行分支 +- 禁止通过复制代码维护桌面端业务页面 +- 禁止在业务模块中直接使用 Tauri API +- 禁止在不同提交上构建同一版本的网页端和桌面端 +- 禁止未经 `dev` 和 `main` 直接向 `release` 发布普通功能 +- 禁止生产热修复只进入 `release` 而不回合并 +- 禁止在正式构建中包含未提交文件 +- 禁止提交 `.env`、密钥、证书、令牌和个人配置 +- 禁止提交 `node_modules`、`dist`、Tauri `target` 等构建产物 + +## 十四、发布前最终检查清单 + +- [ ] 本次发布范围已经冻结 +- [ ] `dev` 集成测试通过 +- [ ] `main` 候选版本验收通过 +- [ ] 网页端与桌面端版本一致 +- [ ] `npm run version:check` 通过 +- [ ] `npm run runtime:check` 通过 +- [ ] `npm run desktop:release:check` 通过 +- [ ] `npm run ui:contract` 通过 +- [ ] `npm run type-check` 通过 +- [ ] `npm run test:unit` 通过 +- [ ] `npm run build` 通过 +- [ ] `npm run desktop:build:app` 通过 +- [ ] 正式桌面发布构建已使用 updater 签名私钥执行 +- [ ] 数据库迁移与回滚方案确认 +- [ ] 发布说明完成 +- [ ] `main -> release` 合并完成 +- [ ] 正式标签创建在准确的 `release` 提交上 +- [ ] 网页端和桌面端从同一标签构建 +- [ ] 发布记录包含版本、标签和完整提交编号 + +## 十五、流程速查 + +普通功能: + +```text +最新 dev + -> feature/* + -> 开发、测试、评审 + -> dev + -> 删除临时分支 +``` + +正式发布: + +```text +dev + -> main + -> 统一版本号 + -> 回归与验收 + -> release + -> 创建 vX.Y.Z 标签 + -> 同一标签构建网页端和桌面端 +``` + +生产热修复: + +```text +release + -> hotfix/* + -> release + -> 创建修订版本标签 + -> main + -> dev +``` diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md new file mode 100644 index 00000000..001e8987 --- /dev/null +++ b/docs/guides/client-release.md @@ -0,0 +1,177 @@ +# CTMS Web and Desktop Release Guide + +## Release Unit + +CTMS uses one repository, one promotion path, and one product version. Web and +Desktop are build targets from the same source commit, not separately versioned +products. + +The release identity consists of: + +- semantic version, for example `1.8.0` +- Git tag, for example `v1.8.0` +- full Git commit SHA +- build channel: `dev`, `main`, or `release` +- client type: `web` or `desktop` + +## Runtime Boundary + +Shared Vue business code lives under `frontend/src/`. Platform decisions are +exposed through `frontend/src/runtime/index.ts`. + +The runtime contract currently provides: + +- API base URL resolution +- Web, macOS, Windows, and Linux runtime identification +- app version, source commit, build channel, and client type metadata +- explicit capability flags +- Desktop server address configuration +- secure session storage +- file picker/save/open adapters +- desktop system notification adapters +- desktop updater adapters + +Business modules must use this public runtime entry point. They must not inspect +Tauri globals or import Tauri packages directly. Native files, notifications, +secure session storage, and automatic updates must remain behind +`frontend/src/runtime/` and explicit capability flags. + +## Version Change + +Update every client manifest with one command: + +```bash +cd frontend +npm run version:set -- 1.8.0 +npm run version:check +``` + +This synchronizes: + +- `frontend/package.json` +- `frontend/package-lock.json` +- `frontend/src-tauri/tauri.conf.json` +- `frontend/src-tauri/Cargo.toml` +- `frontend/src-tauri/Cargo.lock` + +Manual edits that leave these files inconsistent fail CI. + +## Stabilization Gates + +Client release candidates must pass the shared Web checks and the Desktop +release/security gate before promotion: + +```bash +cd frontend +npm run version:check +npm run release:env:check +npm run runtime:check +npm run desktop:release:check +npm run ui:contract +npm run type-check +npm run test:unit +npm run build +npm run desktop:build:app +``` + +`release:env:check` verifies build channel and commit metadata, and can be +made strict for signed Desktop builds with `REQUIRE_DESKTOP_SIGNING=true`. +`desktop:release:check` statically verifies the Tauri bundle, updater public +key, CSP, capability scopes, command allowlist, query-token ban, generic system +notification boundary, CI gate coverage, and secure session token boundary. The +full manual release, security, regression, and Desktop UX checklist lives in +`docs/audits/desktop-release-stabilization-checklist.md`. + +## Promotion + +1. Merge feature branches into `dev`. +2. Require the shared client/Web and macOS Desktop CI jobs to pass. +3. Promote the accepted scope from `dev` to `main`. +4. Set the release version and complete regression testing on `main`. +5. Promote `main` to `release`. +6. Create the matching `vX.Y.Z` tag on the accepted `release` commit. +7. Build both Web and Desktop artifacts from that exact tag. + +Build metadata is injected by CI: + +```bash +VITE_BUILD_CHANNEL=release +VITE_BUILD_COMMIT="$(git rev-parse HEAD)" +``` + +These values support diagnostics but do not replace the semantic version. + +## Desktop Updater + +Formal Desktop release builds must use Tauri updater signatures. The application +embeds the updater public key in `frontend/src-tauri/tauri.conf.json`; the +private key must live only in the organization key vault or CI secret store. + +Runtime update checks derive the feed from the currently configured CTMS origin: + +```text +/desktop-updates/stable/latest.json +``` + +Production update feeds must use HTTPS. Local update testing may use HTTP only +for `localhost`, `127.0.0.1`, or `::1`. + +The release pipeline must: + +1. build from the accepted release tag and commit; +2. build macOS Universal desktop artifacts; +3. sign and notarize the macOS app; +4. produce updater artifacts and `.sig` files with the updater private key; +5. generate `latest.json` and a checksum manifest; +6. verify the feed with `npm run desktop:update-feed:check -- --feed --artifacts-dir `; +7. upload immutable artifacts first; +8. atomically replace `latest.json` last. + +For Universal macOS artifacts, `latest.json` must provide both +`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal +update package. + +## Windows Build Readiness + +Second-phase Windows work is limited to CI compatibility validation. A +`windows-latest` x64 NSIS build may be produced for verification, but it is not +a formal deliverable until Windows code signing and release support are +approved. + +Windows validation must cover: + +- WebView2 runtime prerequisite behavior; +- user-level installer assumptions; +- Windows Credential Manager session storage; +- path handling and temporary file cleanup; +- notification and updater compilation; +- future code-signing requirements. + +## Required Checks + +```bash +cd frontend +npm ci +npm run version:check +export VITE_BUILD_CHANNEL=release +export VITE_BUILD_COMMIT="$(git rev-parse HEAD)" +npm run release:env:check +npm run runtime:check +npm run desktop:release:check +npm run ui:contract +npm run type-check +npm run test:unit +npm run build +npm run desktop:build:app +export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY" +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD" +export REQUIRE_DESKTOP_SIGNING=true +npm run release:env:check +npm run desktop:build -- --bundles app +npm run desktop:update-feed:check -- --feed src-tauri/target/release/bundle/latest.json --artifacts-dir src-tauri/target/release/bundle +``` + +The Desktop build must run on macOS for the current first-phase target. A signed +or notarized public release additionally requires the Apple credentials defined +by the release owner. A formal second-phase desktop release also requires the +updater signing key; unsigned internal builds are not formal distributions. diff --git a/frontend/.env.example b/frontend/.env.example index 9150c955..f30d4cc2 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,9 @@ # Frontend feature flags and timeline overrides VITE_RUNTIME_ENV=production VITE_ALLOW_INSECURE_DEV_LOGIN=false +VITE_DEV_API_PROXY_TARGET=http://backend:8000 +# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry. +VITE_HMR_CLIENT_PORT= VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3 VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bf4975cc..a48a90f9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,12 @@ "name": "ctms-frontend", "version": "0.1.0", "dependencies": { + "@tauri-apps/api": "^2.8.0", + "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-fs": "^2.4.0", + "@tauri-apps/plugin-notification": "^2.3.0", + "@tauri-apps/plugin-opener": "^2.5.0", + "@tauri-apps/plugin-updater": "^2.9.0", "axios": "^1.6.8", "date-fns": "^3.6.0", "echarts": "^6.0.0", @@ -18,6 +24,7 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@tauri-apps/cli": "^2.11.4", "@types/node": "^20.10.5", "@vitejs/plugin-vue": "^6.0.3", "@vue/test-utils": "^2.4.6", @@ -1354,6 +1361,293 @@ "win32" ] }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4ae2616b..d5102bc0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,11 +7,28 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "tauri": "tauri", + "desktop:dev": "tauri dev", + "desktop:build": "tauri build", + "desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app", + "desktop:bundle:dmg": "tauri build --bundles dmg", + "desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs", + "release:env:check": "node scripts/verify-release-build-env.mjs", + "version:check": "node scripts/client-version.mjs --check", + "version:set": "node scripts/client-version.mjs --set", + "runtime:check": "node scripts/verify-runtime-boundary.mjs", + "desktop:release:check": "node scripts/verify-desktop-release.mjs", "test:unit": "vitest run --environment jsdom", "type-check": "vue-tsc --noEmit", "ui:contract": "node scripts/verify-ui-contract.mjs" }, "dependencies": { + "@tauri-apps/api": "^2.8.0", + "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-fs": "^2.4.0", + "@tauri-apps/plugin-notification": "^2.3.0", + "@tauri-apps/plugin-opener": "^2.5.0", + "@tauri-apps/plugin-updater": "^2.9.0", "axios": "^1.6.8", "date-fns": "^3.6.0", "echarts": "^6.0.0", @@ -22,6 +39,7 @@ "vue-router": "^4.2.5" }, "devDependencies": { + "@tauri-apps/cli": "^2.11.4", "@types/node": "^20.10.5", "@vitejs/plugin-vue": "^6.0.3", "@vue/test-utils": "^2.4.6", diff --git a/frontend/scripts/client-version.mjs b/frontend/scripts/client-version.mjs new file mode 100644 index 00000000..0ae95371 --- /dev/null +++ b/frontend/scripts/client-version.mjs @@ -0,0 +1,122 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const paths = { + packageJson: new URL("../package.json", import.meta.url), + packageLock: new URL("../package-lock.json", import.meta.url), + tauriConfig: new URL("../src-tauri/tauri.conf.json", import.meta.url), + cargoToml: new URL("../src-tauri/Cargo.toml", import.meta.url), + cargoLock: new URL("../src-tauri/Cargo.lock", import.meta.url), +}; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); +const writeJson = async (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`); + +const readCargoPackageVersion = async () => { + const cargo = await readFile(paths.cargoToml, "utf8"); + const packageSection = cargo.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1]; + const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + if (!version) throw new Error("Cannot find [package].version in src-tauri/Cargo.toml"); + return version; +}; + +const readCargoLockPackageVersion = async () => { + const cargoLock = await readFile(paths.cargoLock, "utf8"); + const packageSection = cargoLock + .match(/\[\[package\]\]([\s\S]*?)(?=\n\[\[package\]\]|$)/g) + ?.find((section) => /^name\s*=\s*"ctms-desktop"$/m.test(section)); + const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + if (!version) throw new Error("Cannot find ctms-desktop version in src-tauri/Cargo.lock"); + return version; +}; + +const readVersions = async () => { + const [packageJson, packageLock, tauriConfig, cargoVersion, cargoLockVersion] = await Promise.all([ + readJson(paths.packageJson), + readJson(paths.packageLock), + readJson(paths.tauriConfig), + readCargoPackageVersion(), + readCargoLockPackageVersion(), + ]); + return { + "package.json": packageJson.version, + "package-lock.json": packageLock.version, + "package-lock.json root": packageLock.packages?.[""]?.version, + "tauri.conf.json": tauriConfig.version, + "Cargo.toml": cargoVersion, + "Cargo.lock": cargoLockVersion, + }; +}; + +const assertVersionsMatch = async () => { + const versions = await readVersions(); + const uniqueVersions = new Set(Object.values(versions)); + if (uniqueVersions.size !== 1 || uniqueVersions.has(undefined)) { + const details = Object.entries(versions) + .map(([file, version]) => ` ${file}: ${version ?? ""}`) + .join("\n"); + throw new Error(`Client versions are not synchronized:\n${details}`); + } + const [version] = uniqueVersions; + console.log(`Client version ${version} is synchronized.`); +}; + +const setVersion = async (version) => { + if (!SEMVER_PATTERN.test(version)) { + throw new Error(`Invalid semantic version: ${version}`); + } + + const [packageJson, packageLock, tauriConfig, tauriConfigSource, cargo, cargoLock] = await Promise.all([ + readJson(paths.packageJson), + readJson(paths.packageLock), + readJson(paths.tauriConfig), + readFile(paths.tauriConfig, "utf8"), + readFile(paths.cargoToml, "utf8"), + readFile(paths.cargoLock, "utf8"), + ]); + + packageJson.version = version; + packageLock.version = version; + packageLock.packages[""].version = version; + + const tauriVersionPattern = /("version"\s*:\s*")[^"]+(")/; + const packageSectionPattern = /(\[package\][\s\S]*?^version\s*=\s*")[^"]+(")/m; + const lockPackagePattern = + /(\[\[package\]\]\nname\s*=\s*"ctms-desktop"\nversion\s*=\s*")[^"]+(")/m; + if (typeof tauriConfig.version !== "string" || !tauriVersionPattern.test(tauriConfigSource)) { + throw new Error("Cannot update version in src-tauri/tauri.conf.json"); + } + if (!packageSectionPattern.test(cargo)) { + throw new Error("Cannot update [package].version in src-tauri/Cargo.toml"); + } + if (!lockPackagePattern.test(cargoLock)) { + throw new Error("Cannot update ctms-desktop version in src-tauri/Cargo.lock"); + } + + await Promise.all([ + writeJson(paths.packageJson, packageJson), + writeJson(paths.packageLock, packageLock), + writeFile(paths.tauriConfig, tauriConfigSource.replace(tauriVersionPattern, `$1${version}$2`)), + writeFile(paths.cargoToml, cargo.replace(packageSectionPattern, `$1${version}$2`)), + writeFile(paths.cargoLock, cargoLock.replace(lockPackagePattern, `$1${version}$2`)), + ]); + console.log(`Updated CTMS Web and Desktop client version to ${version} in ${frontendDir}`); + await assertVersionsMatch(); +}; + +const [, , command = "--check", value] = process.argv; + +try { + if (command === "--check") { + await assertVersionsMatch(); + } else if (command === "--set" && value) { + await setVersion(value); + } else { + throw new Error("Usage: node scripts/client-version.mjs [--check | --set ]"); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs new file mode 100644 index 00000000..6b790a3b --- /dev/null +++ b/frontend/scripts/verify-desktop-release.mjs @@ -0,0 +1,218 @@ +import { readdir, readFile } from "node:fs/promises"; +import { extname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const rootDir = resolve(frontendDir, ".."); +const sourceDir = resolve(frontendDir, "src"); +const tauriDir = resolve(frontendDir, "src-tauri"); +const failures = []; + +const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); + +const fail = (message) => failures.push(message); +const assert = (condition, message) => { + if (!condition) fail(message); +}; + +const walk = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + return ( + await Promise.all( + entries.map(async (entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? walk(path) : path; + }), + ) + ).flat(); +}; + +const permissionIdentifier = (permission) => + typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : ""; + +const assertPathScope = (permission, expectedPrefix, description) => { + const allow = Array.isArray(permission.allow) ? permission.allow : []; + assert(allow.length > 0, `${description} must define an explicit allow list.`); + for (const item of allow) { + const path = item?.path; + assert( + typeof path === "string" && path.startsWith(expectedPrefix), + `${description} may only allow paths under ${expectedPrefix}; found ${path ?? ""}.`, + ); + } +}; + +const verifyTauriConfig = async () => { + const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json")); + const targets = tauriConfig.bundle?.targets; + const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean); + const csp = tauriConfig.app?.security?.csp || ""; + const cspTokens = csp.split(/[;\s]+/).filter(Boolean); + const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0]; + + assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds."); + assert(targetList.includes("app"), "Tauri bundle targets must include app."); + assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution."); + assert( + tauriConfig.bundle?.createUpdaterArtifacts === true, + "Tauri must create updater artifacts for signed desktop release builds.", + ); + assert( + typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80, + "Tauri updater public key must be configured.", + ); + assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self."); + assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src."); + assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval."); + assert( + !cspTokens.some((token) => token === "*" || token.includes("://*")), + "Tauri CSP must not use wildcard sources.", + ); + assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access."); + assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180."); + assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760."); +}; + +const verifyCapabilities = async () => { + const capabilitiesDir = resolve(tauriDir, "capabilities"); + const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json")); + assert(files.length > 0, "At least one Tauri capability file must exist."); + + const bannedPermissions = new Set([ + "shell:default", + "shell:allow-open", + "shell:allow-execute", + "fs:default", + "fs:allow-read-dir", + "fs:allow-read-text-file", + "fs:allow-write-text-file", + ]); + + for (const file of files) { + const capability = await readJson(resolve(capabilitiesDir, file)); + const permissions = Array.isArray(capability.permissions) ? capability.permissions : []; + const identifiers = permissions.map(permissionIdentifier).filter(Boolean); + + for (const identifier of identifiers) { + assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`); + assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`); + assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`); + } + + const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope"); + assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`); + if (fsScope && typeof fsScope !== "string") { + assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`); + } + + const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path"); + assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`); + if (openerScope && typeof openerScope !== "string") { + assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`); + } + } +}; + +const verifyRustBoundary = async () => { + const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8"); + const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"]; + for (const token of forbiddenRust) { + assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`); + } + + const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init"); + const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init"); + assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered."); + assert( + dialogIndex < 0 || singleInstanceIndex < dialogIndex, + "Single-instance plugin must be registered before other desktop plugins.", + ); + + const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || ""; + const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || []; + const allowedCommands = [ + "credentials::credential_get", + "credentials::credential_set", + "credentials::credential_delete", + "updates::desktop_update_check", + "updates::desktop_update_install", + ]; + const unexpected = commands.filter((command) => !allowedCommands.includes(command)); + const missing = allowedCommands.filter((command) => !commands.includes(command)); + assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || ""}.`); + assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || ""}.`); +}; + +const verifySourceSafety = async () => { + const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]); + const files = [ + ...(await walk(sourceDir)), + ...(await walk(resolve(tauriDir, "src"))), + ].filter((path) => sourceExtensions.has(extname(path))); + + for (const path of files) { + const source = await readFile(path, "utf8"); + const file = relative(rootDir, path); + assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`); + assert( + !/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source), + `${file}: token-related values must not be written to console logs.`, + ); + if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") { + fail(`${file}: ctms_token may only be handled by secureSessionStorage.`); + } + if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") { + fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`); + } + } +}; + +const verifyNotificationBoundary = async () => { + const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8"); + assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic."); + assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic."); + assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content."); +}; + +const verifyUpdaterBoundary = async () => { + const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8"); + assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path."); + assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds."); + assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials."); +}; + +const verifyWorkflowGates = async () => { + const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8"); + const requiredCommands = [ + "npm run version:check", + "npm run runtime:check", + "npm run desktop:release:check", + "npm run ui:contract", + "npm run type-check", + "npm run test:unit", + "npm run build", + "npm run desktop:build:app", + "npm run release:env:check", + ]; + + for (const command of requiredCommands) { + assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`); + } + assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL."); + assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT."); +}; + +await verifyTauriConfig(); +await verifyCapabilities(); +await verifyRustBoundary(); +await verifySourceSafety(); +await verifyNotificationBoundary(); +await verifyUpdaterBoundary(); +await verifyWorkflowGates(); + +if (failures.length > 0) { + console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); + process.exitCode = 1; +} else { + console.log("Desktop release gate passed."); +} diff --git a/frontend/scripts/verify-desktop-update-feed.mjs b/frontend/scripts/verify-desktop-update-feed.mjs new file mode 100644 index 00000000..cb18e00c --- /dev/null +++ b/frontend/scripts/verify-desktop-update-feed.mjs @@ -0,0 +1,98 @@ +import { access, readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const failures = []; + +const args = process.argv.slice(2); +const optionValue = (name) => { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +}; + +const feedPath = resolve( + frontendDir, + optionValue("--feed") || process.env.DESKTOP_UPDATE_FEED || "src-tauri/target/release/bundle/latest.json", +); +const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR; +const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL; + +const fail = (message) => failures.push(message); +const assert = (condition, message) => { + if (!condition) fail(message); +}; + +const assertFileExists = async (path, description) => { + try { + await access(path); + } catch { + fail(`${description} does not exist: ${path}`); + } +}; + +let feed; +try { + feed = JSON.parse(await readFile(feedPath, "utf8")); +} catch (error) { + fail(`Cannot read desktop update feed ${feedPath}: ${error.message}`); +} + +if (feed) { + const normalizedFeedVersion = String(feed.version || "").replace(/^v/, ""); + const platforms = feed.platforms || {}; + const darwinArm = platforms["darwin-aarch64"]; + const darwinIntel = platforms["darwin-x86_64"]; + + assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`); + assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date."); + assert(Boolean(darwinArm), "latest.json must include darwin-aarch64."); + assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64."); + + const entries = [ + ["darwin-aarch64", darwinArm], + ["darwin-x86_64", darwinIntel], + ]; + + for (const [platform, entry] of entries) { + const rawUrl = entry?.url; + const signature = entry?.signature; + assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`); + assert(typeof rawUrl === "string" && rawUrl.length > 0, `${platform} must include an artifact URL.`); + if (!rawUrl) continue; + + let url; + try { + url = new URL(rawUrl); + } catch { + fail(`${platform} artifact URL is invalid: ${rawUrl}`); + continue; + } + + assert(url.protocol === "https:", `${platform} artifact URL must use HTTPS.`); + assert(!/[?&]token=/i.test(url.search), `${platform} artifact URL must not contain token query parameters.`); + assert(!url.pathname.endsWith("/latest.json"), `${platform} artifact URL must point to an immutable artifact, not latest.json.`); + assert(url.pathname.includes(packageInfo.version), `${platform} artifact URL must include version ${packageInfo.version}.`); + if (expectedBaseUrl) { + assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`); + } + + if (artifactDir) { + const artifactPath = resolve(artifactDir, basename(url.pathname)); + await assertFileExists(artifactPath, `${platform} updater artifact`); + await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`); + } + } + + if (darwinArm?.url && darwinIntel?.url) { + assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact."); + } +} + +if (failures.length > 0) { + console.error(`Desktop update feed check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); + process.exitCode = 1; +} else { + console.log("Desktop update feed check passed."); +} diff --git a/frontend/scripts/verify-release-build-env.mjs b/frontend/scripts/verify-release-build-env.mjs new file mode 100644 index 00000000..db684972 --- /dev/null +++ b/frontend/scripts/verify-release-build-env.mjs @@ -0,0 +1,66 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); +const failures = []; + +const allowedChannels = new Set(["dev", "main", "release", "local"]); +const fullShaPattern = /^[0-9a-f]{40}$/i; +const semverTag = `v${packageInfo.version}`; + +const env = process.env; +const channel = env.VITE_BUILD_CHANNEL || "local"; +const commit = env.VITE_BUILD_COMMIT || "local"; +const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true"; +const isTagBuild = env.GITHUB_REF_TYPE === "tag"; +const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release"; + +const fail = (message) => failures.push(message); +const assert = (condition, message) => { + if (!condition) fail(message); +}; + +const requireEnv = (name) => { + assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`); +}; + +assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`); + +if (isCi || isReleaseBuild) { + assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL."); + assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT."); +} + +if (env.GITHUB_SHA) { + assert( + commit === env.GITHUB_SHA || commit === "local", + "VITE_BUILD_COMMIT must match GITHUB_SHA when GitHub Actions provides a source commit.", + ); +} + +if (isTagBuild) { + assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || ""}.`); + assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release."); +} + +if (env.REQUIRE_DESKTOP_SIGNING === "true") { + assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS."); + requireEnv("TAURI_SIGNING_PRIVATE_KEY"); + requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"); + requireEnv("APPLE_ID"); + requireEnv("APPLE_PASSWORD"); + requireEnv("APPLE_TEAM_ID"); + assert( + Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY), + "APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.", + ); +} + +if (failures.length > 0) { + console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); + process.exitCode = 1; +} else { + console.log("Release build environment check passed."); +} diff --git a/frontend/scripts/verify-runtime-boundary.mjs b/frontend/scripts/verify-runtime-boundary.mjs new file mode 100644 index 00000000..80dd99e8 --- /dev/null +++ b/frontend/scripts/verify-runtime-boundary.mjs @@ -0,0 +1,41 @@ +import { readdir, readFile } from "node:fs/promises"; +import { extname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const frontendDir = fileURLToPath(new URL("../", import.meta.url)); +const sourceDir = resolve(frontendDir, "src"); +const runtimeDir = resolve(sourceDir, "runtime"); +const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]); +const violations = []; + +const walk = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + return ( + await Promise.all( + entries.map(async (entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? walk(path) : path; + }), + ) + ).flat(); +}; + +for (const path of await walk(sourceDir)) { + if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue; + + const source = await readFile(path, "utf8"); + const file = relative(frontendDir, path); + if (source.includes("@tauri-apps/") || source.includes("__TAURI")) { + violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`); + } + if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) { + violations.push(`${file}: import platform behavior through src/runtime/index.ts`); + } +} + +if (violations.length > 0) { + console.error(`Runtime boundary violations:\n${violations.map((item) => ` ${item}`).join("\n")}`); + process.exitCode = 1; +} else { + console.log("Runtime boundary is respected."); +} diff --git a/frontend/scripts/verify-ui-contract.mjs b/frontend/scripts/verify-ui-contract.mjs index 572c7ec4..913291ad 100644 --- a/frontend/scripts/verify-ui-contract.mjs +++ b/frontend/scripts/verify-ui-contract.mjs @@ -32,38 +32,71 @@ const missing = [ ]; const pageContractChecks = [ - "src/views/ia/ProjectMilestones.vue", - "src/views/ia/SubjectManagement.vue", - "src/views/ia/RiskIssueSae.vue", - "src/views/ia/RiskIssuePd.vue", - "src/views/ia/RiskIssueMonitoringVisits.vue" + { + file: "src/views/ia/ProjectMilestones.vue", + groups: [ + ["ctms-page-shell", "page"], + ["unified-action-bar", "table-card-toolbar"], + ["ctms-table-card", "table-card"] + ] + }, + { + file: "src/views/ia/SubjectManagement.vue", + groups: [ + ["ctms-page-shell", "page"], + ["unified-action-bar", "table-card-toolbar"], + ["ctms-table-card", "table-card"], + ["subject-table"] + ] + }, + { + file: "src/views/ia/RiskIssueSae.vue", + groups: [ + ["ctms-page-shell", "page"], + ["unified-action-bar", "table-card-toolbar"], + ["ctms-table-card", "table-card"], + ["risk-table"] + ] + }, + { + file: "src/views/ia/RiskIssuePd.vue", + groups: [ + ["ctms-page-shell", "page"], + ["unified-action-bar", "table-card-toolbar"], + ["ctms-table-card", "table-card"], + ["risk-table"] + ] + }, + { + file: "src/views/ia/RiskIssueMonitoringVisits.vue", + groups: [ + ["ctms-page-shell", "page"], + ["unified-action-bar", "monitoring-toolbar", "template-toolbar", "toolbar"], + ["ctms-table-card", "table-card", "monitoring-table", "issue-table-panel"], + ["issue-table"] + ] + } ]; -const requiredPageClasses = [ - "ctms-page-shell", - "unified-action-bar", - "ctms-table-card" -]; - -for (const file of pageContractChecks) { +for (const { file, groups } of pageContractChecks) { const content = readFileSync(file, "utf8"); - for (const className of requiredPageClasses) { - if (!content.includes(className)) { - missing.push(`${file}:${className}`); + for (const group of groups) { + if (!group.some((className) => content.includes(className))) { + missing.push(`${file}:${group.join("|")}`); } } } const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8"); -const requiredOverviewClasses = [ - "ctms-page-shell", - "kpi", - "unified-section" +const requiredOverviewGroups = [ + ["ctms-page-shell", "page"], + ["kpi", "overview-card"], + ["unified-section", "overview-container"] ]; -for (const className of requiredOverviewClasses) { - if (!overview.includes(className)) { - missing.push(`src/views/ia/ProjectOverview.vue:${className}`); +for (const group of requiredOverviewGroups) { + if (!group.some((className) => overview.includes(className))) { + missing.push(`src/views/ia/ProjectOverview.vue:${group.join("|")}`); } } diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock new file mode 100644 index 00000000..5b6f3ac6 --- /dev/null +++ b/frontend/src-tauri/Cargo.lock @@ -0,0 +1,5737 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ctms-desktop" +version = "0.1.0" +dependencies = [ + "keyring", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-updater", + "time", + "url", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fef88805a7ddbc8f9cf52bfa8dba90b3f80dff23a1e1533cd4f1d8e8d448fc8" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml 0.38.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2616f96cb644bf2c5c456d9de4d5d5100e592d7424c74d8b55c5cb96e359e93" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml new file mode 100644 index 00000000..2e8e39b2 --- /dev/null +++ b/frontend/src-tauri/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "ctms-desktop" +version = "0.1.0" +description = "CTMS desktop client" +authors = ["Huapont"] +edition = "2021" + +[lib] +name = "ctms_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" +tauri-plugin-notification = "2" +tauri-plugin-opener = "2" +tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] } +url = "2" + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "4.1.2", default-features = false, features = ["v1", "apple-native-keyring-store"] } + +[target.'cfg(windows)'.dependencies] +keyring = { version = "4.1.2", default-features = false, features = ["v1", "windows-native-keyring-store"] } + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/frontend/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json new file mode 100644 index 00000000..08b32614 --- /dev/null +++ b/frontend/src-tauri/capabilities/default.json @@ -0,0 +1,27 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability for the CTMS desktop window.", + "windows": ["main"], + "permissions": [ + "core:default", + "dialog:allow-open", + "dialog:allow-save", + "fs:allow-read-file", + "fs:allow-write-file", + "fs:allow-remove", + { + "identifier": "fs:scope", + "allow": [ + { "path": "$TEMP/ctms-desktop/**" } + ] + }, + "notification:default", + { + "identifier": "opener:allow-open-path", + "allow": [ + { "path": "$TEMP/ctms-desktop/**" } + ] + } + ] +} diff --git a/frontend/src-tauri/icons/128x128.png b/frontend/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..bb0f9d174ae86e8741c36245380dd2b2ec9be877 GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSZ#-QbLn>~)y=2JCz`%2OgY47( zN)xTCZ33#tw|FEl@H8+QFt8n9kYHdQP>w+Z3&pOGm7B1ZiSg6(Z>kJH;OXk;vd$@? F2>^nPIm!S4 literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/128x128@2x.png b/frontend/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..38794d45eaee9b580fdd2d30e59f6b194e5719e5 GIT binary patch literal 659 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEyC{Gv1kcwMxuNVU5cn)ol z{c+uf*+OCBg~gT$VJZyF3_J`53zopr0NQt2wg3PC literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/32x32.png b/frontend/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..d2e6a786df4db2cb3a95d1398f698c88358b1b67 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzEl(H6kcwMx&nYqjc}xn;|IMur jGjV5}(^5bIANY7I<}fsh_w5%4s$=kU^>bP0l+XkK0=yQ~ literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/64x64.png b/frontend/src-tauri/icons/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..1a7c3049d114160a785dbd979ec192a0d65c3aed GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=iJmTwAr-gYUT_p-VBld|VEbl% trKOD`)6POc1&0O(MkW>x0ZJIZY#%LPWX!JGJq2hPgQu&X%Q~loCICG#A29#` literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/Square107x107Logo.png b/frontend/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4e4434ff64db088bd6147be3d92dbdace0b1e491 GIT binary patch literal 233 zcmeAS@N?(olHy`uVBq!ia0vp^*&xip1|$pi*YE(T4W2HJAr-gYUNq!oVBk5tLH22X zr9xF`k-`znvm)FHoMJi$g>)jCR3kR9cyCDHoR$FO9t3imSiLu}a8ILw0x=yt)~>2% WyU)1xS!6cIBMhFdelF{r5}E)dr$}A^ literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/Square142x142Logo.png b/frontend/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..eb910ec840eec0fb068657d3a9fff4b0495dea48 GIT binary patch literal 322 zcmeAS@N?(olHy`uVBq!ia0vp^eIU%i1|*;VHQ-=iVC3|4aSW-r_4bk>F9QS5;SI7+ z`zvLBU279iWpDLpC^Xg&E)78&qol`;+0K=F9QS5;SI7+ z`zu*~ecP<)a`K>q%7F+8?&QXe24csUk`r}$SdSe73iPxB1%Tp-IzaJ55eYg#0eUK= cms?)QJ&j_DG?lOw1O^v_r>mdKI;Vst0J>LnYybcN literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/Square284x284Logo.png b/frontend/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d992d51b9712eb4cb7bd66e3a0743e9d8b1bd277 GIT binary patch literal 756 zcmeAS@N?(olHy`uVBq!ia0y~yV3Yx24mO~O=ojt>3=B-WJzX3_DsH{KqR7a=z;k#* z^Uv!wyvoj{s-EE*m%Nbn_CFtjnlDWDLD2d2OZ+L+<$a49EN9S&nDwO*o+ z1>szfM{v0lhyBRP!3Ghb4jO0}$|Ygr+Ar-gYo;74-VBld|Ap7n7 s9*HUk#v)b0?xRT?BXq=o)W^I(we<{@pQb+g3)I5k>FVdQ&MBb@08~L82mk;8 literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/Square310x310Logo.png b/frontend/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..04d68ef16f4d50e53fb11a977b712130cd2e10c3 GIT binary patch literal 846 zcmeAS@N?(olHy`uVBq!ia0y~yU^D|^4mP03>plBcGB7ZUdb&7iB z*{A)LDpjFoEixw=dpcPfHZn>`!-!*S2{{Z<0wF3PjSvO1a7n?80m~zsm5_r|3YS?x zXW){;X%>;z0QDm~jwowT!vLoXkjzopr0I^5_&;S4c literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/Square44x44Logo.png b/frontend/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b1793925d6c9ec44ed26aad8581b3d2037415506 GIT binary patch literal 114 zcmeAS@N?(olHy`uVBq!ia0vp^Iv~u!1|;QLq8NdcrKgKyNX4zU=L|U+79CIN{C=4sJYPyX}Qv3>%AAFQ@_ XpJ$YPWFN{5w3xxu)z4*}Q$iB}FTpqL literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/Square89x89Logo.png b/frontend/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3deeff797ee1da3a14741cf1f2e57cf599e6c839 GIT binary patch literal 194 zcmeAS@N?(olHy`uVBq!ia0vp^ks!>$1|&ndJJtiKPEQxdkcwMxFBtMNFz_%hko~&8 zQtOwALZeJ+phBe4Bvo&>O(`ng(|S@)PMWAAd2*7fGEDTO2Z|_28Bi@;6r@Z!5+MrG oJW&TB3exN`4Q!8G|n(o3WLr3gqKBl1mVAh)#Kcm1=t2AQViYA&23_Cmo;ugAdpq4N8qOSdH#7I`Z@&F~ z-^@4P+{U8E;sA0(EE)^}h*x{}HaFzwmF59Jep6%pJ^%r$0=RSV>(KFAhf(NYQ+?h3 zjyIpiWcGnG9fPs|Lv3U<_>{ z5+drMZKh*v79eLL8QNZ#klk=ccfbyTk4IbR03s+zSN7mcYW%_|LZv79fi<$T2n_=S0%{*G(;oUr2axmZ*t z`S1?xW#MMWJ1HP2)g{iMbDPAL2e_nD?@w+XrWvFMOsGUJaI523FK2u0b&i z+wz%COiBLen=`0GR#eFd=2P0dSHFN#%%aL-MeZVhSwe+A(qlod!4wYp2sfi<8p;w3 zMC-GNx;AZ<0o>pLO9jqdgu4h~z{3CjOBZp4$x}Nls#`Fi5{77s_)I5+sN_SZ#VMjS zqc{c5U_~AbOv$1=AZ^ttD{>bhv(P}lsl77F46fvsP8JH?4>nGb0p}mPjs~mn$^R|J z17uK8Ows(wQ1LRTa0J$>o>TF^8llzSk@F+qMa#Vb5(zT?Gpdw|gg?Cfm P9Z~rD@q0O7nIr2DTsx~) literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/icon.ico b/frontend/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..005c578f62aad1ecaac2a5bc8f9b8cbe07f784f5 GIT binary patch literal 1956 zcmZQzU}RupP*7k10tJSVEGq+S8}1_ltmIFP-TiGjhv z0mAnM@{cfs*dU<5Fc~N($jZRb8Q|y6%O%AH6y){va0voS0u^wu0a?XWRbPPA5>FS$ zkP61P2bdSAL{2;Q@qhWd-V>7xLjKjR`13p9=l6x5a7a9qXY4aza4g((;W`0lf?`zy z?92z{wm*QJE()AE9~cLETK{*h`15?xCki>hkUoE%A@S7P>1_nO07{4kU@wF-{cHzv z?or@{{nAta-(B(Nz1!1zbFKfo1ApG{J@r2?G& p7z`K=Fi?v@J(J)r8a0x7AwWHo8lEv$Ut(c2RQY$20SI6f3;>gM;`jgn literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/icons/icon.png b/frontend/src-tauri/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..352fca207c24d72fdf8c417a1ecdac2562f52841 GIT binary patch literal 1809 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe+UZg{#lhE&{odrgs%fr01n zhUTBwYlM@%RYXkL8P0UFFeCt#GBYqpFfbedvVa;57#JErN*M@9k*FMD2~nmalq0!` zEO&xTBEegz7V|WWh7kV18x0{s$!j!(Mnfn8ShkLqLj-CRQbOp$=aZ!zjA!{D?*xV{ NgQu&X%Q~loCIEgRox}hD literal 0 HcmV?d00001 diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs new file mode 100644 index 00000000..e48a0dc0 --- /dev/null +++ b/frontend/src-tauri/src/credentials.rs @@ -0,0 +1,114 @@ +use sha2::{Digest, Sha256}; +use url::Url; + +const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session"; + +fn credential_account(server_origin: &str) -> Result { + let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?; + let is_https = parsed.scheme() == "https"; + let is_local_http = parsed.scheme() == "http" + && matches!( + parsed.host_str(), + Some("localhost") | Some("127.0.0.1") | Some("::1") + ); + if !is_https && !is_local_http { + return Err("非本地服务必须使用 HTTPS".to_string()); + } + if parsed.username() != "" || parsed.password().is_some() { + return Err("服务器地址不能包含凭据".to_string()); + } + let origin = parsed.origin().ascii_serialization(); + let digest = Sha256::digest(origin.as_bytes()); + Ok(format!("{digest:x}")) +} + +#[cfg(any(target_os = "macos", windows))] +fn get_entry(server_origin: &str) -> Result { + let account = credential_account(server_origin)?; + keyring::Entry::new(CREDENTIAL_SERVICE, &account) + .map_err(|error| format!("无法访问系统凭据库:{error}")) +} + +#[tauri::command] +pub async fn credential_get(server_origin: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + let entry = get_entry(&server_origin)?; + return match entry.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(format!("读取系统凭据失败:{error}")), + }; + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("读取系统凭据任务失败:{error}"))? +} + +#[tauri::command] +pub async fn credential_set(server_origin: String, token: String) -> Result<(), String> { + if token.trim().is_empty() { + return Err("拒绝保存空凭据".to_string()); + } + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + return get_entry(&server_origin)? + .set_password(&token) + .map_err(|error| format!("保存系统凭据失败:{error}")); + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("保存系统凭据任务失败:{error}"))? +} + +#[tauri::command] +pub async fn credential_delete(server_origin: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + #[cfg(any(target_os = "macos", windows))] + { + let entry = get_entry(&server_origin)?; + return match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(error) => Err(format!("删除系统凭据失败:{error}")), + }; + } + #[cfg(not(any(target_os = "macos", windows)))] + { + let _ = credential_account(&server_origin)?; + Err("当前平台不支持系统凭据存储".to_string()) + } + }) + .await + .map_err(|error| format!("删除系统凭据任务失败:{error}"))? +} + +#[cfg(test)] +mod tests { + use super::credential_account; + + #[test] + fn account_is_stable_for_same_origin() { + assert_eq!( + credential_account("https://ctms.example.com/path").unwrap(), + credential_account("https://ctms.example.com/other").unwrap() + ); + } + + #[test] + fn rejects_insecure_remote_origin() { + assert!(credential_account("http://ctms.example.com").is_err()); + assert!(credential_account("http://localhost:8000").is_ok()); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs new file mode 100644 index 00000000..b3708d5f --- /dev/null +++ b/frontend/src-tauri/src/lib.rs @@ -0,0 +1,152 @@ +mod credentials; +mod updates; + +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::{Emitter, Manager, Runtime}; + +const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command"; + +fn desktop_menu(handle: &tauri::AppHandle) -> tauri::Result> { + Menu::with_items( + handle, + &[ + &Submenu::with_items( + handle, + "文件", + true, + &[ + &MenuItem::with_id( + handle, + "ctms.desktop.commandPalette", + "打开命令面板", + true, + Some("CmdOrCtrl+K"), + )?, + &MenuItem::with_id( + handle, + "ctms.desktop.serverSettings", + "服务器设置", + true, + None::<&str>, + )?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::close_window(handle, None)?, + &PredefinedMenuItem::quit(handle, None)?, + ], + )?, + &Submenu::with_items( + handle, + "编辑", + true, + &[ + &PredefinedMenuItem::undo(handle, None)?, + &PredefinedMenuItem::redo(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::cut(handle, None)?, + &PredefinedMenuItem::copy(handle, None)?, + &PredefinedMenuItem::paste(handle, None)?, + &PredefinedMenuItem::select_all(handle, None)?, + ], + )?, + &Submenu::with_items( + handle, + "视图", + true, + &[ + &MenuItem::with_id( + handle, + "ctms.desktop.refresh", + "刷新当前视图", + true, + Some("CmdOrCtrl+R"), + )?, + &PredefinedMenuItem::fullscreen(handle, None)?, + ], + )?, + &Submenu::with_items( + handle, + "导航", + true, + &[ + &MenuItem::with_id( + handle, + "ctms.desktop.back", + "返回", + true, + Some("CmdOrCtrl+["), + )?, + &MenuItem::with_id( + handle, + "ctms.desktop.forward", + "前进", + true, + Some("CmdOrCtrl+]"), + )?, + ], + )?, + &Submenu::with_items( + handle, + "窗口", + true, + &[ + &PredefinedMenuItem::minimize(handle, None)?, + &PredefinedMenuItem::maximize(handle, None)?, + &PredefinedMenuItem::close_window(handle, None)?, + ], + )?, + &Submenu::with_items( + handle, + "帮助", + true, + &[ + &MenuItem::with_id( + handle, + "ctms.desktop.preferences", + "桌面偏好", + true, + Some("CmdOrCtrl+,"), + )?, + ], + )?, + ], + ) +} + +fn emit_menu_command(app: &tauri::AppHandle, command: &str) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit(DESKTOP_MENU_COMMAND_EVENT, command); + } +} + +pub fn run() { + tauri::Builder::default() + .menu(desktop_menu) + .on_menu_event(|app, event| { + let command = event.id().as_ref(); + if command.starts_with("ctms.desktop.") { + emit_menu_command(app, command); + } + }) + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .manage(updates::PendingUpdate::default()) + .invoke_handler(tauri::generate_handler![ + credentials::credential_get, + credentials::credential_set, + credentials::credential_delete, + updates::desktop_update_check, + updates::desktop_update_install, + ]) + .run(tauri::generate_context!()) + .expect("error while running CTMS desktop application"); +} diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs new file mode 100644 index 00000000..8aef4410 --- /dev/null +++ b/frontend/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + ctms_desktop_lib::run() +} diff --git a/frontend/src-tauri/src/updates.rs b/frontend/src-tauri/src/updates.rs new file mode 100644 index 00000000..ce9302b7 --- /dev/null +++ b/frontend/src-tauri/src/updates.rs @@ -0,0 +1,116 @@ +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{AppHandle, State}; +use tauri_plugin_updater::{Update, UpdaterExt}; +use url::Url; + +#[derive(Default)] +pub struct PendingUpdate(pub Mutex>); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopUpdateMetadata { + version: String, + current_version: String, + notes: Option, + date: Option, +} + +fn normalized_origin(raw: &str) -> Result { + let parsed = Url::parse(raw).map_err(|_| "invalid server origin".to_string())?; + let scheme = parsed.scheme(); + if parsed.username() != "" || parsed.password().is_some() { + return Err("server origin must not include credentials".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "server origin must include a host".to_string())?; + let localhost = matches!(host, "localhost" | "127.0.0.1" | "::1"); + if scheme != "https" && !(scheme == "http" && localhost) { + return Err("desktop updates require HTTPS outside localhost".to_string()); + } + let port = parsed.port().map(|value| format!(":{value}")).unwrap_or_default(); + Url::parse(&format!("{scheme}://{host}{port}/")).map_err(|_| "invalid normalized origin".to_string()) +} + +fn update_endpoint(server_origin: &str) -> Result { + let origin = normalized_origin(server_origin)?; + origin + .join("desktop-updates/stable/latest.json") + .map_err(|_| "invalid update endpoint".to_string()) +} + +#[tauri::command] +pub async fn desktop_update_check( + app: AppHandle, + pending_update: State<'_, PendingUpdate>, + server_origin: String, +) -> Result, String> { + let endpoint = update_endpoint(&server_origin)?; + let update = app + .updater_builder() + .endpoints(vec![endpoint]) + .map_err(|err| err.to_string())? + .build() + .map_err(|err| err.to_string())? + .check() + .await + .map_err(|err| err.to_string())?; + + let metadata = update.as_ref().map(|update| DesktopUpdateMetadata { + version: update.version.clone(), + current_version: update.current_version.clone(), + notes: update.body.clone(), + date: update.date.map(|value| value.to_string()), + }); + + let mut guard = pending_update + .0 + .lock() + .map_err(|_| "pending update lock poisoned".to_string())?; + *guard = update; + Ok(metadata) +} + +#[tauri::command] +pub async fn desktop_update_install( + app: AppHandle, + pending_update: State<'_, PendingUpdate>, +) -> Result<(), String> { + let update = { + let mut guard = pending_update + .0 + .lock() + .map_err(|_| "pending update lock poisoned".to_string())?; + guard + .take() + .ok_or_else(|| "there is no pending update".to_string())? + }; + + update + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|err| err.to_string())?; + app.restart(); +} + +#[cfg(test)] +mod tests { + use super::update_endpoint; + + #[test] + fn update_endpoint_uses_fixed_https_path() { + let endpoint = update_endpoint("https://ctms.example.com/app/").unwrap(); + assert_eq!( + endpoint.as_str(), + "https://ctms.example.com/desktop-updates/stable/latest.json" + ); + } + + #[test] + fn update_endpoint_allows_localhost_http_only() { + assert!(update_endpoint("http://localhost:8888").is_ok()); + assert!(update_endpoint("http://ctms.example.com").is_err()); + } +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json new file mode 100644 index 00000000..238281de --- /dev/null +++ b/frontend/src-tauri/tauri.conf.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "CTMS", + "version": "0.1.0", + "identifier": "cn.huapont.ctms.desktop", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:5173", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "CTMS", + "width": 1440, + "height": 900, + "minWidth": 1180, + "minHeight": 760 + } + ], + "security": { + "csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'" + } + }, + "bundle": { + "active": true, + "targets": ["app", "dmg"], + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo=" + } + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c3dcc97f..da554f13 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,9 +8,17 @@ + + diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue new file mode 100644 index 00000000..122612b3 --- /dev/null +++ b/frontend/src/components/DesktopLayout.vue @@ -0,0 +1,1667 @@ + + + + + diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts new file mode 100644 index 00000000..0c946954 --- /dev/null +++ b/frontend/src/components/Layout.desktop.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8"); +const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8"); +const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8"); +const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8"); + +describe("desktop layout shell", () => { + it("routes desktop and web shells through the runtime flag", () => { + const source = readLayoutSource(); + + expect(source).toContain("const isDesktop = isTauriRuntime()"); + expect(source).toContain(""); + expect(source).toContain(""); + }); + + it("uses route-only desktop preference storage", () => { + const source = readDesktopLayoutSource(); + + expect(source).toContain("readDesktopRecentRoutes"); + expect(source).toContain("readDesktopFavoriteRoutes"); + expect(source).toContain("recordDesktopRecentRoute"); + expect(source).toContain("currentDesktopRoutePreference"); + }); + + it("shares active route mapping between web and desktop shells", () => { + const webLayout = readWebLayoutSource(); + const desktopLayout = readDesktopLayoutSource(); + const navigation = readNavigationSource(); + + expect(webLayout).toContain("getActiveLayoutPath(route.path)"); + expect(desktopLayout).toContain("getActiveLayoutPath(route.path)"); + expect(navigation).toContain("export const getActiveLayoutPath"); + }); +}); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 5bc74709..286f510d 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -1,2042 +1,12 @@ - - diff --git a/frontend/src/components/PermissionAccessLogs.vue b/frontend/src/components/PermissionAccessLogs.vue index 31663d19..24ce31ac 100644 --- a/frontend/src/components/PermissionAccessLogs.vue +++ b/frontend/src/components/PermissionAccessLogs.vue @@ -154,6 +154,7 @@ import type { } from "@/types/api"; import { Search as SearchIcon } from "@element-plus/icons-vue"; import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta"; +import { saveFile } from "@/runtime"; const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), { showSecurityLog: false, @@ -311,7 +312,8 @@ const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => { const account = row.account_label || "未知账号"; const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status; const userAgent = row.user_agent || "-"; - return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`; + const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown"; + return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`; }; const terminalLogRows = computed(() => @@ -515,14 +517,9 @@ const openSecurityLogDialog = () => { scrollSecurityTerminalToBottom(); }; -const downloadLogFile = (fileName: string, lines: string[]) => { +const downloadLogFile = async (fileName: string, lines: string[]) => { const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - link.click(); - URL.revokeObjectURL(url); + await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob }); }; const downloadInterfaceLog = () => { diff --git a/frontend/src/components/SessionTimeoutPrompt.vue b/frontend/src/components/SessionTimeoutPrompt.vue index 050c3146..6b09cac1 100644 --- a/frontend/src/components/SessionTimeoutPrompt.vue +++ b/frontend/src/components/SessionTimeoutPrompt.vue @@ -70,7 +70,7 @@ const continueSession = () => { }; const logoutNow = () => { - forceLogout(LOGOUT_REASON_MANUAL); + void forceLogout(LOGOUT_REASON_MANUAL); }; watch( diff --git a/frontend/src/components/ThreadComposer.vue b/frontend/src/components/ThreadComposer.vue index c69756b8..6203651d 100644 --- a/frontend/src/components/ThreadComposer.vue +++ b/frontend/src/components/ThreadComposer.vue @@ -11,9 +11,12 @@
{{ TEXT.common.labels.attachments }}
- + {{ TEXT.common.actions.upload }} + + {{ TEXT.common.actions.upload }} +
{{ TEXT.common.actions.cancel }} @@ -26,6 +29,7 @@ import { computed } from "vue"; import type { UploadUserFile } from "element-plus"; import { displayDateTime, displayUser } from "../utils/display"; +import { clientRuntime, pickFiles } from "../runtime"; import { TEXT } from "../locales"; const props = defineProps<{ @@ -46,6 +50,7 @@ const emit = defineEmits<{ (e: "cancel"): void; (e: "clear-quote"): void; }>(); +const nativeFiles = clientRuntime.capabilities().nativeFiles; const contentProxy = computed({ get: () => props.modelValue, @@ -58,6 +63,21 @@ const fileListProxy = computed({ }); const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback); + +const pickNativeAttachments = async () => { + const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments }); + const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`)); + const additions: UploadUserFile[] = files + .filter((file) => !existing.has(`${file.name}:${file.size}`)) + .map((file) => ({ + name: file.name, + size: file.size, + raw: file as any, + status: "ready", + uid: Date.now() + Math.floor(Math.random() * 100000), + })); + emit("update:fileList", [...props.fileList, ...additions]); +}; diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 46f6ca42..3b1a564a 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -5,6 +5,7 @@ {{ headerTitle }}
{{ TEXT.common.actions.upload }} + + {{ TEXT.common.actions.upload }} +
@@ -41,6 +45,7 @@ {{ TEXT.common.labels.preview }} {{ TEXT.common.actions.download }} + 打开
+
+
{{ group.label }}
+
+ + {{ group.uploadText || "点击上传文件" }} +
+
("other"); const previewTitle = ref(TEXT.common.labels.preview); const previewError = ref(""); const previewLoading = ref(false); +const nativeFiles = clientRuntime.capabilities().nativeFiles; const headerTitle = computed(() => props.title || TEXT.common.labels.attachments); const displayMode = computed(() => props.mode || "table"); const maxSize = computed(() => props.maxSizeMb ?? 50); @@ -240,10 +263,9 @@ const validateFile = (file: File) => { const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`; -const queuePendingUpload = (fileType: string, uploadFile: any) => { +const queuePendingFile = (fileType: string, file: File) => { const group = uploadGroups.value.find((item) => item.key === fileType); if (!group || !canUploadGroup(group)) return; - const file = uploadFile?.raw as File | undefined; if (!file || !validateFile(file)) return; const next = pendingMap[fileType] || []; if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) { @@ -251,6 +273,17 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => { } }; +const queuePendingUpload = (fileType: string, uploadFile: any) => { + const file = uploadFile?.raw as File | undefined; + if (file) queuePendingFile(fileType, file); +}; + +const pickPendingNative = async (group: UploadGroup) => { + if (!canUploadGroup(group)) return; + const selected = await pickFiles({ multiple: true, title: group.label }); + selected.forEach((file) => queuePendingFile(group.key, file)); +}; + const removePendingUpload = (fileType: string, item: PendingUploadItem) => { pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file)); }; @@ -317,6 +350,11 @@ const uploadImmediate = async (options: any) => { } }; +const pickImmediateNative = async () => { + const [file] = await pickFiles({ multiple: false, title: headerTitle.value }); + if (file) await uploadImmediate({ file }); +}; + const load = async () => { if (!props.studyId || !props.entityId) return; loading.value = true; @@ -352,25 +390,35 @@ const uploaderLabel = (row: any) => { return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback; }; -const getDownloadUrl = (row: any) => { - const token = localStorage.getItem("ctms_token"); - return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`); +const fetchAttachmentBlob = async (row: any): Promise => { + const response = await downloadAttachment(row.id); + return new Blob([response.data], { + type: response.headers?.["content-type"] || row?.content_type || "application/octet-stream", + }); }; -const getPreviewUrl = (row: any) => { - const token = localStorage.getItem("ctms_token"); - return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`; +const download = async (row: any) => { + try { + await saveFile({ + suggestedName: row?.filename || "download", + mimeType: row?.content_type, + data: await fetchAttachmentBlob(row), + }); + } catch { + ElMessage.error(TEXT.common.messages.downloadFailed); + } }; -const download = (row: any) => { - const url = getDownloadUrl(row); - const link = document.createElement("a"); - link.href = url; - link.download = row?.filename || "download"; - link.rel = "noopener"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); +const openExternally = async (row: any) => { + try { + await openFile({ + suggestedName: row?.filename || "attachment", + mimeType: row?.content_type, + data: await fetchAttachmentBlob(row), + }); + } catch { + ElMessage.error(TEXT.common.messages.previewNotSupported); + } }; const getFileName = (row: any) => (row?.filename || "").toLowerCase(); @@ -386,37 +434,29 @@ const detectPreviewType = (row: any) => { return "other"; }; -const previewImage = async (downloadUrl: string) => { +const previewImage = async (row: any) => { previewLoading.value = true; try { - const token = localStorage.getItem("ctms_token"); - const headers: Record = {}; - if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(downloadUrl, { headers }); - if (!response.ok) throw new Error("preview failed"); - const blob = await response.blob(); + const blob = await fetchAttachmentBlob(row); if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value); previewObjectUrl.value = URL.createObjectURL(blob); previewUrl.value = previewObjectUrl.value; } catch { - previewUrl.value = downloadUrl; + previewError.value = TEXT.common.messages.previewNotSupported; } finally { previewLoading.value = false; } }; -const preview = (row: any) => { +const preview = async (row: any) => { previewError.value = ""; previewType.value = detectPreviewType(row); previewTitle.value = row?.filename || TEXT.common.labels.preview; - const downloadUrl = getDownloadUrl(row); - if (previewType.value === "image") { + if (previewType.value === "image" || previewType.value === "pdf") { previewUrl.value = ""; - previewImage(downloadUrl); - } else if (previewType.value === "pdf") { - previewUrl.value = getPreviewUrl(row); + await previewImage(row); } else { - previewUrl.value = downloadUrl; + previewUrl.value = ""; } if (previewType.value === "other") { previewError.value = TEXT.common.messages.previewNotSupported; diff --git a/frontend/src/components/layout/navigation.ts b/frontend/src/components/layout/navigation.ts new file mode 100644 index 00000000..ec072c4d --- /dev/null +++ b/frontend/src/components/layout/navigation.ts @@ -0,0 +1,298 @@ +import { TEXT } from "../../locales"; + +export type LayoutNavigationIcon = + | "audit" + | "calendar" + | "check" + | "coin" + | "dashboard" + | "document" + | "flag" + | "folder" + | "key" + | "monitor" + | "notebook" + | "project" + | "settings" + | "subject" + | "users"; + +export type LayoutNavigationItem = { + label: string; + path: string; + group: string; + icon: LayoutNavigationIcon; + keywords?: string[]; + children?: LayoutNavigationItem[]; +}; + +export const getActiveLayoutPath = (path: string) => { + if (path.startsWith("/project/milestones")) return "/project/milestones"; + if (path.startsWith("/project/")) return "/project/overview"; + if (path.startsWith("/fees/contracts")) return "/fees/contracts"; + if (path.startsWith("/drug/shipments")) return "/drug/shipments"; + if (path.startsWith("/materials/equipment")) return "/materials/equipment"; + if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions"; + if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics"; + if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) { + return "/startup/meeting-auth"; + } + if (path.startsWith("/subjects")) return "/subjects"; + if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae"; + if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd"; + if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits"; + if (path.startsWith("/risk-issues")) return "/risk-issues/sae"; + if (path.startsWith("/etmf")) return "/etmf"; + if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult"; + if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions"; + if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files"; + if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files"; + if (path.startsWith("/projects/")) return "/admin/projects"; + if (path.startsWith("/admin/projects/")) return "/admin/projects"; + if (path.startsWith("/admin/system-monitoring") || path.startsWith("/admin/permission-monitoring")) return "/admin/system-monitoring"; + if (path.startsWith("/admin/permissions/")) return path; + return path; +}; + +export const buildAdminNavigationItems = (options: { + hasUser: boolean; + isAdmin: boolean; + canAccessAdminPermissions: boolean; +}): LayoutNavigationItem[] => { + if (!options.hasUser) return []; + + const items: LayoutNavigationItem[] = []; + if (options.isAdmin) { + items.push({ + label: TEXT.menu.accountManagement, + path: "/admin/users", + group: TEXT.menu.admin, + icon: "users", + keywords: ["user", "account"], + }); + } + items.push({ + label: TEXT.menu.projectManagement, + path: "/admin/projects", + group: TEXT.menu.admin, + icon: "project", + keywords: ["project"], + }); + if (options.isAdmin) { + items.push( + { + label: TEXT.menu.auditLogs, + path: "/admin/audit-logs", + group: TEXT.menu.admin, + icon: "audit", + keywords: ["audit"], + }, + { + label: TEXT.menu.systemMonitoring, + path: "/admin/system-monitoring", + group: TEXT.menu.admin, + icon: "monitor", + keywords: ["monitoring"], + }, + { + label: "邮件服务", + path: "/admin/email-settings", + group: TEXT.menu.admin, + icon: "settings", + keywords: ["email"], + }, + ); + } + if (options.canAccessAdminPermissions) { + items.push({ + label: TEXT.menu.permissionManagement, + path: "/admin/permissions/system", + group: TEXT.menu.admin, + icon: "key", + keywords: ["permission"], + children: [ + { + label: "系统级权限", + path: "/admin/permissions/system", + group: TEXT.menu.permissionManagement, + icon: "key", + keywords: ["permission", "system"], + }, + { + label: "项目权限配置", + path: "/admin/permissions/project", + group: TEXT.menu.permissionManagement, + icon: "key", + keywords: ["permission", "project"], + }, + ], + }); + } + return items; +}; + +export const buildProjectNavigationItems = (options: { + hasCurrentStudy: boolean; + hasAnyProjectModuleAccess: boolean; + canAccessProjectPath: (path: string) => boolean; +}): LayoutNavigationItem[] => { + if (!options.hasCurrentStudy || !options.hasAnyProjectModuleAccess) return []; + + const items: LayoutNavigationItem[] = [ + { + label: TEXT.menu.projectOverview, + path: "/project/overview", + group: TEXT.menu.currentProject, + icon: "dashboard", + keywords: ["overview"], + }, + { + label: TEXT.menu.projectMilestones, + path: "/project/milestones", + group: TEXT.menu.currentProject, + icon: "calendar", + keywords: ["milestone"], + }, + { + label: TEXT.menu.feeContracts, + path: "/fees/contracts", + group: TEXT.menu.currentProject, + icon: "coin", + keywords: ["fee", "contract"], + }, + { + label: TEXT.menu.materialManagement, + path: "/drug/shipments", + group: TEXT.menu.currentProject, + icon: "folder", + children: [ + { + label: TEXT.menu.drugShipments, + path: "/drug/shipments", + group: TEXT.menu.materialManagement, + icon: "folder", + keywords: ["drug", "shipment"], + }, + { + label: TEXT.menu.materialEquipment, + path: "/materials/equipment", + group: TEXT.menu.materialManagement, + icon: "folder", + keywords: ["material", "equipment"], + }, + ], + }, + { + label: TEXT.menu.fileVersionManagement, + path: "/file-versions", + group: TEXT.menu.currentProject, + icon: "document", + keywords: ["document", "file", "version"], + }, + { + label: TEXT.menu.startupFeasibilityEthics, + path: "/startup/feasibility-ethics", + group: TEXT.menu.currentProject, + icon: "calendar", + keywords: ["startup", "ethics"], + }, + { + label: TEXT.menu.startupMeetingAuth, + path: "/startup/meeting-auth", + group: TEXT.menu.currentProject, + icon: "check", + keywords: ["meeting", "training"], + }, + { + label: TEXT.menu.subjects, + path: "/subjects", + group: TEXT.menu.currentProject, + icon: "subject", + keywords: ["subject"], + }, + { + label: TEXT.menu.riskIssues, + path: "/risk-issues/sae", + group: TEXT.menu.currentProject, + icon: "flag", + children: [ + { + label: TEXT.menu.riskIssueSae, + path: "/risk-issues/sae", + group: TEXT.menu.riskIssues, + icon: "flag", + keywords: ["sae", "risk"], + }, + { + label: TEXT.menu.riskIssuePd, + path: "/risk-issues/pd", + group: TEXT.menu.riskIssues, + icon: "flag", + keywords: ["pd", "risk"], + }, + { + label: TEXT.menu.riskIssueMonitoringVisits, + path: "/risk-issues/monitoring-visits", + group: TEXT.menu.riskIssues, + icon: "flag", + keywords: ["monitoring", "visit"], + }, + ], + }, + { + label: TEXT.menu.etmf, + path: "/etmf", + group: TEXT.menu.currentProject, + icon: "folder", + keywords: ["etmf"], + }, + { + label: TEXT.menu.sharedLibrary, + path: "/knowledge/medical-consult", + group: TEXT.menu.currentProject, + icon: "notebook", + children: [ + { + label: TEXT.menu.knowledgeMedicalConsult, + path: "/knowledge/medical-consult", + group: TEXT.menu.sharedLibrary, + icon: "notebook", + keywords: ["knowledge", "medical"], + }, + { + label: TEXT.menu.knowledgeNotes, + path: "/knowledge/precautions", + group: TEXT.menu.sharedLibrary, + icon: "notebook", + keywords: ["knowledge", "note"], + }, + { + label: TEXT.menu.knowledgeSupportFiles, + path: "/knowledge/support-files", + group: TEXT.menu.sharedLibrary, + icon: "notebook", + keywords: ["knowledge", "support"], + }, + { + label: TEXT.menu.knowledgeInstructionFiles, + path: "/knowledge/instruction-files", + group: TEXT.menu.sharedLibrary, + icon: "notebook", + keywords: ["knowledge", "instruction"], + }, + ], + }, + ]; + + return items + .map((item) => { + if (!item.children) return options.canAccessProjectPath(item.path) ? item : null; + const children = item.children.filter((child) => options.canAccessProjectPath(child.path)); + if (!children.length) return null; + return { ...item, path: children[0].path, children }; + }) + .filter(Boolean) as LayoutNavigationItem[]; +}; + +export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) => + items.flatMap((item) => [item, ...(item.children || [])]); diff --git a/frontend/src/composables/useDesktopRefresh.ts b/frontend/src/composables/useDesktopRefresh.ts new file mode 100644 index 00000000..32c7d79f --- /dev/null +++ b/frontend/src/composables/useDesktopRefresh.ts @@ -0,0 +1,23 @@ +export const DESKTOP_REFRESH_CURRENT_VIEW_EVENT = "ctms:desktop-refresh-current-view"; + +export interface DesktopRefreshEventDetail { + handled: boolean; +} + +export const dispatchDesktopRefreshCurrentView = (): boolean => { + if (typeof window === "undefined") return false; + const detail: DesktopRefreshEventDetail = { handled: false }; + window.dispatchEvent(new CustomEvent(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, { detail })); + return detail.handled; +}; + +export const onDesktopRefreshCurrentView = (handler: () => void | Promise): (() => void) => { + if (typeof window === "undefined") return () => {}; + const listener = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail) detail.handled = true; + void handler(); + }; + window.addEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener); + return () => window.removeEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener); +}; diff --git a/frontend/src/composables/useDesktopShortcuts.test.ts b/frontend/src/composables/useDesktopShortcuts.test.ts new file mode 100644 index 00000000..21797cf5 --- /dev/null +++ b/frontend/src/composables/useDesktopShortcuts.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { isEditableShortcutTarget } from "./useDesktopShortcuts"; + +describe("desktop shortcut target detection", () => { + it("does not hijack form controls", () => { + const input = document.createElement("input"); + const textarea = document.createElement("textarea"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + + expect(isEditableShortcutTarget(input)).toBe(true); + expect(isEditableShortcutTarget(textarea)).toBe(true); + expect(isEditableShortcutTarget(editable)).toBe(true); + }); + + it("allows shortcuts outside editable areas", () => { + const button = document.createElement("button"); + + expect(isEditableShortcutTarget(button)).toBe(false); + }); +}); diff --git a/frontend/src/composables/useDesktopShortcuts.ts b/frontend/src/composables/useDesktopShortcuts.ts new file mode 100644 index 00000000..28d11e2b --- /dev/null +++ b/frontend/src/composables/useDesktopShortcuts.ts @@ -0,0 +1,58 @@ +import { onBeforeUnmount, onMounted } from "vue"; + +export interface DesktopShortcutHandlers { + openCommandPalette: () => void; + closeActiveLayer?: () => boolean; + navigateBack?: () => void; + navigateForward?: () => void; + refreshCurrentView?: () => void; +} + +export const isEditableShortcutTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) return false; + const tag = target.tagName.toLowerCase(); + if (["input", "textarea", "select"].includes(tag)) return true; + if ((target as HTMLElement).isContentEditable) return true; + if (target.getAttribute("contenteditable") === "true") return true; + return Boolean(target.closest('[contenteditable="true"], .el-input, .el-textarea, .el-select')); +}; + +export const useDesktopShortcuts = (enabled: () => boolean, handlers: DesktopShortcutHandlers) => { + const onKeydown = (event: KeyboardEvent) => { + if (!enabled() || isEditableShortcutTarget(event.target)) return; + const modifier = event.metaKey || event.ctrlKey; + const key = event.key.toLowerCase(); + + if (!modifier && key === "escape") { + if (handlers.closeActiveLayer?.()) event.preventDefault(); + return; + } + + if (!modifier) return; + + if (key === "k") { + event.preventDefault(); + handlers.openCommandPalette(); + return; + } + if (key === "[") { + event.preventDefault(); + handlers.navigateBack?.(); + return; + } + if (key === "]") { + event.preventDefault(); + handlers.navigateForward?.(); + return; + } + if (key === "r") { + event.preventDefault(); + handlers.refreshCurrentView?.(); + } + }; + + onMounted(() => window.addEventListener("keydown", onKeydown)); + onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown)); + + return { onKeydown }; +}; diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index fc812394..6b14a58a 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -1,5 +1,14 @@ /// +interface ImportMetaEnv { + readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local"; + readonly VITE_BUILD_COMMIT?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare module "*.vue" { import type { DefineComponent } from "vue"; const component: DefineComponent<{}, {}, any>; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 0f385dd9..d6b3d21d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -12,22 +12,33 @@ import App from "./App.vue"; import router from "./router"; import { getToken } from "./utils/auth"; import { useStudyStore } from "./store/study"; +import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime"; -const app = createApp(App); -const pinia = createPinia(); -app.use(pinia); -dayjs.locale("zh-cn"); -app.use(ElementPlus, { locale: zhCn }); +const bootstrap = async () => { + await initializeSecureSessionStorage().catch((error) => { + console.error("Secure session storage initialization failed", error); + }); + await cleanupTemporaryFiles().catch((error) => { + console.warn("Desktop temporary file cleanup failed", error); + }); + const app = createApp(App); + const pinia = createPinia(); + app.use(pinia); + dayjs.locale("zh-cn"); + app.use(ElementPlus, { locale: zhCn }); -// 初始化项目上下文 -const studyStore = useStudyStore(); -studyStore.loadCurrentStudy(); -if (getToken()) { - await studyStore.rehydrateStudyForLastUser(); - await studyStore.loadCurrentStudyPermissions().catch(() => {}); -} + // 初始化项目上下文 + const studyStore = useStudyStore(); + studyStore.loadCurrentStudy(); + if (getToken() && !shouldRequireDesktopServerUrl()) { + await studyStore.rehydrateStudyForLastUser(); + await studyStore.loadCurrentStudyPermissions().catch(() => {}); + } -app.use(router); -await router.isReady(); + app.use(router); + await router.isReady(); -app.mount("#app"); + app.mount("#app"); +}; + +bootstrap(); diff --git a/frontend/src/router/desktopGuard.ts b/frontend/src/router/desktopGuard.ts new file mode 100644 index 00000000..7dae6761 --- /dev/null +++ b/frontend/src/router/desktopGuard.ts @@ -0,0 +1,19 @@ +import type { RouteLocationNormalized } from "vue-router"; +import { hasDesktopServerUrl, isTauriRuntime, shouldRequireDesktopServerUrl } from "../runtime"; + +const DESKTOP_SETTINGS_PATH = "/desktop/server-settings"; + +export const resolveDesktopRouteRedirect = (to: RouteLocationNormalized): string | null => { + const isSettingsRoute = to.path === DESKTOP_SETTINGS_PATH; + + if (!isTauriRuntime()) { + return isSettingsRoute ? "/login" : null; + } + + if (shouldRequireDesktopServerUrl()) { + return isSettingsRoute ? null : DESKTOP_SETTINGS_PATH; + } + + if (isSettingsRoute || hasDesktopServerUrl()) return null; + return DESKTOP_SETTINGS_PATH; +}; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 06dbd3b6..278148c9 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -10,6 +10,7 @@ import Layout from "../components/Layout.vue"; import Login from "../views/Login.vue"; import Register from "../views/Register.vue"; import ForgotPassword from "../views/ForgotPassword.vue"; +import DesktopServerSettings from "../views/DesktopServerSettings.vue"; import StudyHome from "../views/StudyHome.vue"; import FaqDetail from "../views/FaqDetail.vue"; import AuditLogs from "../views/admin/AuditLogs.vue"; @@ -55,6 +56,7 @@ import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue"; import SubjectForm from "../views/subjects/SubjectForm.vue"; import SubjectDetail from "../views/subjects/SubjectDetail.vue"; import { TEXT } from "../locales"; +import { resolveDesktopRouteRedirect } from "./desktopGuard"; const SYSTEM_PERMISSION_READ = "system:permissions:read"; const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config"; @@ -78,6 +80,12 @@ const routes: RouteRecordRaw[] = [ component: ForgotPassword, meta: { public: true, title: TEXT.modules.auth.forgotTitle }, }, + { + path: "/desktop/server-settings", + name: "DesktopServerSettings", + component: DesktopServerSettings, + meta: { public: true, title: "服务器设置" }, + }, { path: "/", component: Layout, @@ -504,6 +512,12 @@ const ensureProjectPermissionAccess = async ( }; router.beforeEach(async (to, _from, next) => { + const desktopRedirect = resolveDesktopRouteRedirect(to); + if (desktopRedirect) { + next({ path: desktopRedirect }); + return; + } + const auth = useAuthStore(); const studyStore = useStudyStore(); const getToken = () => auth.token; @@ -513,7 +527,7 @@ router.beforeEach(async (to, _from, next) => { await auth.fetchMe(); } catch { // 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面 - auth.logout(); + await auth.logout(); next({ path: "/login" }); return; } diff --git a/frontend/src/runtime/apiBaseUrl.ts b/frontend/src/runtime/apiBaseUrl.ts new file mode 100644 index 00000000..b1112012 --- /dev/null +++ b/frontend/src/runtime/apiBaseUrl.ts @@ -0,0 +1,7 @@ +import { getDesktopServerUrl } from "./desktopServerConfig"; +import { isTauriRuntime } from "./platform"; + +export const resolveApiBaseUrl = (): string => { + if (!isTauriRuntime()) return "/"; + return getDesktopServerUrl() || "/"; +}; diff --git a/frontend/src/runtime/appMetadata.test.ts b/frontend/src/runtime/appMetadata.test.ts new file mode 100644 index 00000000..b57af8ca --- /dev/null +++ b/frontend/src/runtime/appMetadata.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import packageInfo from "../../package.json"; +import { clientRuntime } from "./clientRuntime"; +import { getAppMetadata } from "./appMetadata"; + +afterEach(() => { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); + vi.unstubAllEnvs(); +}); + +describe("client runtime", () => { + it("reports a web runtime with desktop capabilities disabled", () => { + expect(getAppMetadata()).toMatchObject({ + version: packageInfo.version, + commit: "local", + channel: "local", + clientType: "web", + platform: "web", + }); + expect(clientRuntime.capabilities()).toEqual({ + serverConfiguration: false, + nativeFiles: false, + systemNotifications: false, + secureSessionStorage: false, + automaticUpdates: false, + }); + }); + + it("exposes only the implemented desktop capability", () => { + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); + + expect(getAppMetadata().clientType).toBe("desktop"); + expect(clientRuntime.capabilities().serverConfiguration).toBe(true); + expect(clientRuntime.capabilities().secureSessionStorage).toBe(false); + }); + + it("accepts release build metadata only from the expected CI values", () => { + vi.stubEnv("VITE_BUILD_CHANNEL", "release"); + vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567"); + + expect(getAppMetadata()).toMatchObject({ + channel: "release", + commit: "0123456789abcdef0123456789abcdef01234567", + }); + }); + + it("does not expose arbitrary build metadata strings", () => { + vi.stubEnv("VITE_BUILD_CHANNEL", "feature/demo"); + vi.stubEnv("VITE_BUILD_COMMIT", "token=secret"); + + expect(getAppMetadata()).toMatchObject({ + channel: "local", + commit: "local", + }); + }); +}); diff --git a/frontend/src/runtime/appMetadata.ts b/frontend/src/runtime/appMetadata.ts new file mode 100644 index 00000000..fe17f89f --- /dev/null +++ b/frontend/src/runtime/appMetadata.ts @@ -0,0 +1,41 @@ +import packageInfo from "../../package.json"; +import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform"; + +export type ClientType = "web" | "desktop"; +export type BuildChannel = "dev" | "main" | "release" | "local"; + +export interface AppMetadata { + version: string; + commit: string; + channel: BuildChannel; + clientType: ClientType; + platform: RuntimePlatform; +} + +const BUILD_CHANNELS = new Set(["dev", "main", "release", "local"]); +const BUILD_COMMIT_PATTERN = /^[0-9a-f]{7,40}$/i; + +const resolveBuildChannel = (value: string | undefined): BuildChannel => + value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local"; + +const resolveBuildCommit = (value: string | undefined): string => + value && BUILD_COMMIT_PATTERN.test(value) ? value : "local"; + +export const getAppMetadata = (): AppMetadata => ({ + version: packageInfo.version, + commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT), + channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL), + clientType: isTauriRuntime() ? "desktop" : "web", + platform: getRuntimePlatform(), +}); + +export const getAppMetadataHeaders = (): Record => { + const metadata = getAppMetadata(); + return { + "X-CTMS-Client-Type": metadata.clientType, + "X-CTMS-Client-Version": metadata.version, + "X-CTMS-Client-Platform": metadata.platform, + "X-CTMS-Build-Channel": metadata.channel, + "X-CTMS-Build-Commit": metadata.commit, + }; +}; diff --git a/frontend/src/runtime/clientRuntime.ts b/frontend/src/runtime/clientRuntime.ts new file mode 100644 index 00000000..2b8bdf33 --- /dev/null +++ b/frontend/src/runtime/clientRuntime.ts @@ -0,0 +1,57 @@ +import { resolveApiBaseUrl } from "./apiBaseUrl"; +import { getAppMetadata } from "./appMetadata"; +import * as files from "./files"; +import * as notifications from "./notifications"; +import * as updates from "./updates"; +import { isTauriRuntime } from "./platform"; +import { + clearSessionToken, + getSessionToken, + initializeSecureSessionStorage, + isSecureSessionStorageAvailable, + setSessionToken, +} from "./secureSessionStorage"; + +export interface RuntimeCapabilities { + serverConfiguration: boolean; + nativeFiles: boolean; + systemNotifications: boolean; + secureSessionStorage: boolean; + automaticUpdates: boolean; +} + +export interface ClientRuntime { + apiBaseUrl(): string; + metadata: typeof getAppMetadata; + capabilities(): RuntimeCapabilities; + secureSessionStorage: { + initialize: typeof initializeSecureSessionStorage; + get: typeof getSessionToken; + set: typeof setSessionToken; + clear: typeof clearSessionToken; + }; + files: typeof files; + notifications: typeof notifications; + updates: typeof updates; +} + +export const clientRuntime: ClientRuntime = { + apiBaseUrl: resolveApiBaseUrl, + metadata: getAppMetadata, + secureSessionStorage: { + initialize: initializeSecureSessionStorage, + get: getSessionToken, + set: setSessionToken, + clear: clearSessionToken, + }, + files, + notifications, + updates, + capabilities: () => ({ + serverConfiguration: isTauriRuntime(), + nativeFiles: isTauriRuntime(), + systemNotifications: isTauriRuntime(), + secureSessionStorage: isTauriRuntime() && isSecureSessionStorageAvailable(), + automaticUpdates: updates.isDesktopUpdaterAvailable(), + }), +}; diff --git a/frontend/src/runtime/desktopMenu.ts b/frontend/src/runtime/desktopMenu.ts new file mode 100644 index 00000000..bee6e562 --- /dev/null +++ b/frontend/src/runtime/desktopMenu.ts @@ -0,0 +1,23 @@ +import { isTauriRuntime } from "./platform"; + +export const DESKTOP_MENU_COMMAND_EVENT = "ctms:desktop-menu-command"; + +export type DesktopMenuCommand = + | "ctms.desktop.commandPalette" + | "ctms.desktop.preferences" + | "ctms.desktop.serverSettings" + | "ctms.desktop.refresh" + | "ctms.desktop.back" + | "ctms.desktop.forward"; + +type Unlisten = () => void; + +export const listenDesktopMenuCommand = async ( + handler: (command: DesktopMenuCommand | string) => void, +): Promise => { + if (!isTauriRuntime()) return () => {}; + const { listen } = await import("@tauri-apps/api/event"); + return listen(DESKTOP_MENU_COMMAND_EVENT, (event) => { + if (typeof event.payload === "string") handler(event.payload); + }); +}; diff --git a/frontend/src/runtime/desktopServerConfig.test.ts b/frontend/src/runtime/desktopServerConfig.test.ts new file mode 100644 index 00000000..75680e0c --- /dev/null +++ b/frontend/src/runtime/desktopServerConfig.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DESKTOP_SERVER_URL_CHANGED_EVENT, + DESKTOP_SERVER_URL_KEY, + getDesktopServerUrl, + normalizeDesktopServerUrl, + setDesktopServerUrl, + shouldRequireDesktopServerUrl, +} from "./desktopServerConfig"; + +const setTauriRuntime = (enabled: boolean) => { + if (enabled) { + Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true }); + } else { + Reflect.deleteProperty(window, "__TAURI_INTERNALS__"); + } +}; + +const createMemoryStorage = (): Storage => { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear: vi.fn(() => store.clear()), + getItem: vi.fn((key: string) => store.get(key) ?? null), + key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null), + removeItem: vi.fn((key: string) => store.delete(key)), + setItem: vi.fn((key: string, value: string) => store.set(key, value)), + }; +}; + +beforeEach(() => { + Object.defineProperty(window, "localStorage", { + value: createMemoryStorage(), + configurable: true, + }); +}); + +afterEach(() => { + window.localStorage.clear(); + setTauriRuntime(false); +}); + +describe("desktop server config", () => { + it("normalizes server URLs to an origin with trailing slash", () => { + expect(normalizeDesktopServerUrl("https://ctms.example.com/api")).toEqual({ + ok: true, + url: "https://ctms.example.com/", + }); + }); + + it("allows local HTTP but rejects non-local HTTP", () => { + expect(normalizeDesktopServerUrl("http://localhost:8000")).toEqual({ + ok: true, + url: "http://localhost:8000/", + }); + expect(normalizeDesktopServerUrl("http://ctms.example.com").ok).toBe(false); + }); + + it("stores valid desktop server URLs and emits a change event", () => { + const listener = vi.fn(); + window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, listener); + + const result = setDesktopServerUrl("https://ctms.example.com"); + + expect(result).toEqual({ ok: true, url: "https://ctms.example.com/" }); + expect(window.localStorage.getItem(DESKTOP_SERVER_URL_KEY)).toBe("https://ctms.example.com/"); + expect(getDesktopServerUrl()).toBe("https://ctms.example.com/"); + expect(listener).toHaveBeenCalledOnce(); + }); + + it("requires a server URL only inside the Tauri runtime", () => { + expect(shouldRequireDesktopServerUrl()).toBe(false); + setTauriRuntime(true); + expect(shouldRequireDesktopServerUrl()).toBe(true); + setDesktopServerUrl("https://ctms.example.com"); + expect(shouldRequireDesktopServerUrl()).toBe(false); + }); +}); diff --git a/frontend/src/runtime/desktopServerConfig.ts b/frontend/src/runtime/desktopServerConfig.ts new file mode 100644 index 00000000..251d6204 --- /dev/null +++ b/frontend/src/runtime/desktopServerConfig.ts @@ -0,0 +1,80 @@ +import { isTauriRuntime } from "./platform"; + +export const DESKTOP_SERVER_URL_KEY = "ctms_desktop_server_url"; +export const DESKTOP_SERVER_URL_CHANGED_EVENT = "ctms:desktop-server-url-changed"; + +export type DesktopServerUrlValidationResult = + | { ok: true; url: string } + | { ok: false; reason: string }; + +const LOCAL_HTTP_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +const getStorage = (): Storage | null => { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +}; + +const emitServerUrlChanged = (previous: string | null, current: string | null): void => { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, { + detail: { previous, current }, + }), + ); +}; + +export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => { + const raw = value.trim(); + if (!raw) return { ok: false, reason: "请输入服务器地址" }; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return { ok: false, reason: "服务器地址格式不正确" }; + } + + if (parsed.username || parsed.password) { + return { ok: false, reason: "服务器地址不能包含用户名或密码" }; + } + + const isHttps = parsed.protocol === "https:"; + const isLocalHttp = parsed.protocol === "http:" && LOCAL_HTTP_HOSTS.has(parsed.hostname); + if (!isHttps && !isLocalHttp) { + return { ok: false, reason: "非本地服务必须使用 HTTPS" }; + } + + return { ok: true, url: `${parsed.origin}/` }; +}; + +export const getDesktopServerUrl = (): string | null => { + const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY); + if (!stored) return null; + const result = normalizeDesktopServerUrl(stored); + return result.ok ? result.url : null; +}; + +export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl()); + +export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => { + const result = normalizeDesktopServerUrl(value); + if (!result.ok) return result; + const storage = getStorage(); + if (!storage) return { ok: false, reason: "当前环境无法保存服务器地址" }; + const previous = getDesktopServerUrl(); + storage.setItem(DESKTOP_SERVER_URL_KEY, result.url); + emitServerUrlChanged(previous, result.url); + return result; +}; + +export const clearDesktopServerUrl = (): void => { + const previous = getDesktopServerUrl(); + getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY); + emitServerUrlChanged(previous, null); +}; + +export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl(); diff --git a/frontend/src/runtime/desktopUiPreferences.test.ts b/frontend/src/runtime/desktopUiPreferences.test.ts new file mode 100644 index 00000000..9058964e --- /dev/null +++ b/frontend/src/runtime/desktopUiPreferences.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + DESKTOP_FAVORITE_ROUTES_KEY, + DESKTOP_RECENT_ROUTES_KEY, + DESKTOP_THEME_KEY, + applyDesktopThemePreference, + readDesktopFavoriteRoutes, + readDesktopRecentRoutes, + readDesktopThemePreference, + recordDesktopRecentRoute, + setDesktopThemePreference, + toggleDesktopFavoriteRoute, +} from "./desktopUiPreferences"; + +const installLocalStorageMock = () => { + const store = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + }, + }); +}; + +describe("desktop UI preferences", () => { + beforeEach(() => { + installLocalStorageMock(); + document.documentElement.removeAttribute("data-ctms-theme"); + document.documentElement.style.colorScheme = ""; + }); + + it("stores only route metadata for recent desktop routes", () => { + recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" }); + + expect(readDesktopRecentRoutes()).toEqual([ + expect.objectContaining({ + path: "/subjects", + title: "受试者", + group: "当前项目", + }), + ]); + expect(window.localStorage.getItem(DESKTOP_RECENT_ROUTES_KEY)).not.toContain("token"); + }); + + it("deduplicates favorites by path", () => { + toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" }); + toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" }); + toggleDesktopFavoriteRoute({ path: "/file-versions", title: "文件版本" }); + + expect(readDesktopFavoriteRoutes()).toHaveLength(1); + expect(readDesktopFavoriteRoutes()[0].path).toBe("/file-versions"); + expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no"); + }); + + it("stores and applies only the desktop theme enum", () => { + expect(readDesktopThemePreference()).toBe("light"); + + setDesktopThemePreference("dark"); + + expect(readDesktopThemePreference()).toBe("dark"); + expect(window.localStorage.getItem(DESKTOP_THEME_KEY)).toBe("dark"); + expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("dark"); + expect(document.documentElement.style.colorScheme).toBe("dark"); + + applyDesktopThemePreference("light"); + expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light"); + }); +}); diff --git a/frontend/src/runtime/desktopUiPreferences.ts b/frontend/src/runtime/desktopUiPreferences.ts new file mode 100644 index 00000000..44d204bf --- /dev/null +++ b/frontend/src/runtime/desktopUiPreferences.ts @@ -0,0 +1,113 @@ +export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes"; +export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes"; +export const DESKTOP_THEME_KEY = "ctms_desktop_theme"; +export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed"; + +export type DesktopThemePreference = "light" | "dark"; + +export interface DesktopRoutePreference { + path: string; + title: string; + group?: string; + updatedAt: string; +} + +const MAX_RECENT_ROUTES = 8; +const MAX_FAVORITE_ROUTES = 12; +const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light"; +const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme"; + +const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined"; + +const sanitizeRoutePreference = (value: Partial | null | undefined): DesktopRoutePreference | null => { + const path = typeof value?.path === "string" ? value.path.trim() : ""; + const title = typeof value?.title === "string" ? value.title.trim() : ""; + if (!path.startsWith("/") || !title) return null; + return { + path, + title: title.slice(0, 80), + group: typeof value?.group === "string" ? value.group.slice(0, 40) : undefined, + updatedAt: typeof value?.updatedAt === "string" ? value.updatedAt : new Date().toISOString(), + }; +}; + +const readRoutePreferences = (key: string): DesktopRoutePreference[] => { + if (!isStorageAvailable()) return []; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .map((item) => sanitizeRoutePreference(item)) + .filter(Boolean) as DesktopRoutePreference[]; + } catch { + return []; + } +}; + +const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) => { + if (!isStorageAvailable()) return; + window.localStorage.setItem(key, JSON.stringify(routes)); +}; + +const sanitizeDesktopThemePreference = (value: unknown): DesktopThemePreference => + value === "dark" ? "dark" : DEFAULT_DESKTOP_THEME; + +export const readDesktopThemePreference = (): DesktopThemePreference => { + if (!isStorageAvailable()) return DEFAULT_DESKTOP_THEME; + return sanitizeDesktopThemePreference(window.localStorage.getItem(DESKTOP_THEME_KEY)); +}; + +export const applyDesktopThemePreference = (theme: DesktopThemePreference = readDesktopThemePreference()): DesktopThemePreference => { + const nextTheme = sanitizeDesktopThemePreference(theme); + if (typeof document !== "undefined") { + document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, nextTheme); + document.documentElement.style.colorScheme = nextTheme; + } + return nextTheme; +}; + +export const setDesktopThemePreference = (theme: DesktopThemePreference): DesktopThemePreference => { + const nextTheme = sanitizeDesktopThemePreference(theme); + if (isStorageAvailable()) { + window.localStorage.setItem(DESKTOP_THEME_KEY, nextTheme); + } + applyDesktopThemePreference(nextTheme); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(DESKTOP_THEME_CHANGED_EVENT, { detail: nextTheme })); + } + return nextTheme; +}; + +export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY); + +export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY); + +export const recordDesktopRecentRoute = (route: Pick): DesktopRoutePreference[] => { + const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() }); + if (!item) return readDesktopRecentRoutes(); + const next = [ + item, + ...readDesktopRecentRoutes().filter((existing) => existing.path !== item.path), + ].slice(0, MAX_RECENT_ROUTES); + writeRoutePreferences(DESKTOP_RECENT_ROUTES_KEY, next); + return next; +}; + +export const isDesktopFavoriteRoute = (path: string): boolean => + readDesktopFavoriteRoutes().some((item) => item.path === path); + +export const toggleDesktopFavoriteRoute = ( + route: Pick, +): DesktopRoutePreference[] => { + const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() }); + if (!item) return readDesktopFavoriteRoutes(); + const current = readDesktopFavoriteRoutes(); + const exists = current.some((existing) => existing.path === item.path); + const next = exists + ? current.filter((existing) => existing.path !== item.path) + : [item, ...current].slice(0, MAX_FAVORITE_ROUTES); + writeRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY, next); + return next; +}; diff --git a/frontend/src/runtime/files.ts b/frontend/src/runtime/files.ts new file mode 100644 index 00000000..71d981b2 --- /dev/null +++ b/frontend/src/runtime/files.ts @@ -0,0 +1,145 @@ +import { isTauriRuntime } from "./platform"; + +export interface FilePickerOptions { + multiple?: boolean; + accept?: string[]; + title?: string; +} + +export interface FileOutput { + suggestedName: string; + mimeType?: string; + data: Blob | ArrayBuffer | Uint8Array | string; +} + +export type SaveFileResult = "saved" | "cancelled"; + +const sanitizeFileName = (value: string): string => { + const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim(); + return normalized.slice(0, 180) || "download"; +}; + +const extensionFilters = (accept: string[] | undefined) => { + if (!accept?.length) return undefined; + const extensions = accept + .map((value) => value.trim().replace(/^\./, "")) + .filter((value) => value && !value.includes("/") && /^[a-zA-Z0-9]+$/.test(value)); + return extensions.length ? [{ name: "可选文件", extensions }] : undefined; +}; + +const toBlobPart = (data: FileOutput["data"]): BlobPart => { + if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) return data; + const copy = new Uint8Array(data.byteLength); + copy.set(data); + return copy; +}; + +const toBlob = (output: FileOutput): Blob => + output.data instanceof Blob + ? output.data + : new Blob([toBlobPart(output.data)], { type: output.mimeType || "application/octet-stream" }); + +const toBytes = async (output: FileOutput): Promise => + new Uint8Array(await toBlob(output).arrayBuffer()); + +const webPickFiles = (options: FilePickerOptions): Promise => + new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = Boolean(options.multiple); + input.accept = options.accept?.map((item) => (item.includes("/") ? item : `.${item.replace(/^\./, "")}`)).join(",") || ""; + input.style.display = "none"; + input.addEventListener( + "change", + () => { + resolve(Array.from(input.files || [])); + input.remove(); + }, + { once: true }, + ); + document.body.appendChild(input); + input.click(); + }); + +export const pickFiles = async (options: FilePickerOptions = {}): Promise => { + if (!isTauriRuntime()) return webPickFiles(options); + const [{ open }, { readFile }] = await Promise.all([ + import("@tauri-apps/plugin-dialog"), + import("@tauri-apps/plugin-fs"), + ]); + const selected = await open({ + title: options.title, + multiple: Boolean(options.multiple), + directory: false, + filters: extensionFilters(options.accept), + }); + const paths = selected ? (Array.isArray(selected) ? selected : [selected]) : []; + return Promise.all( + paths.map(async (path) => { + const bytes = await readFile(path); + const name = path.split(/[\\/]/).pop() || "upload"; + return new File([bytes], name, { lastModified: Date.now() }); + }), + ); +}; + +export const saveFile = async (output: FileOutput): Promise => { + const suggestedName = sanitizeFileName(output.suggestedName); + if (!isTauriRuntime()) { + const url = URL.createObjectURL(toBlob(output)); + const link = document.createElement("a"); + link.href = url; + link.download = suggestedName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + return "saved"; + } + + const [{ save }, { writeFile }] = await Promise.all([ + import("@tauri-apps/plugin-dialog"), + import("@tauri-apps/plugin-fs"), + ]); + const path = await save({ defaultPath: suggestedName }); + if (!path) return "cancelled"; + await writeFile(path, await toBytes(output)); + return "saved"; +}; + +export const openFile = async (output: FileOutput): Promise => { + if (!isTauriRuntime()) { + const url = URL.createObjectURL(toBlob(output)); + const link = document.createElement("a"); + link.href = url; + link.target = "_blank"; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + return; + } + + const [{ tempDir, join }, { mkdir, writeFile }, { openPath }] = await Promise.all([ + import("@tauri-apps/api/path"), + import("@tauri-apps/plugin-fs"), + import("@tauri-apps/plugin-opener"), + ]); + const root = await join(await tempDir(), "ctms-desktop"); + const directory = await join(root, crypto.randomUUID()); + await mkdir(directory, { recursive: true }); + const path = await join(directory, sanitizeFileName(output.suggestedName)); + await writeFile(path, await toBytes(output)); + await openPath(path); +}; + +export const cleanupTemporaryFiles = async (): Promise => { + if (!isTauriRuntime()) return; + const [{ tempDir, join }, { exists, remove }] = await Promise.all([ + import("@tauri-apps/api/path"), + import("@tauri-apps/plugin-fs"), + ]); + const root = await join(await tempDir(), "ctms-desktop"); + if (await exists(root)) await remove(root, { recursive: true }); +}; diff --git a/frontend/src/runtime/index.ts b/frontend/src/runtime/index.ts new file mode 100644 index 00000000..2668a416 --- /dev/null +++ b/frontend/src/runtime/index.ts @@ -0,0 +1,61 @@ +export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./clientRuntime"; +export { + clearDesktopServerUrl, + DESKTOP_SERVER_URL_CHANGED_EVENT, + getDesktopServerUrl, + hasDesktopServerUrl, + normalizeDesktopServerUrl, + setDesktopServerUrl, + shouldRequireDesktopServerUrl, +} from "./desktopServerConfig"; +export { + DESKTOP_FAVORITE_ROUTES_KEY, + DESKTOP_RECENT_ROUTES_KEY, + DESKTOP_THEME_CHANGED_EVENT, + DESKTOP_THEME_KEY, + applyDesktopThemePreference, + isDesktopFavoriteRoute, + readDesktopFavoriteRoutes, + readDesktopRecentRoutes, + readDesktopThemePreference, + recordDesktopRecentRoute, + setDesktopThemePreference, + toggleDesktopFavoriteRoute, + type DesktopRoutePreference, + type DesktopThemePreference, +} from "./desktopUiPreferences"; +export { + DESKTOP_MENU_COMMAND_EVENT, + listenDesktopMenuCommand, + type DesktopMenuCommand, +} from "./desktopMenu"; +export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata"; +export { + cleanupTemporaryFiles, + openFile, + pickFiles, + saveFile, + type FileOutput, + type FilePickerOptions, + type SaveFileResult, +} from "./files"; +export { + getNotificationPermission, + requestNotificationPermission, + showSystemNotification, + type NotificationPermissionState, +} from "./notifications"; +export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform"; +export { + clearSessionToken, + getSessionToken, + initializeSecureSessionStorage, + isSecureSessionStorageAvailable, + setSessionToken, +} from "./secureSessionStorage"; +export { + checkForDesktopUpdate, + installPendingDesktopUpdate, + isDesktopUpdaterAvailable, + type DesktopUpdateInfo, +} from "./updates"; diff --git a/frontend/src/runtime/notifications.ts b/frontend/src/runtime/notifications.ts new file mode 100644 index 00000000..7260e5f1 --- /dev/null +++ b/frontend/src/runtime/notifications.ts @@ -0,0 +1,25 @@ +import { isTauriRuntime } from "./platform"; + +export type NotificationPermissionState = "granted" | "denied" | "prompt" | "unsupported"; + +export const getNotificationPermission = async (): Promise => { + if (!isTauriRuntime()) return "unsupported"; + const { isPermissionGranted } = await import("@tauri-apps/plugin-notification"); + return (await isPermissionGranted()) ? "granted" : "prompt"; +}; + +export const requestNotificationPermission = async (): Promise => { + if (!isTauriRuntime()) return "unsupported"; + const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification"); + if (await isPermissionGranted()) return "granted"; + return (await requestPermission()) === "granted" ? "granted" : "denied"; +}; + +export const showSystemNotification = async (): Promise => { + if (!isTauriRuntime()) return; + const { sendNotification } = await import("@tauri-apps/plugin-notification"); + sendNotification({ + title: "CTMS 文件更新", + body: "有新的文件版本待查看", + }); +}; diff --git a/frontend/src/runtime/platform.ts b/frontend/src/runtime/platform.ts new file mode 100644 index 00000000..cfe44c52 --- /dev/null +++ b/frontend/src/runtime/platform.ts @@ -0,0 +1,21 @@ +export type RuntimePlatform = "web" | "macos" | "windows" | "linux"; + +declare global { + interface Window { + __TAURI__?: unknown; + __TAURI_INTERNALS__?: unknown; + } +} + +export const isTauriRuntime = (): boolean => + typeof window !== "undefined" && Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__); + +export const getRuntimePlatform = (): RuntimePlatform => { + if (!isTauriRuntime() || typeof navigator === "undefined") return "web"; + + const platform = navigator.platform.toLowerCase(); + const userAgent = navigator.userAgent.toLowerCase(); + if (platform.includes("mac") || userAgent.includes("mac os x")) return "macos"; + if (platform.includes("win") || userAgent.includes("windows")) return "windows"; + return "linux"; +}; diff --git a/frontend/src/runtime/secureSessionStorage.ts b/frontend/src/runtime/secureSessionStorage.ts new file mode 100644 index 00000000..dfb66a60 --- /dev/null +++ b/frontend/src/runtime/secureSessionStorage.ts @@ -0,0 +1,117 @@ +import { getDesktopServerUrl } from "./desktopServerConfig"; +import { isTauriRuntime } from "./platform"; + +const LEGACY_TOKEN_KEY = "ctms_token"; + +let cachedToken: string | null = null; +let initialized = false; +let activeServerOrigin: string | null = null; +let secureStorageAvailable = false; + +const invokeCredential = async ( + command: "credential_get" | "credential_set" | "credential_delete", + args: Record, +): Promise => { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke(command, args); +}; + +const isUsableJwt = (token: string): boolean => { + const segments = token.split("."); + if (segments.length !== 3) return false; + try { + const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); + const payload = JSON.parse(atob(padded)); + return typeof payload.exp !== "number" || payload.exp * 1000 > Date.now(); + } catch { + return false; + } +}; + +export const initializeSecureSessionStorage = async (): Promise => { + if (initialized) return; + initialized = true; + + if (!isTauriRuntime()) { + cachedToken = window.localStorage.getItem(LEGACY_TOKEN_KEY); + secureStorageAvailable = true; + return; + } + + const legacyToken = window.localStorage.getItem(LEGACY_TOKEN_KEY); + window.localStorage.removeItem(LEGACY_TOKEN_KEY); + activeServerOrigin = getDesktopServerUrl(); + if (!activeServerOrigin) { + cachedToken = null; + secureStorageAvailable = true; + return; + } + + try { + if (legacyToken && isUsableJwt(legacyToken)) { + await invokeCredential("credential_set", { + serverOrigin: activeServerOrigin, + token: legacyToken, + }); + cachedToken = legacyToken; + } else { + cachedToken = await invokeCredential("credential_get", { + serverOrigin: activeServerOrigin, + }); + } + secureStorageAvailable = true; + } catch (error) { + cachedToken = null; + secureStorageAvailable = false; + throw error; + } +}; + +export const getSessionToken = (): string | null => { + if (!initialized && !isTauriRuntime() && typeof localStorage !== "undefined") { + return localStorage.getItem(LEGACY_TOKEN_KEY); + } + return cachedToken; +}; + +export const setSessionToken = async (token: string): Promise => { + if (!token) throw new Error("拒绝保存空 token"); + if (!isTauriRuntime()) { + window.localStorage.setItem(LEGACY_TOKEN_KEY, token); + cachedToken = token; + initialized = true; + secureStorageAvailable = true; + return; + } + + const serverOrigin = getDesktopServerUrl(); + if (!serverOrigin) throw new Error("尚未配置桌面服务器地址"); + await invokeCredential("credential_set", { serverOrigin, token }); + activeServerOrigin = serverOrigin; + cachedToken = token; + initialized = true; + secureStorageAvailable = true; +}; + +export const clearSessionToken = async (): Promise => { + cachedToken = null; + if (!isTauriRuntime()) { + if (typeof localStorage !== "undefined") localStorage.removeItem(LEGACY_TOKEN_KEY); + return; + } + + const serverOrigin = activeServerOrigin || getDesktopServerUrl(); + activeServerOrigin = getDesktopServerUrl(); + if (!serverOrigin) return; + await invokeCredential("credential_delete", { serverOrigin }); +}; + +export const isSecureSessionStorageAvailable = (): boolean => secureStorageAvailable; + +export const resetSecureSessionStorageForTests = (): void => { + cachedToken = null; + initialized = false; + activeServerOrigin = null; + secureStorageAvailable = false; +}; diff --git a/frontend/src/runtime/updates.ts b/frontend/src/runtime/updates.ts new file mode 100644 index 00000000..82e928b8 --- /dev/null +++ b/frontend/src/runtime/updates.ts @@ -0,0 +1,39 @@ +import { getDesktopServerUrl } from "./desktopServerConfig"; +import { getAppMetadata } from "./appMetadata"; +import { isTauriRuntime } from "./platform"; + +export interface DesktopUpdateInfo { + version: string; + currentVersion: string; + notes?: string | null; + date?: string | null; +} + +type TauriCore = typeof import("@tauri-apps/api/core"); + +const loadTauriCore = async (): Promise => { + if (!isTauriRuntime()) return null; + return import("@tauri-apps/api/core"); +}; + +export const isDesktopUpdaterAvailable = (): boolean => { + if (!isTauriRuntime()) return false; + const metadata = getAppMetadata(); + return metadata.channel === "release" || import.meta.env.VITE_ENABLE_DESKTOP_UPDATES === "true"; +}; + +export const checkForDesktopUpdate = async (): Promise => { + if (!isDesktopUpdaterAvailable()) return null; + const serverOrigin = getDesktopServerUrl(); + if (!serverOrigin) return null; + const tauri = await loadTauriCore(); + if (!tauri) return null; + return tauri.invoke("desktop_update_check", { serverOrigin }); +}; + +export const installPendingDesktopUpdate = async (): Promise => { + if (!isDesktopUpdaterAvailable()) return; + const tauri = await loadTauriCore(); + if (!tauri) return; + await tauri.invoke("desktop_update_install"); +}; diff --git a/frontend/src/session/desktopNotificationManager.ts b/frontend/src/session/desktopNotificationManager.ts new file mode 100644 index 00000000..e23dd83c --- /dev/null +++ b/frontend/src/session/desktopNotificationManager.ts @@ -0,0 +1,76 @@ +import { + acknowledgeDesktopNotifications, + claimDesktopNotifications, + getDesktopNotificationSubscription, +} from "../api/desktopNotifications"; +import { getToken } from "../utils/auth"; +import { + getNotificationPermission, + isTauriRuntime, + showSystemNotification, +} from "../runtime"; + +const POLL_INTERVAL_MS = 60_000; +const MAX_BACKOFF_MS = 15 * 60_000; + +let initialized = false; +let timer: number | null = null; +let failureCount = 0; + +const schedule = (delay: number) => { + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => void poll(), delay); +}; + +const poll = async () => { + if (!getToken()) { + failureCount = 0; + schedule(POLL_INTERVAL_MS); + return; + } + try { + const permission = await getNotificationPermission(); + if (permission !== "granted") { + schedule(POLL_INTERVAL_MS); + return; + } + const { data: subscription } = await getDesktopNotificationSubscription(); + if (!subscription.enabled) { + schedule(POLL_INTERVAL_MS); + return; + } + const { data } = await claimDesktopNotifications(); + const deliveredIds: string[] = []; + for (const item of data.items) { + await showSystemNotification(); + deliveredIds.push(item.id); + } + if (data.claim_token && deliveredIds.length) { + await acknowledgeDesktopNotifications(data.claim_token, deliveredIds); + } + failureCount = 0; + schedule(POLL_INTERVAL_MS); + } catch { + failureCount += 1; + schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS)); + } +}; + +export const initDesktopNotificationManager = (): void => { + if (initialized || !isTauriRuntime()) return; + initialized = true; + schedule(5_000); +}; + +export const triggerDesktopNotificationPoll = (): void => { + if (!initialized) return; + failureCount = 0; + schedule(0); +}; + +export const stopDesktopNotificationManager = (): void => { + if (timer !== null) window.clearTimeout(timer); + timer = null; + initialized = false; + failureCount = 0; +}; diff --git a/frontend/src/session/desktopUpdateManager.ts b/frontend/src/session/desktopUpdateManager.ts new file mode 100644 index 00000000..337329e5 --- /dev/null +++ b/frontend/src/session/desktopUpdateManager.ts @@ -0,0 +1,122 @@ +import { ElMessage, ElMessageBox } from "element-plus"; +import { + checkForDesktopUpdate, + installPendingDesktopUpdate, + isDesktopUpdaterAvailable, + type DesktopUpdateInfo, +} from "../runtime"; + +const INITIAL_CHECK_DELAY_MS = 30_000; +const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const POSTPONE_MS = 24 * 60 * 60 * 1000; +const POSTPONE_PREFIX = "ctms_desktop_update_postponed:"; + +let initialized = false; +let checkTimer: number | null = null; +let intervalTimer: number | null = null; +let promptVisible = false; + +export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed"; + +export interface DesktopUpdateCheckOptions { + notifyWhenCurrent?: boolean; +} + +const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`; + +const getPostponeUntil = (version: string): number => { + try { + const raw = window.localStorage.getItem(postponeKey(version)); + return raw ? Number(raw) || 0 : 0; + } catch { + return 0; + } +}; + +const postponeVersion = (version: string) => { + try { + window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS)); + } catch { + /* ignore */ + } +}; + +const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now(); + +const releaseNotes = (update: DesktopUpdateInfo): string => { + const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`]; + if (update.notes?.trim()) { + lines.push("", "发布说明:", update.notes.trim()); + } + lines.push("", "确认后将下载、验签、安装并重启应用。"); + return lines.join("\n"); +}; + +const promptForUpdate = async (update: DesktopUpdateInfo): Promise => { + if (promptVisible || isSuppressed(update.version)) return "suppressed"; + promptVisible = true; + try { + await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", { + confirmButtonText: "立即更新并重启", + cancelButtonText: "稍后", + distinguishCancelAndClose: true, + type: "info", + }); + await installPendingDesktopUpdate(); + return "available"; + } catch (error) { + if (error === "cancel" || error === "close") { + postponeVersion(update.version); + return "postponed"; + } + ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。"); + return "failed"; + } finally { + promptVisible = false; + } +}; + +export const checkDesktopUpdateAndPrompt = async ( + options: DesktopUpdateCheckOptions = {}, +): Promise => { + if (!isDesktopUpdaterAvailable()) { + if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新"); + return "disabled"; + } + try { + const update = await checkForDesktopUpdate(); + if (update) { + return promptForUpdate(update); + } + if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本"); + return "up-to-date"; + } catch { + // 启动和定时检查不打断录入;下一轮继续检查。 + if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。"); + return "failed"; + } +}; + +export const initDesktopUpdateManager = () => { + if (initialized || !isDesktopUpdaterAvailable()) return; + initialized = true; + checkTimer = window.setTimeout(() => { + void checkDesktopUpdateAndPrompt(); + }, INITIAL_CHECK_DELAY_MS); + intervalTimer = window.setInterval(() => { + void checkDesktopUpdateAndPrompt(); + }, CHECK_INTERVAL_MS); +}; + +export const stopDesktopUpdateManager = () => { + if (checkTimer) { + window.clearTimeout(checkTimer); + checkTimer = null; + } + if (intervalTimer) { + window.clearInterval(intervalTimer); + intervalTimer = null; + } + initialized = false; + promptVisible = false; +}; diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts index 955995a3..a09ccb6e 100644 --- a/frontend/src/session/sessionManager.ts +++ b/frontend/src/session/sessionManager.ts @@ -1,7 +1,7 @@ import router from "../router"; import { useAuthStore } from "../store/auth"; import { useSessionStore } from "../store/session"; -import { getToken, setToken, clearToken } from "../utils/auth"; +import { getToken, setToken } from "../utils/auth"; import { extendToken } from "../api/authClient"; import { parseJwtExp } from "./jwt"; @@ -30,7 +30,7 @@ const getTimeoutAt = (session: ReturnType) => const reconcileSessionState = (now: number = Date.now()) => { const session = useSessionStore(); if (now >= getTimeoutAt(session)) { - forceLogout(LOGOUT_REASON_TIMEOUT); + void forceLogout(LOGOUT_REASON_TIMEOUT); return false; } return true; @@ -56,11 +56,11 @@ const handleBroadcast = (message: BroadcastMessage) => { } if (message.type === "TOKEN_UPDATED") { auth.setToken(message.token); - setToken(message.token); + void setToken(message.token); session.resetActivity(); } if (message.type === "LOGOUT") { - performLogout(message.reason); + void performLogout(message.reason); } }; @@ -156,25 +156,25 @@ export const consumeLogoutReason = () => { } }; -const performLogout = (reason?: string) => { +const performLogout = async (reason?: string) => { setLogoutReason(reason); const auth = useAuthStore(); const session = useSessionStore(); - auth.logout(); + const logoutPromise = auth.logout(); session.resetActivity(); - clearToken(); router.replace("/login"); + await logoutPromise; }; -export const forceLogout = (reason?: string) => { - performLogout(reason); +export const forceLogout = async (reason?: string) => { + await performLogout(reason); broadcast({ type: "LOGOUT", reason }); }; -const updateToken = (token: string) => { +const updateToken = async (token: string) => { const auth = useAuthStore(); auth.setToken(token); - setToken(token); + await setToken(token); broadcast({ type: "TOKEN_UPDATED", token }); }; @@ -194,15 +194,15 @@ export const extendAccessToken = async (reason: "early" | "response-401") => { try { const { data } = await extendToken(token); session.setLastExtendAt(Date.now()); - updateToken(data.accessToken); + await updateToken(data.accessToken); return { token: data.accessToken, authFailed: false }; } catch (err: any) { const status = err?.response?.status; if (status === 401) { - forceLogout(LOGOUT_REASON_AUTH_EXPIRED); + await forceLogout(LOGOUT_REASON_AUTH_EXPIRED); authFailed = true; } else if (status === 403) { - forceLogout(LOGOUT_REASON_AUTH_EXPIRED); + await forceLogout(LOGOUT_REASON_AUTH_EXPIRED); authFailed = true; } return { token: null, authFailed }; diff --git a/frontend/src/store/auth.test.ts b/frontend/src/store/auth.test.ts index ec9cf172..90a0391b 100644 --- a/frontend/src/store/auth.test.ts +++ b/frontend/src/store/auth.test.ts @@ -92,7 +92,7 @@ describe("auth store logout", () => { session.recordUserActivity(staleTs); session.setLastExtendAt(staleTs); session.showTimeoutWarning(staleTs + 30_000); - auth.logout(); + await auth.logout(); expect(session.lastUserActiveAt).toBeGreaterThan(staleTs); expect(session.lastExtendAt).toBe(0); diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 9a27c502..a33308b8 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -25,7 +25,7 @@ export const useAuthStore = defineStore("auth", () => { ? await devLogin({ email, password }) : await encryptedLogin(email, password); token.value = data.access_token; - setToken(data.access_token); + await setToken(data.access_token); useSessionStore().resetActivity(); localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email); const me = await fetchMeAction(); @@ -62,7 +62,7 @@ export const useAuthStore = defineStore("auth", () => { return data; }; - const logout = () => { + const logout = async () => { const studyStore = useStudyStore(); const sessionStore = useSessionStore(); const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || ""; @@ -71,7 +71,7 @@ export const useAuthStore = defineStore("auth", () => { user.value = null; forceLogin.value = false; sessionStore.resetActivity(); - clearToken(); + await clearToken(); studyStore.clearCurrentStudy(); }; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 8c3bb04a..71b31715 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -70,6 +70,54 @@ --el-text-color-regular: var(--ctms-text-regular); } +:root[data-ctms-theme="dark"] { + --ctms-primary: #8fb7d4; + --ctms-primary-hover: #a7c9df; + --ctms-primary-active: #c3dced; + --ctms-primary-light: #203145; + --ctms-brand-900: #d8e7f4; + --ctms-brand-700: #b4d0e5; + + --ctms-success: #7dd3a8; + --ctms-warning: #f1c271; + --ctms-danger: #f28b8b; + --ctms-info: #a6b4c5; + + --ctms-text-main: #f8fafc; + --ctms-text-regular: #dbe5f1; + --ctms-text-secondary: #9aaabd; + --ctms-text-disabled: #66788d; + + --ctms-bg-base: #0f172a; + --ctms-bg-card: #172033; + --ctms-bg-muted: #111827; + --ctms-neutral-100: #182337; + --ctms-neutral-300: #334155; + --ctms-border-color: #26364a; + --ctms-border-color-hover: #3d5068; + + --ctms-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.24); + --ctms-shadow: 0 8px 20px rgba(0, 0, 0, 0.32); + --ctms-shadow-md: 0 12px 26px rgba(0, 0, 0, 0.36); + --ctms-shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.42); + + --el-color-primary: var(--ctms-primary); + --el-color-primary-light-3: #567894; + --el-color-primary-light-5: #3c5871; + --el-color-primary-light-7: #283c50; + --el-color-primary-light-8: #203145; + --el-color-primary-dark-2: #c3dced; + --el-border-color: var(--ctms-border-color); + --el-text-color-primary: var(--ctms-text-main); + --el-text-color-regular: var(--ctms-text-regular); + --el-bg-color: var(--ctms-bg-card); + --el-bg-color-overlay: #172033; + --el-fill-color-blank: #172033; + --el-fill-color-light: #111827; + --el-fill-color-lighter: #0f172a; + --el-mask-color: rgba(0, 0, 0, 0.62); +} + /* 全局重置 */ html, body, @@ -85,6 +133,90 @@ body { -webkit-font-smoothing: antialiased; } +:root[data-ctms-theme="dark"] body { + background: var(--ctms-bg-base); + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-button--default { + background-color: #172033; + border-color: #334155; + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-button--default:hover { + border-color: #4b6580; + background-color: #1f2d3d; + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-card, +:root[data-ctms-theme="dark"] .el-alert, +:root[data-ctms-theme="dark"] .el-popper, +:root[data-ctms-theme="dark"] .el-dropdown-menu, +:root[data-ctms-theme="dark"] .el-popover.el-popper, +:root[data-ctms-theme="dark"] .el-dialog { + border-color: var(--ctms-border-color); + background-color: var(--ctms-bg-card); + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-card__header { + border-bottom-color: var(--ctms-border-color); + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-input__wrapper, +:root[data-ctms-theme="dark"] .el-select .el-input__wrapper { + background-color: #172033; + box-shadow: 0 0 0 1px #334155 inset; +} + +:root[data-ctms-theme="dark"] .el-input__inner { + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-input__inner::placeholder { + color: var(--ctms-text-disabled); +} + +:root[data-ctms-theme="dark"] .el-table { + background-color: transparent; + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-table th.el-table__cell { + background-color: #111827; + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .el-table tr, +:root[data-ctms-theme="dark"] .el-table td.el-table__cell { + background-color: #172033; + border-bottom-color: var(--ctms-border-color); +} + +:root[data-ctms-theme="dark"] .el-table--enable-row-hover .el-table__row:hover>td.el-table__cell { + background-color: #1f2d3d; +} + +:root[data-ctms-theme="dark"] .el-dropdown-menu__item { + color: var(--ctms-text-regular); +} + +:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):focus, +:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):hover { + background-color: #1f2d3d; + color: var(--ctms-text-main); +} + +:root[data-ctms-theme="dark"] .ctms-state, +:root[data-ctms-theme="dark"] .ctms-table-card, +:root[data-ctms-theme="dark"] .ctms-section-card { + border-color: var(--ctms-border-color); + background: var(--ctms-bg-card); +} + /* Element Plus 全局覆盖 */ /* 按钮美化 */ diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 0dae874d..af82bd2f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -376,6 +376,11 @@ export interface SecurityAccessLogItem { ip_city: string; ip_isp: string; user_agent: string | null; + client_type: string | null; + client_version: string | null; + client_platform: string | null; + build_channel: string | null; + build_commit: string | null; auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string; user_identifier: string | null; account_label: string; diff --git a/frontend/src/types/desktopCommands.test.ts b/frontend/src/types/desktopCommands.test.ts new file mode 100644 index 00000000..bf025c80 --- /dev/null +++ b/frontend/src/types/desktopCommands.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getVisibleDesktopCommands, type DesktopCommand } from "./desktopCommands"; + +const commands: DesktopCommand[] = [ + { + id: "visible", + title: "受试者", + group: "当前项目", + keywords: ["subject"], + visible: true, + run: () => {}, + }, + { + id: "hidden", + title: "系统监控", + group: "管理后台", + visible: false, + run: () => {}, + }, +]; + +describe("desktop command filtering", () => { + it("only returns visible commands", () => { + expect(getVisibleDesktopCommands(commands, "").map((item) => item.id)).toEqual(["visible"]); + }); + + it("matches title, group, and keywords", () => { + expect(getVisibleDesktopCommands(commands, "subject").map((item) => item.id)).toEqual(["visible"]); + expect(getVisibleDesktopCommands(commands, "当前").map((item) => item.id)).toEqual(["visible"]); + expect(getVisibleDesktopCommands(commands, "missing")).toEqual([]); + }); +}); diff --git a/frontend/src/types/desktopCommands.ts b/frontend/src/types/desktopCommands.ts new file mode 100644 index 00000000..d6626549 --- /dev/null +++ b/frontend/src/types/desktopCommands.ts @@ -0,0 +1,22 @@ +export interface DesktopCommand { + id: string; + title: string; + group: string; + keywords?: string[]; + shortcut?: string; + visible: boolean; + run: () => void | Promise; +} + +export const getVisibleDesktopCommands = (commands: DesktopCommand[], query: string): DesktopCommand[] => { + const normalized = query.trim().toLowerCase(); + return commands.filter((command) => { + if (!command.visible) return false; + if (!normalized) return true; + const haystack = [command.title, command.group, command.shortcut, ...(command.keywords || [])] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(normalized); + }); +}; diff --git a/frontend/src/types/notifications.ts b/frontend/src/types/notifications.ts index 7f0e01cd..88232530 100644 --- a/frontend/src/types/notifications.ts +++ b/frontend/src/types/notifications.ts @@ -8,4 +8,8 @@ export interface NotificationItem { change_summary?: string | null; effective_at?: string | null; created_at: string; + study_id?: string | null; + study_name?: string | null; + delivered_at?: string | null; + read_at?: string | null; } diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts index dbdf55c6..dd4512f2 100644 --- a/frontend/src/utils/auth.ts +++ b/frontend/src/utils/auth.ts @@ -1,11 +1,7 @@ -const TOKEN_KEY = "ctms_token"; +import { clearSessionToken, getSessionToken, setSessionToken } from "../runtime"; -export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY); +export const getToken = (): string | null => getSessionToken(); -export const setToken = (token: string): void => { - localStorage.setItem(TOKEN_KEY, token); -}; +export const setToken = (token: string): Promise => setSessionToken(token); -export const clearToken = (): void => { - localStorage.removeItem(TOKEN_KEY); -}; +export const clearToken = (): Promise => clearSessionToken(); diff --git a/frontend/src/views/DesktopPreferences.vue b/frontend/src/views/DesktopPreferences.vue new file mode 100644 index 00000000..102103e9 --- /dev/null +++ b/frontend/src/views/DesktopPreferences.vue @@ -0,0 +1,429 @@ + + + + + diff --git a/frontend/src/views/DesktopServerSettings.vue b/frontend/src/views/DesktopServerSettings.vue new file mode 100644 index 00000000..f090cf02 --- /dev/null +++ b/frontend/src/views/DesktopServerSettings.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/frontend/src/views/Login.test.ts b/frontend/src/views/Login.test.ts index 22101e6e..ac5cd5c0 100644 --- a/frontend/src/views/Login.test.ts +++ b/frontend/src/views/Login.test.ts @@ -95,4 +95,16 @@ describe("Login protocol agreement", () => { expect(source).toContain("width: clamp(480px, 34vw, 560px);"); expect(source).toContain("max-width: calc(100vw - 40px);"); }); + + it("uses only server-configured email domains on web and desktop", () => { + const source = readLoginView(); + + expect(source).toContain('fetchEmailDomains()'); + expect(source).toContain(':disabled="availableEmailDomains.length === 0"'); + expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)"); + expect(source).toContain("configuredEmailDomains.value.includes(domain)"); + expect(source).not.toContain("preservedEmailDomain"); + expect(source).not.toContain("showDomainSelect"); + expect(source).not.toContain("email-domain-input"); + }); }); diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index bf96905e..1ce337fa 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -100,6 +100,12 @@ 账号登录
+
+ 服务器 + {{ desktopServerUrl || "未配置" }} + 设置 +
+
@@ -153,22 +159,14 @@
@@ -195,7 +193,7 @@ 登 录 - +
忘记密码? 新用户注册 @@ -246,7 +244,7 @@