diff --git a/.codex/skills/create-codex-theme/SKILL.md b/.codex/skills/create-codex-theme/SKILL.md index 6859bef..4f4c299 100644 --- a/.codex/skills/create-codex-theme/SKILL.md +++ b/.codex/skills/create-codex-theme/SKILL.md @@ -1,6 +1,6 @@ --- name: create-codex-theme -description: 为 Awesome Codex Theme 创建、补全或校验原创主题或明确披露的非官方 Fan Art。适用于把一句视觉想法变成双语 theme brief、用 OpenAI image job 生成安全留白的源图、写入 themes/catalog.json 与 source-art/jobs.json、导出明暗模式并运行 ACT Registry 校验。Use when a user asks to create, adapt, package, validate, or contribute a Codex theme in this repository. +description: 为 Awesome Codex Theme 创建、补全或校验原创主题或明确披露的非官方 Fan Art。适用于把一句视觉想法变成双语 theme brief、用 OpenAI image job 生成带焦点构图与安全区的 16:9 源图、写入 themes/catalog.json 与 source-art/jobs.json、导出明暗模式并运行 ACT Registry 校验。Use when a user asks to create, adapt, package, validate, or contribute a Codex theme in this repository. --- # 创建 Codex 主题 @@ -31,7 +31,7 @@ description: 为 Awesome Codex Theme 创建、补全或校验原创主题或明 - 中文主名称与英文辅助名称 - 一句话场景 brief - `original` 或 `fan-art` 权利轨道与相应声明 -- 左侧或右侧安全留白 +- 左侧、右侧、中部或无安全区,以及与主体对应的焦点位置 - 明暗模式 token 中文文案先写,英文随后补齐。描述具体画面,不使用“震撼”“极致”“重新定义”等宣传套话。 @@ -57,6 +57,8 @@ node .codex/skills/create-codex-theme/scripts/scaffold-theme.mjs \ ### 4. 生成并审查源图 +新主题的 source art 必须为 2560×1440 或更高的 16:9 图像。`themes/source-art/jobs.json` 中现有 1536×1024 的具名历史例外只覆盖已列出的 ID,不能复制给新主题。 + 使用仓库的 image job 流程: ```bash @@ -69,10 +71,17 @@ npm run art:generate -- --ids= - 画面没有文字、签名、水印、Logo 或 UI - 原创轨道的人物和物件不像现成 IP;Fan Art 只出现 brief 已声明的角色 - Fan Art 没有复制官方截图、海报、镜头、Logo 或宣传素材 -- 16:9 中心裁切不会截断主体 +- 焦点裁切在 16:9 输出中不会截断主体 - 图片达到主题 Gallery 的完成度 -任何一项不合格,就改 job prompt 后重跑 `--force`。具体命令和检查顺序见 [references/image-job-playbook.md](references/image-job-playbook.md)。 +任何一项不合格,就改 job prompt 后重跑 `--force`。通过源图检查后运行: + +```bash +npm run art:review:write +npm run art:review +``` + +打开联系表和安全区夹具,确认焦点输出与 `left`、`right`、`center`、`none` 四种阅读区都符合 brief。具体命令和检查顺序见 [references/image-job-playbook.md](references/image-job-playbook.md)。 ### 5. 生成主题包 diff --git a/.codex/skills/create-codex-theme/references/image-job-playbook.md b/.codex/skills/create-codex-theme/references/image-job-playbook.md index 0fc45ce..70e71e7 100644 --- a/.codex/skills/create-codex-theme/references/image-job-playbook.md +++ b/.codex/skills/create-codex-theme/references/image-job-playbook.md @@ -12,6 +12,8 @@ ## 生成一张 +新主题的 job 输出必须为 2560×1440 或更高的 16:9 图像。`themes/source-art/jobs.json` 中的 1536×1024 历史例外只适用于列出的现有主题,不能复制给新 ID。 + ```bash npm run art:generate -- --ids=qinglan-odyssey ``` @@ -38,15 +40,17 @@ npm run art:generate -- --ids=theme-a,theme-b --concurrency=2 - 城市或幻想世界靠什么气质被识别 - 哪些具体元素不要出现 +同时写明主体对应的 `focusX`、`focusY` 与阅读安全区。新源图已是 16:9,不能依赖后续中心裁切来修正构图。 + 不要重复 common prompt,也不要写具体画师或工作室。原创 job 不写作品名;Fan Art job 只写 brief 已声明的作品、角色、道具和新构图。 ## 失败处理 -- 构图挤占安全区:明确把主体推到右侧三分之一,并让左侧保持雾、天空或水面。 +- 构图挤占安全区:根据 `left`、`right`、`center` 或 `none` 选择阅读区。把主体放到对应的非阅读区,并让阅读区保持雾、天空或水面。 - 出现文字或 Logo:在场景 brief 中点名移除,再用 `--force` 重做。 - 原创角色像现成 IP:更改轮廓、服装结构、配色和道具,不能只换颜色。 - Fan Art 出现未声明角色或复制镜头:移除多余角色,改换机位、动作、景别和环境叙事后重做。 - 画面像概念草图:要求完成的动画背景、可信材质和环境细节。 -- 3:2 好看但 16:9 被截:把重要内容收进垂直中间 82%。 +- 16:9 焦点裁切仍会截断主体:调整 `focusX`、`focusY` 或重做画面,把重要内容收进垂直中间 82%。 -源图通过后运行 `npm run check`。生成器会完成裁切、明暗分级、哈希、包和预览。 +源图通过后运行 `npm run art:review:write`,人工检查联系表和安全区夹具,再运行 `npm run art:review` 与 `npm run check`。生成器会完成焦点裁切、明暗分级、哈希、包和预览。 diff --git a/.codex/skills/create-codex-theme/references/theme-authoring.md b/.codex/skills/create-codex-theme/references/theme-authoring.md index f4e4426..a96fd2a 100644 --- a/.codex/skills/create-codex-theme/references/theme-authoring.md +++ b/.codex/skills/create-codex-theme/references/theme-authoring.md @@ -10,7 +10,7 @@ - `cinematic`、`chibi`、`cityscape` 或 `scene` variant - 左右焦点、安全区和任务模式 - 符合对比度门槛的明暗 token -- 一张 1536×1024 已审查源图及 provenance +- 一张 2560×1440 或更高的 16:9 已审查源图及 provenance `cinematic-chibi` collection 要求同一 `pair` 同时有原画版和 Q 版。新增此类主题时准备两份 brief,并在运行完整校验前把两个 variant 都加入 catalog。`standalone` collection 可以一次加入一套。 @@ -32,11 +32,12 @@ ## 工作区构图 -源图不是实机截图,但会生成 Full Skin 使用的 2560×1440 背景。Codex Native 回退仍然只改变配色。安全区要同时照顾左侧导航、主标题、建议卡片和底部 composer,不能只为了 Gallery 卡片好看。 +源图不是实机截图,但会生成 Full Skin 使用的 2560×1440 背景。新主题必须使用 16:9、2560×1440 或更高的源图。当前 1536×1024 源图只能通过 `themes/source-art/jobs.json` 的具名历史例外保留给现有 ID,不能复制到新 job。Codex Native 回退仍然只改变配色。安全区要同时照顾左侧导航、主标题、建议卡片和底部 composer,不能只为了 Gallery 卡片好看。 -- `safeArea: left` 时,左侧约 46% 保持低对比、低细节。 -- 主体放在右侧三分之一,不能贴边。 -- 重要内容放在垂直中间 82%,以适应 3:2 到 16:9 的裁切。 +- `focusX` 和 `focusY` 是归一化焦点。生成器在源图可移动时以该点为中心生成 16:9 焦点裁切,并在边缘夹紧。 +- `safeArea: left` 时,左侧约 46% 保持低对比、低细节;`right` 反转该阅读区;`center` 保留中部阅读区;`none` 不追加阅读区叠色。 +- 主体放在与安全区相反的区域,不能贴边。`center` 安全区需要把主体分布到两侧,或使用 `none`。 +- 重要内容留在垂直中间 82%,即使源图已经是 16:9,也要为不同预览尺寸留余量。 - 避免满屏人脸、强烈放射光、碎片和密集文字形状。 - Q 版角色要有完整剪影和可信材质,不能像随机贴纸。 @@ -60,12 +61,12 @@ 校验器要求: -- text/surface ≥ 4.5 -- muted/surface ≥ 4.5 -- accent/background ≥ 3.0 +- text/surface 和 text/surfaceAlt 都 ≥ 4.5 +- muted/surface 和 muted/surfaceAlt 都 ≥ 4.5 +- accent/background、accent/surface 和 accent/surfaceAlt 都 ≥ 3.0 - accentContrast/accent ≥ 4.5 -不要只看色卡。把预览放进 Gallery 和安装弹窗后再判断。 +`border` 只作装饰,不能作为键盘焦点或可点击状态的唯一边界。不要只看色卡。把预览放进 Gallery 和安装弹窗后再判断。 ## 来源与授权 diff --git a/.codex/skills/create-codex-theme/scripts/scaffold-theme.mjs b/.codex/skills/create-codex-theme/scripts/scaffold-theme.mjs index cba8b59..d862d32 100644 --- a/.codex/skills/create-codex-theme/scripts/scaffold-theme.mjs +++ b/.codex/skills/create-codex-theme/scripts/scaffold-theme.mjs @@ -91,7 +91,10 @@ function validateBrief(brief, catalog) { requireValue(brief.fanArt?.commercialUse === false, "fan art must prohibit commercial use"); requireValue(brief.fanArt?.officialAssetsUsed === false, "fan art must disclose that no official assets were used"); } - requireValue(brief.art?.safeArea === "left", "v1 Gallery themes must use a left safe area"); + requireValue(Number.isFinite(brief.art?.focusX) && brief.art.focusX >= 0 && brief.art.focusX <= 1, "art.focusX must be a number from 0 to 1"); + requireValue(Number.isFinite(brief.art?.focusY) && brief.art.focusY >= 0 && brief.art.focusY <= 1, "art.focusY must be a number from 0 to 1"); + requireValue(["left", "right", "center", "none"].includes(brief.art?.safeArea), "unsupported art.safeArea"); + requireValue(["ambient", "banner", "off"].includes(brief.art?.taskMode), "unsupported art.taskMode"); validateTokens("light", brief.light?.tokens); validateTokens("dark", brief.dark?.tokens); } diff --git a/.github/ISSUE_TEMPLATE/accessibility-report.yml b/.github/ISSUE_TEMPLATE/accessibility-report.yml new file mode 100644 index 0000000..8451ed5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/accessibility-report.yml @@ -0,0 +1,44 @@ +name: Accessibility report / 无障碍报告 +description: Report a keyboard, focus, contrast, screen-reader, zoom, motion, or forced-colors barrier. +title: "[Accessibility] " +labels: ["accessibility"] +body: + - type: dropdown + id: surface + attributes: + label: Surface / 受影响部分 + options: + - Gallery website + - Theme Manager + - Theme Manager installer or update UI + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment / 环境 + description: OS, browser/WebView, assistive technology when relevant, zoom or display scaling, and color/motion settings. + validations: + required: true + + - type: textarea + id: barrier + attributes: + label: Barrier and expected outcome / 障碍与预期结果 + description: Include the control or content, current behavior, expected behavior, and why it blocks the task. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Reproduction steps / 复现步骤 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe evidence / 脱敏证据 + description: Screenshots, a short recording, or screen-reader output are welcome after private content is removed. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..d015024 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,63 @@ +name: Bug report / 缺陷报告 +description: Report a reproducible Gallery, generator, Theme Manager, or Native fallback defect. +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Do not include credentials, private chats, account names, local home paths, or unredacted screenshots. For a security vulnerability, follow SECURITY.md instead of opening a public issue. + + - type: dropdown + id: surface + attributes: + label: Surface / 受影响部分 + options: + - Gallery + - Theme generator or validator + - Theme Manager + - Full Skin runtime + - Native fallback + - Installer or release artifact + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment / 环境 + description: Include OS, architecture, browser or Theme Manager version, and the exact Codex version only when relevant. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Reproduction steps / 复现步骤 + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + + - type: textarea + id: expected_actual + attributes: + label: Expected and actual result / 预期与实际结果 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe evidence / 脱敏证据 + description: Add a redacted error, screenshot, or a minimal trace. Do not attach private application data. + + - type: checkboxes + id: confirmation + attributes: + label: Confirmation / 确认 + options: + - label: I removed private data, secrets, and personal paths from this report. / 我已移除私有数据、密钥和个人路径。 + required: true diff --git a/.github/ISSUE_TEMPLATE/compatibility-report.yml b/.github/ISSUE_TEMPLATE/compatibility-report.yml new file mode 100644 index 0000000..ac8729d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility-report.yml @@ -0,0 +1,55 @@ +name: Compatibility report / 兼容性报告 +description: Report a platform, package-version, or supported-runtime compatibility result. +title: "[Compatibility] " +labels: ["compatibility"] +body: + - type: markdown + attributes: + value: | + Compatibility is version-bound. Please do not run a Full Skin probe against a personal or production session just to fill this form. Remove private paths, user names, chats, and account data. + + - type: dropdown + id: platform + attributes: + label: Platform / 平台 + options: + - Windows x64 + - macOS Apple silicon + - macOS Intel + - Other or unsupported platform + validations: + required: true + + - type: dropdown + id: path + attributes: + label: Feature path / 功能路径 + options: + - Full Skin apply and restore + - Native fallback import + - Persistent replay + - Theme Manager build or launch + - App update channel + validations: + required: true + + - type: textarea + id: identity + attributes: + label: Version and target identity / 版本与目标身份 + description: State exact app and Theme Manager versions. Redact personal installation paths; package family/channel is enough. + validations: + required: true + + - type: textarea + id: result + attributes: + label: Result and verification boundary / 结果与验证边界 + description: Describe what worked or failed, what direct evidence you observed, and any step you intentionally did not run. + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe evidence / 脱敏证据 diff --git a/.github/ISSUE_TEMPLATE/performance-report.yml b/.github/ISSUE_TEMPLATE/performance-report.yml new file mode 100644 index 0000000..de0f8ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/performance-report.yml @@ -0,0 +1,51 @@ +name: Performance report / 性能报告 +description: Report measurable slowness, memory pressure, rendering jank, or generator regressions. +title: "[Performance] " +labels: ["performance"] +body: + - type: markdown + attributes: + value: | + Use privacy-safe measurements. Do not upload full process dumps, private pages, account data, or credentials. + + - type: dropdown + id: surface + attributes: + label: Surface / 受影响部分 + options: + - Gallery load, search, or filter + - Theme generator + - Theme Manager interface + - Full Skin apply or restore + - Persistent replay + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment and build / 环境与构建 + description: OS, architecture, app/browser version, catalog revision, and hardware class. Omit serial numbers and private paths. + validations: + required: true + + - type: textarea + id: measurement + attributes: + label: Measurement / 测量结果 + description: State baseline, observed time or memory, sample count, and how you measured it. + placeholder: "Baseline: ...\nObserved: ...\nSamples: ...\nMethod: ..." + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Reproduction path / 复现路径 + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Privacy-safe trace or screenshot / 脱敏追踪或截图 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d579fd2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: npm + directory: /apps/theme-manager + schedule: + interval: weekly + cooldown: + default-days: 7 + + - package-ecosystem: cargo + directory: /apps/theme-manager/src-tauri + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index a31b6c0..91931f0 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -1,4 +1,4 @@ -name: Build desktop theme manager +name: Verify theme artifacts, build Theme Manager, and publish gallery on: push: @@ -7,54 +7,121 @@ on: tags: - "v*" paths: - - ".github/workflows/desktop.yml" + - ".github/dependabot.yml" + - ".github/workflows/**" - "apps/theme-manager/**" - - "packages/full-skin/**" - - "scripts/prepare-desktop.mjs" - - "scripts/capture-full-skin-screenshots.mjs" - - "scripts/smoke-theme-manager.mjs" - - "scripts/lib/png.mjs" - - "themes/catalog.json" - - "themes/source-art/**" - - "screenshots/**" + - "assets/**" + - "docs/assets/**" + - "index.html" + - "installer/**" + - "LICENSE" + - "NOTICE.md" - "package.json" + - "packages/**" + - "schemas/**" + - "scripts/**" + - "screenshots/**" + - "site/**" + - "tests/**" + - "themes/**" pull_request: paths: - - ".github/workflows/desktop.yml" + - ".github/dependabot.yml" + - ".github/workflows/**" - "apps/theme-manager/**" - - "packages/full-skin/**" - - "scripts/prepare-desktop.mjs" - - "scripts/capture-full-skin-screenshots.mjs" - - "scripts/smoke-theme-manager.mjs" - - "scripts/lib/png.mjs" - - "themes/catalog.json" - - "themes/source-art/**" - - "screenshots/**" + - "assets/**" + - "docs/assets/**" + - "index.html" + - "installer/**" + - "LICENSE" + - "NOTICE.md" - "package.json" + - "packages/**" + - "schemas/**" + - "scripts/**" + - "screenshots/**" + - "site/**" + - "tests/**" + - "themes/**" workflow_dispatch: permissions: contents: read +concurrency: + group: theme-artifacts-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - validate: + prepare: + name: Generate and validate canonical artifacts + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + package-manager-cache: false + + - name: Generate, validate, and test public artifacts once + run: npm run test:ci + + - name: Prepare desktop assets once + run: npm run desktop:prepare + + - name: Upload generated artifact set + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: act-generated-theme-assets + path: | + themes/registry.json + themes/*/manifest.json + themes/*/assets/** + themes/*/previews/** + screenshots/*/presentation/** + packages/** + apps/theme-manager/build/** + retention-days: 2 + if-no-files-found: error + + a11y: + name: Verify Theme Manager accessibility runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 + package-manager-cache: false + + - name: Install accessibility test dependencies + run: npm ci --prefix apps/theme-manager - - name: Generate, validate, and test public artifacts - run: npm run generate && npm run validate && npm test + - name: Assert Theme Manager accessibility in Chromium + env: + ACT_CHROME_BIN: google-chrome + run: npm run a11y:check --prefix apps/theme-manager desktop: - needs: validate + name: Desktop ${{ matrix.os }} ${{ matrix.rust-target || 'native' }} + needs: prepare + runs-on: ${{ matrix.os }} permissions: - contents: write + contents: read strategy: fail-fast: false matrix: @@ -63,45 +130,40 @@ jobs: rust-target: "" build-args: "--bundles nsis" artifact: Awesome-Codex-Theme-Windows-x64 - release-asset-name: Awesome-Codex-Theme-[version]-Windows-x64[setup][ext] artifact-path: apps/theme-manager/src-tauri/target/release/bundle/nsis/*.exe - os: macos-14 rust-target: aarch64-apple-darwin build-args: "--target aarch64-apple-darwin --bundles app,dmg" artifact: Awesome-Codex-Theme-macOS-Apple-Silicon - release-asset-name: Awesome-Codex-Theme-[version]-macOS-Apple-Silicon[ext] artifact-path: apps/theme-manager/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg - os: macos-14 rust-target: x86_64-apple-darwin build-args: "--target x86_64-apple-darwin --bundles app,dmg" artifact: Awesome-Codex-Theme-macOS-Intel - release-asset-name: Awesome-Codex-Theme-[version]-macOS-Intel[ext] artifact-path: apps/theme-manager/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg - runs-on: ${{ matrix.os }} steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Download generated artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: act-generated-theme-assets + path: . - name: Set up Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 - cache: npm - cache-dependency-path: apps/theme-manager/package-lock.json + package-manager-cache: false - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: targets: ${{ matrix.rust-target }} - - name: Cache Rust build - uses: swatinem/rust-cache@v2 - with: - workspaces: apps/theme-manager/src-tauri -> target - - - name: Generate, validate, and prepare desktop assets - run: npm run generate && npm run validate && npm run desktop:prepare - - name: Validate Windows portable helper if: runner.os == 'Windows' run: npm run installer:validate @@ -113,15 +175,16 @@ jobs: run: npm run desktop:check - name: Build platform-untrusted CI artifact - if: ${{ !startsWith(github.ref, 'refs/tags/v') || vars.DESKTOP_RELEASE_READY != 'true' }} working-directory: apps/theme-manager run: npm run tauri -- build ${{ matrix.build-args }} - name: Verify macOS disk image, bundle identity, and ad-hoc signature - if: ${{ (!startsWith(github.ref, 'refs/tags/v') || vars.DESKTOP_RELEASE_READY != 'true') && startsWith(matrix.os, 'macos-') }} + if: startsWith(matrix.os, 'macos-') shell: bash run: | - artifacts=( ${{ matrix.artifact-path }} ) + dmg_dir="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/dmg" + shopt -s nullglob + artifacts=("$dmg_dir"/*.dmg) if [ "${#artifacts[@]}" -ne 1 ]; then echo "::error::Expected exactly one macOS DMG, found ${#artifacts[@]}." exit 1 @@ -137,11 +200,12 @@ jobs: trap cleanup EXIT hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" - app_path="$(find "$mount_dir" -maxdepth 1 -type d -name '*.app' -print -quit)" - if [ -z "$app_path" ]; then - echo "::error::The DMG does not contain a macOS application bundle." + apps=("$mount_dir"/*.app) + if [ "${#apps[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS application bundle." exit 1 fi + app_path="${apps[0]}" info_plist="$app_path/Contents/Info.plist" bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info_plist")" @@ -180,15 +244,121 @@ jobs: fi - name: Upload platform-untrusted CI artifact - if: ${{ !startsWith(github.ref, 'refs/tags/v') || vars.DESKTOP_RELEASE_READY != 'true' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ matrix.artifact }} path: ${{ matrix.artifact-path }} if-no-files-found: error + pages: + name: Build and verify gallery + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Download generated artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: act-generated-theme-assets + path: . + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + package-manager-cache: false + + - name: Build gallery from the canonical artifact set + run: npm run build + + - name: Assert live gallery behavior in Chromium + run: npm run gallery:check -- --screenshot gallery-home.png + + - name: Upload gallery screenshot evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: gallery-screenshot + path: gallery-home.png + if-no-files-found: warn + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 + with: + path: dist + + deploy: + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + needs: pages + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + steps: + - name: Deploy GitHub Pages + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 + + release: + if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' + needs: + - prepare + - desktop + name: Release ${{ matrix.os }} ${{ matrix.rust-target || 'native' }} + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rust-target: "" + build-args: "--bundles nsis" + release-asset-name: Awesome-Codex-Theme-[version]-Windows-x64[setup][ext] + - os: macos-14 + rust-target: aarch64-apple-darwin + build-args: "--target aarch64-apple-darwin --bundles app,dmg" + release-asset-name: Awesome-Codex-Theme-[version]-macOS-Apple-Silicon[ext] + - os: macos-14 + rust-target: x86_64-apple-darwin + build-args: "--target x86_64-apple-darwin --bundles app,dmg" + release-asset-name: Awesome-Codex-Theme-[version]-macOS-Intel[ext] + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Download generated artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: act-generated-theme-assets + path: . + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + package-manager-cache: false + + - name: Set up Rust + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + targets: ${{ matrix.rust-target }} + + - name: Install desktop dependencies + run: npm ci --prefix apps/theme-manager + - name: Check updater release credentials - if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' shell: bash env: ACT_UPDATER_PUBKEY: ${{ vars.TAURI_UPDATER_PUBKEY }} @@ -202,9 +372,15 @@ jobs: fi done - - name: Build updater-signed beta artifacts and publish the tagged release + - name: Verify release tag matches metadata if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' - uses: tauri-apps/tauri-action@v1 + shell: bash + env: + RELEASE_TAG: ${{ github.ref_name }} + run: test "$RELEASE_TAG" = "v0.3.2" + + - name: Build updater-signed beta artifacts and publish the tagged release + uses: tauri-apps/tauri-action@944946e3e4cac6603d1fe8f514171e9ecd3c78aa # v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACT_UPDATER_PUBKEY: ${{ vars.TAURI_UPDATER_PUBKEY }} @@ -214,17 +390,19 @@ jobs: projectPath: apps/theme-manager args: "--config src-tauri/tauri.release.conf.json ${{ matrix.build-args }}" tagName: ${{ github.ref_name }} - releaseName: "Awesome Codex Theme ${{ github.ref_name }} Beta" + releaseName: "Awesome Codex Theme v0.3.2 Beta" releaseAssetNamePattern: ${{ matrix.release-asset-name }} releaseBody: "Updater-signed Tauri Theme Manager beta with ACT Full Skin v1. Windows Authenticode and Apple Developer ID signing/notarization are not included in this beta, so the operating system may show an unknown-publisher warning. macOS bundles carry an ad-hoc signature for package integrity only. The app applies verified backgrounds, materials, colors, and theme copy without modifying ChatGPT application files." - prerelease: false + prerelease: true releaseDraft: true - name: Verify release macOS disk image and ad-hoc signature - if: startsWith(github.ref, 'refs/tags/v') && vars.DESKTOP_RELEASE_READY == 'true' && startsWith(matrix.os, 'macos-') + if: startsWith(matrix.os, 'macos-') shell: bash run: | - artifacts=( ${{ matrix.artifact-path }} ) + dmg_dir="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/dmg" + shopt -s nullglob + artifacts=("$dmg_dir"/*.dmg) if [ "${#artifacts[@]}" -ne 1 ]; then echo "::error::Expected exactly one release DMG, found ${#artifacts[@]}." exit 1 @@ -239,11 +417,12 @@ jobs: } trap cleanup EXIT hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_dir" - app_path="$(find "$mount_dir" -maxdepth 1 -type d -name '*.app' -print -quit)" - if [ -z "$app_path" ]; then - echo "::error::The release DMG does not contain a macOS application bundle." + apps=("$mount_dir"/*.app) + if [ "${#apps[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS application bundle." exit 1 fi + app_path="${apps[0]}" codesign --verify --deep --strict --verbose=2 "$app_path" signing_details="$(codesign --display --verbose=4 "$app_path" 2>&1)" @@ -253,11 +432,18 @@ jobs: exit 1 fi - updater_archives=( apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/macos/*.app.tar.gz ) - updater_signatures=( apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/macos/*.app.tar.gz.sig ) - if [ "${#updater_archives[@]}" -ne 1 ] || [ "${#updater_signatures[@]}" -ne 1 ]; then - echo "::error::Expected one macOS updater archive and one signature." + updater_dir="apps/theme-manager/src-tauri/target/${{ matrix.rust-target }}/release/bundle/macos" + updater_archives=("$updater_dir"/*.app.tar.gz) + if [ "${#updater_archives[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS updater archive." + exit 1 + fi + updater_archive="${updater_archives[0]}" + updater_signatures=("$updater_dir"/*.app.tar.gz.sig) + if [ "${#updater_signatures[@]}" -ne 1 ]; then + echo "::error::Expected exactly one macOS updater signature." exit 1 fi - test -s "${updater_archives[0]}" - test -s "${updater_signatures[0]}" + updater_signature="${updater_signatures[0]}" + test -s "$updater_archive" + test -s "$updater_signature" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 4b1948e..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Validate and publish gallery - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: github-pages - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up Node.js - uses: actions/setup-node@v7 - with: - node-version: 22 - - - name: Generate, validate, test, and build - run: npm run generate && npm run validate && npm test && npm run build - - - name: Capture gallery screenshot - shell: bash - run: | - node scripts/serve.mjs > server.log 2>&1 & - for attempt in {1..20}; do - if curl --fail --silent http://127.0.0.1:4173/ > /dev/null; then - break - fi - sleep 1 - done - google-chrome --headless=new --disable-gpu --no-sandbox --hide-scrollbars --window-size=1440,1100 --screenshot=gallery-home.png http://127.0.0.1:4173/ - test -s gallery-home.png - - - name: Upload screenshot evidence - uses: actions/upload-artifact@v7 - with: - name: gallery-screenshot - path: gallery-home.png - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: dist - - deploy: - if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - steps: - - name: Deploy GitHub Pages - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 0000000..703e7da --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,58 @@ +name: Lint workflow and supply-chain configuration + +on: + push: + branches: + - main + paths: + - ".github/dependabot.yml" + - ".github/workflows/**" + pull_request: + paths: + - ".github/dependabot.yml" + - ".github/workflows/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: supply-chain-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Lint GitHub Actions workflows + uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 + with: + reporter: github-pr-check + filter_mode: nofilter + fail_level: error + + zizmor: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Audit workflow security + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + with: + version: 1.28.0 + online-audits: "false" + advanced-security: "false" + annotations: "true" diff --git a/.gitignore b/.gitignore index 2f10c5c..faf8ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ themes/registry.json themes/*/ !themes/source-art/ !themes/source-art/** +screenshots/codex-beta-*/presentation/ .DS_Store Thumbs.db *.log diff --git a/README.md b/README.md index 2eb66ad..1110dd5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Awesome Codex Theme

- Full visual skins for Codex Desktop, backed by real app captures and code-free theme packs. + Full visual skins for Codex Desktop, backed by version-bound app-capture evidence and code-free theme packs.

@@ -23,12 +23,27 @@ Code-free packs

-![Saint Tibo theme running in the real ChatGPT Beta test bench](screenshots/codex-beta-26.715.3651.0/tibo-reset-saint-dark.png) +![Saint Tibo theme in the ChatGPT Beta v2 reference capture](screenshots/codex-beta-26.715.3651.0/tibo-reset-saint-dark.png) -The screenshot above comes from ChatGPT Beta `26.715.3651.0` after the skin was applied and read back from the live renderer. It is not a wallpaper pasted into an app mockup. +The screenshot above is a real `act-full-skin-runtime-v2` capture from ChatGPT Beta `26.715.3651.0`, after the skin was applied and read back from the live renderer. It is not a wallpaper pasted into an app mockup. The current v3 runtime is source-tested and marked recapture-required, so this image is reference evidence rather than a claim of current v3 app verification. > Desktop builds are published only on the official [GitHub Releases page](https://github.com/rwang23/awesome-codex-theme/releases). If that page has no asset compatible with your operating system and CPU, there is no supported public installer for that target. + +## Current platform capability matrix + +This table is generated from [`metadata/release.json`](metadata/release.json) and checked by `npm run metadata:check`. + +| Target | Full Skin | Native fallback | Persistence | +| --- | --- | --- | --- | +| Windows x64 | Build-only (`26.715.3651.0`) | Verified (`26.715.2305.0`) | Verified (`26.715.3651.0`) | +| macOS Apple silicon | Build-only | Build-only | Unverified | +| macOS Intel | Build-only | Build-only | Unverified | + +The current catalog has **68 themes** and **136 modes** (revision `2026072701`). +The current `beta` update-feed status is `unreleased`; in-app updates are not claimed until a signed static feed covers every platform. + + ## More than a color palette Awesome Codex Theme combines the parts that make a desktop skin feel complete: @@ -71,12 +86,16 @@ The complete agent contract, including expected checks and source-build instruct For contributors, the base requirement is Node.js 22+. Desktop builds also need stable Rust and the platform toolchain. ```bash -npm run generate -npm run validate -npm test -npm run build +npm test # fast, source-only checks; works in a clean checkout +npm run generate # expensive: renders the full generated catalog +npm run validate # generated-artifact validation +npm run test:generated # Registry/package/site assertions +npm run build # generated-artifact Pages build +npm run check:full # release-grade full generation, validation, test, desktop, and build path ``` +The generator streams one theme at a time, reuses verified mode-and-size outputs, and reports cache, elapsed-time, and peak-RSS telemetry. Use `npm run generate -- --ids=qinglan-odyssey --summary` for a targeted regeneration, `npm run generate:check -- --concurrency=1 --summary` for a reproducibility readback, and `npm run generate:benchmark -- --scenario=warm` for the 60-second warm-cache budget. + ### Method C: Install the desktop app manually 1. Download the build for your operating system and CPU from [GitHub Releases](https://github.com/rwang23/awesome-codex-theme/releases). @@ -90,7 +109,7 @@ The Windows implementation of **Apply & Keep Full Skin** has passed an exact Cha Theme updates and app updates are independent. The manager refreshes the verified Registry on startup, so newly published themes appear immediately without reinstalling or restarting the app. A signed app update is needed only when the manager itself changes, such as its runtime, compatibility rules, security boundary, platform integration, or interface. -The public beta uses Tauri updater signatures so future app updates can be verified. macOS bundles also carry an ad-hoc signature for package integrity, but Windows Authenticode, Apple Developer ID, and notarization are deferred. The operating system may still show an unknown-publisher warning; neither the updater signature nor the ad-hoc Mac signature removes SmartScreen or Gatekeeper warnings. See [release trust and signing](docs/release-signing.md). +The public beta uses Tauri updater signatures for the future signed-update path. The current update-feed state is intentionally **unreleased**: a GitHub prerelease alone does not enable in-app updates until a signed static feed covers every platform and passes action-after verification. macOS bundles also carry an ad-hoc signature for package integrity, but Windows Authenticode, Apple Developer ID, and notarization are deferred. The operating system may still show an unknown-publisher warning; neither the updater signature nor the ad-hoc Mac signature removes SmartScreen or Gatekeeper warnings. See [release metadata](docs/release-metadata.md) and [release trust and signing](docs/release-signing.md). ## See the real result @@ -129,7 +148,7 @@ The collection currently includes 68 themes and 136 light/dark modes. The Englis - 2 disclosed, unofficial Codex community tributes; - the broader original and fan-art catalog, available through language-aware discovery in the Gallery. -Every mode has a 1440×810 capture from the pinned Beta test bench. Registry records bind each capture to the exact app version, background hash, runtime hash, byte count, and renderer readback. +Every mode retains a 1440×810 v2 reference capture from the pinned Beta test bench. The generator derives integrity-bound 320, 480, and 960px presentation PNGs from that evidence; Gallery browsing uses those responsive variants, while the full original is loaded only after you explicitly choose **View original evidence**. Registry records bind both layers to the exact app version, background hash, runtime hash, byte count, and renderer readback; the current v3 runtime remains recapture-required until that exact test bench is rerun. “Saint Tibo” is an affectionate, unofficial community parody. It is not endorsed by OpenAI or by the person depicted, and no official product artwork is bundled. @@ -159,7 +178,7 @@ Use $create-codex-theme to add an original "Suzhou Canal Mist" theme. Keep the left work area quiet, provide light and dark modes, and run every check. ``` -The Skill prepares an art brief and image job, checks original or fan-art disclosure, configures safe areas and contrast, updates the Registry, and guides the real-app capture step. +The Skill prepares an art brief and image job, checks original or fan-art disclosure, configures focal composition, safe areas, and contrast, updates the Registry, and guides the real-app capture step. New source art must be 16:9 at 2560x1440 or larger. The current 1536x1024 catalog material has a named legacy exception and is not a template for new themes. Run `npm run art:review:write` to create the composition contact sheets, inspect them, then keep `npm run art:review` green before the exact-app capture. ## Join the community @@ -187,7 +206,7 @@ schemas/ Theme Pack and Registry schemas themes/catalog.json Hand-maintained theme catalog themes/source-art/ Image jobs, source art, and provenance themes/registry.json Generated public Registry -screenshots/ Version-pinned real app captures +screenshots/ Version-pinned app-capture evidence scripts/ Build, validation, capture, and site tooling site/ Dependency-free Gallery ``` @@ -196,9 +215,16 @@ Useful commands: ```bash npm run art:generate +npm test +npm run test:source +npm run test:generated +npm run test:ci npm run generate npm run generate:check +npm run generate -- --ids=qinglan-odyssey --summary +npm run generate:benchmark -- --scenario=warm npm run validate +npm run check:full npm run screenshots:capture npm run desktop:check npm run desktop:start diff --git a/README.zh-CN.md b/README.zh-CN.md index c071d7a..59d00b1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,7 @@

Awesome Codex Theme

- 面向 Codex 桌面版的完整视觉皮肤。主题包不带代码,展示图来自真实应用。 + 面向 Codex 桌面版的完整视觉皮肤。主题包不带代码,展示图是绑定版本的应用截图证据。

@@ -23,12 +23,27 @@ 主题包不带代码

-![提博大神主题在真实 ChatGPT Beta 测试台中的效果](screenshots/codex-beta-26.715.3651.0/tibo-reset-immortal-light.png) +![提博大神主题在 ChatGPT Beta v2 参考截图中的效果](screenshots/codex-beta-26.715.3651.0/tibo-reset-immortal-light.png) -上图来自 ChatGPT Beta `26.715.3651.0`:皮肤真正应用到页面并完成运行时读回后才截图,不是把壁纸贴进应用效果图。 +上图是 ChatGPT Beta `26.715.3651.0` 中 `act-full-skin-runtime-v2` 的真实截图:皮肤真正应用到页面并完成运行时读回后才截图,不是把壁纸贴进应用效果图。当前 v3 运行时已完成源码测试但标为需要重新采集,因此该图是参考证据,不是对当前 v3 实机验证的声明。 > 桌面安装包只会发布在官方 [GitHub Releases 页面](https://github.com/rwang23/awesome-codex-theme/releases)。如果页面上没有与你的操作系统和 CPU 匹配的文件,就表示当前没有支持该目标的公开安装包。 + +## 当前平台能力矩阵 + +此表由 [`metadata/release.json`](metadata/release.json) 生成,并由 `npm run metadata:check` 校验。 + +| 目标 | Full Skin | Native 回退 | 常驻 | +| --- | --- | --- | --- | +| Windows x64 | 仅构建 (`26.715.3651.0`) | 已验证 (`26.715.2305.0`) | 已验证 (`26.715.3651.0`) | +| macOS Apple 芯片 | 仅构建 | 仅构建 | 未验证 | +| macOS Intel | 仅构建 | 仅构建 | 未验证 | + +当前 Catalog 有 **68 套主题**、**136 个模式**(修订 `2026072701`)。 +当前 `beta` 通道更新源状态为 `unreleased`;在签名静态清单覆盖全部平台前,不宣称应用内更新可用。 + + ## 它不只是换一组颜色 Awesome Codex Theme 会把一套皮肤需要的部分一起应用: @@ -70,12 +85,16 @@ ChatGPT/Codex。如果当前没有兼容的 Release,请停止并告诉我, 贡献者需要 Node.js 22+。桌面构建还需要 Rust stable 和对应平台的构建工具。 ```bash -npm run generate -npm run validate -npm test -npm run build +npm test # 快速源码检查;干净检出即可运行 +npm run generate # 较慢:渲染完整生成目录 +npm run validate # 生成产物校验 +npm run test:generated # Registry、主题包与站点断言 +npm run build # 依赖生成产物的 Pages 构建 +npm run check:full # 发布级完整生成、校验、测试、桌面检查与构建 ``` +生成器按主题流式处理,复用已经验证的 mode/尺寸产物,并报告缓存、耗时与峰值 RSS。用 `npm run generate -- --ids=qinglan-odyssey --summary` 定向重建,用 `npm run generate:check -- --concurrency=1 --summary` 回读可复现性,用 `npm run generate:benchmark -- --scenario=warm` 检查 60 秒的热缓存预算。 + ### 方式 C:手动安装桌面应用 1. 从 [GitHub Releases](https://github.com/rwang23/awesome-codex-theme/releases) 下载与你的系统和 CPU 匹配的版本。 @@ -89,7 +108,7 @@ Windows 版“应用并保持完整皮肤”已经通过 ChatGPT Beta `26.715.36 主题更新与应用更新彼此独立。管理器启动时会刷新经过验证的 Registry,所以新发布的主题无需重装或重启应用就会直接出现。只有管理器自身的运行时、兼容规则、安全边界、平台集成或界面发生变化时,才需要安装签名应用更新。 -公开 Beta 使用 Tauri updater 签名来验证以后收到的应用更新;macOS 应用包还带有只用于包完整性的 ad-hoc 签名。Windows Authenticode、Apple Developer ID 与公证暂时延后,所以系统仍可能显示未知发布者提示。Updater 与 ad-hoc 签名都不能消除 SmartScreen 或 Gatekeeper 提示,详见[发布信任与签名](docs/release-signing.md)。 +公开 Beta 为未来的签名更新链路保留 Tauri updater 签名。当前更新源状态明确为**尚未发布**:仅有 GitHub prerelease 并不代表可以应用内更新,必须先有覆盖全部平台的签名静态清单并完成 action-after 验证。macOS 应用包还带有只用于包完整性的 ad-hoc 签名。Windows Authenticode、Apple Developer ID 与公证暂时延后,所以系统仍可能显示未知发布者提示。Updater 与 ad-hoc 签名都不能消除 SmartScreen 或 Gatekeeper 提示,详见[发布元数据](docs/release-metadata.md)与[发布信任与签名](docs/release-signing.md)。 ## 真实使用效果 @@ -130,7 +149,7 @@ Windows 版“应用并保持完整皮肤”已经通过 ChatGPT Beta `26.715.36 - 5 套面向美国工作场景的原创主题,包括海岸工作室和深夜餐馆; - 1 套 2007 桌面怀旧 Q 版主题,以及 2 套 Codex 社区致意主题。 -每个模式都有固定 Beta 测试台生成的 1440×810 实机截图。Registry 会把截图与准确应用版本、背景哈希、运行时哈希、字节数和页面读回结果绑定。 +每个模式都有固定 Beta 测试台生成的 1440×810 v2 参考截图。生成器会从这份证据派生带完整性绑定的 320、480、960px 展示 PNG;Gallery 日常浏览只加载这些响应式版本,只有明确点击“查看原始证据”才会加载完整原图。Registry 会把两层素材与准确应用版本、背景哈希、运行时哈希、字节数和页面读回结果绑定;当前 v3 运行时要在该准确测试台重新采集后,才可成为当前实机验证。 “提博大神”是社区表达喜爱的非官方戏仿与致意,不代表 OpenAI 或被描绘者背书,也不包含官方产品素材。 @@ -159,7 +178,7 @@ Beta 包,并且只连接 `app://` 页面目标。 左侧保留安静的工作区,提供明暗模式,并运行全部检查。 ``` -Skill 会准备美术 brief 和 image job,检查原创或 Fan Art 披露,配置安全区与对比度,更新 Registry,并引导完成真实应用截图。 +Skill 会准备美术 brief 和 image job,检查原创或 Fan Art 披露,配置焦点构图、安全区与对比度,更新 Registry,并引导完成真实应用截图。新源图必须是 2560×1440 或更高的 16:9 图像。现有目录里的 1536×1024 素材只有具名历史例外,不是新主题模板。先运行 `npm run art:review:write` 生成并检查构图联系表,再用 `npm run art:review` 固定审批结果,之后才进入准确应用版本的实机截图。 ## 加入社区 @@ -187,7 +206,7 @@ schemas/ Theme Pack 与 Registry Schema themes/catalog.json 人工维护的主题目录 themes/source-art/ image job、源图与 provenance themes/registry.json 自动生成的公共 Registry -screenshots/ 绑定版本的真实应用截图 +screenshots/ 绑定版本的应用截图证据 scripts/ 构建、校验、截图与站点工具 site/ 无依赖 Gallery ``` @@ -196,9 +215,16 @@ site/ 无依赖 Gallery ```bash npm run art:generate +npm test +npm run test:source +npm run test:generated +npm run test:ci npm run generate npm run generate:check +npm run generate -- --ids=qinglan-odyssey --summary +npm run generate:benchmark -- --scenario=warm npm run validate +npm run check:full npm run screenshots:capture npm run desktop:check npm run desktop:start diff --git a/apps/theme-manager/package-lock.json b/apps/theme-manager/package-lock.json index 27b1108..1683e21 100644 --- a/apps/theme-manager/package-lock.json +++ b/apps/theme-manager/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.2", "license": "MIT", "devDependencies": { - "@tauri-apps/cli": "2.11.4" + "@tauri-apps/cli": "2.11.4", + "axe-core": "4.12.1" } }, "node_modules/@tauri-apps/cli": { @@ -243,6 +244,16 @@ "engines": { "node": ">= 10" } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } } } } diff --git a/apps/theme-manager/package.json b/apps/theme-manager/package.json index eec8925..54ea318 100644 --- a/apps/theme-manager/package.json +++ b/apps/theme-manager/package.json @@ -7,10 +7,11 @@ "scripts": { "tauri": "tauri", "prepare:assets": "node ../../scripts/prepare-desktop.mjs && tauri icon build/icon.png --output src-tauri/icons", + "a11y:check": "node scripts/verify-a11y.mjs", "start": "npm run prepare:assets && tauri dev", "pretest": "npm run prepare:assets", "test": "cargo test --manifest-path src-tauri/Cargo.toml", - "check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo test --manifest-path src-tauri/Cargo.toml", + "check": "node ../../scripts/prepare-desktop.mjs && cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo test --manifest-path src-tauri/Cargo.toml", "build": "npm run prepare:assets && tauri build", "build:win": "npm run prepare:assets && tauri build --bundles nsis", "build:mac": "npm run prepare:assets && tauri build --bundles dmg", @@ -18,7 +19,8 @@ "build:mac:release": "npm run prepare:assets && tauri build --config src-tauri/tauri.release.conf.json --bundles dmg" }, "devDependencies": { - "@tauri-apps/cli": "2.11.4" + "@tauri-apps/cli": "2.11.4", + "axe-core": "4.12.1" }, "author": "Awesome Codex Theme contributors", "license": "MIT" diff --git a/apps/theme-manager/scripts/verify-a11y.mjs b/apps/theme-manager/scripts/verify-a11y.mjs new file mode 100644 index 0000000..e2e30ae --- /dev/null +++ b/apps/theme-manager/scripts/verify-a11y.mjs @@ -0,0 +1,605 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(SCRIPT_DIR, ".."); +const RENDERER_ROOT = path.join(APP_ROOT, "src", "renderer"); +const AXE_SOURCE_PATH = path.join(APP_ROOT, "node_modules", "axe-core", "axe.min.js"); +const DEFAULT_TIMEOUT_MS = 30_000; +const PROCESS_STOP_TIMEOUT_MS = 2_000; + +const dataImage = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1600' height='900'%3E%3Crect width='100%25' height='100%25' fill='%23246A4B'/%3E%3C/svg%3E"; +const fixtureTheme = { + id: "a11y-fixture", + collection: "fixture", + rightsProfile: "original", + variant: "cinematic", + name: { en: "Accessibility Fixture", "zh-CN": "无障碍测试主题" }, + tagline: { en: "Keyboard and dialog fixture", "zh-CN": "键盘与对话框测试" }, + description: { en: "A local renderer fixture for accessibility checks.", "zh-CN": "用于无障碍检查的本地渲染器测试主题。" }, + tags: ["fixture"], + previews: { + light: { + imageUrl: dataImage, + capture: { appVersion: "fixture", sha256: "a".repeat(64) }, + fullSkin: { testedVersion: "fixture" }, + }, + dark: { + imageUrl: dataImage, + capture: { appVersion: "fixture", sha256: "b".repeat(64) }, + fullSkin: { testedVersion: "fixture" }, + }, + }, +}; +const fixtureThemeTwo = { + ...fixtureTheme, + id: "a11y-fixture-second", + name: { en: "Second Accessibility Fixture", "zh-CN": "第二个无障碍测试主题" }, + tagline: { en: "Keyboard selection fixture", "zh-CN": "键盘选择测试" }, +}; +const fixtureCatalog = { + catalogRevision: 1, + registrySha256: "c".repeat(64), + source: "bundled", + collections: [{ id: "fixture", name: { en: "Fixture", "zh-CN": "测试" } }], + themes: [fixtureTheme, fixtureThemeTwo], +}; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function testBridgeSource() { + const bootstrap = { + appVersion: "0.0.0-a11y", + platform: "win32", + targets: [{ channel: "beta", label: "ChatGPT Beta", version: "fixture" }], + skinState: { active: true, themeId: fixtureTheme.id, mode: "light", channel: "beta" }, + persistenceState: { enabled: false, phase: "disabled" }, + updateState: { status: "development" }, + catalogState: { status: "bundled" }, + catalog: fixtureCatalog, + }; + return ` +const fixtureCatalog = ${JSON.stringify(fixtureCatalog)}; +const fixtureBootstrap = ${JSON.stringify(bootstrap)}; +const clone = (value) => JSON.parse(JSON.stringify(value)); +window.__ACT_A11Y__ = { calls: [] }; +const disabledPersistence = () => ({ enabled: false, phase: "disabled" }); +let persistence = disabledPersistence(); +async function invoke(command, args) { + window.__ACT_A11Y__.calls.push({ command, args }); + switch (command) { + case "bootstrap": return clone(fixtureBootstrap); + case "refresh_catalog": return { status: "current", catalog: clone(fixtureCatalog) }; + case "get_skin_state": + case "restore_full_skin": return { active: false }; + case "get_persistence_state": return clone(persistence); + case "enable_persistent_theme": + persistence = { + enabled: true, + phase: "active", + themeId: args.themeId, + mode: args.mode, + channel: args.channel, + autostartEnabled: true, + }; + return clone(persistence); + case "disable_persistent_theme": + persistence = disabledPersistence(); + return clone(persistence); + case "check_for_app_update": return { status: "development" }; + case "copy_theme": + case "open_external": + case "install_app_update": return null; + default: throw new Error("Unexpected accessibility fixture command: " + command); + } +} +window.__TAURI__ = { + core: { convertFileSrc: (value) => value, invoke }, + event: { listen: async () => () => {} }, + window: { + getCurrentWindow: () => ({ + close: async () => {}, + minimize: async () => {}, + startDragging: async () => {}, + toggleMaximize: async () => {}, + }), + }, +}; +if (new URL(window.location.href).searchParams.has("fallback")) { + Object.defineProperty(window.HTMLDialogElement.prototype, "showModal", { configurable: true, value: undefined }); + Object.defineProperty(window.HTMLDialogElement.prototype, "close", { configurable: true, value: undefined }); +} +`; +} + +function contentType(file) { + const types = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + }; + return types[path.extname(file).toLowerCase()] || "application/octet-stream"; +} + +async function startFixtureServer() { + const axeSource = await readFile(AXE_SOURCE_PATH, "utf8").catch(() => { + throw new Error("axe-core is unavailable. Run npm ci --prefix apps/theme-manager first."); + }); + const server = createServer(async (request, response) => { + try { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); + if (requestUrl.pathname === "/test-bridge.js") { + response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" }); + response.end(testBridgeSource()); + return; + } + if (requestUrl.pathname === "/axe.min.js") { + response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" }); + response.end(axeSource); + return; + } + const relative = requestUrl.pathname === "/" ? "index.html" : requestUrl.pathname.replace(/^\/+/, ""); + const candidate = path.resolve(RENDERER_ROOT, decodeURIComponent(relative)); + const boundary = RENDERER_ROOT.endsWith(path.sep) ? RENDERER_ROOT : RENDERER_ROOT + path.sep; + if (!candidate.startsWith(boundary)) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + const info = await stat(candidate); + if (!info.isFile()) throw new Error("Fixture path is not a file"); + let body = await readFile(candidate); + if (path.basename(candidate) === "index.html") { + const marker = ''; + const html = body.toString("utf8"); + if (!html.includes(marker)) throw new Error("Renderer entrypoint marker is missing"); + body = Buffer.from(html.replace(marker, '\n ' + marker)); + } + response.writeHead(200, { "Content-Type": contentType(candidate), "Cache-Control": "no-store" }); + response.end(body); + } catch { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Could not start the accessibility fixture server"); + return { server, url: "http://127.0.0.1:" + address.port + "/" }; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function waitFor(description, probe, timeoutMs = DEFAULT_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() < deadline) { + try { + const value = await probe(); + if (value) return value; + } catch (error) { + lastError = error; + } + await sleep(100); + } + const detail = lastError ? ": " + lastError.message : ""; + throw new Error("Timed out waiting for " + description + detail); +} + +function defaultBrowserCommand() { + if (process.env.ACT_CHROME_BIN) return process.env.ACT_CHROME_BIN; + return process.platform === "win32" + ? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" + : "google-chrome"; +} + +function startBrowser(profile) { + const child = spawn(defaultBrowserCommand(), [ + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--disable-dev-shm-usage", + "--remote-allow-origins=*", + "--remote-debugging-port=0", + "--user-data-dir=" + profile, + "about:blank", + ], { stdio: ["ignore", "ignore", "pipe"], windowsHide: true }); + let startupError = null; + let stderr = ""; + child.once("error", (error) => { + startupError = error; + }); + child.stderr.on("data", (chunk) => { + stderr = (stderr + chunk).slice(-4_000); + }); + return { + child, + assertRunning() { + if (startupError) throw new Error("Chromium could not start: " + startupError.message); + if (child.exitCode !== null) throw new Error("Chromium exited early" + (stderr ? ": " + stderr.trim() : "")); + }, + }; +} + +async function stopBrowser(browser) { + if (!browser?.child) return; + const { child } = browser; + const exited = () => child.exitCode !== null || child.signalCode !== null; + const waitForExit = async () => { + if (exited()) return; + await Promise.race([ + new Promise((resolve) => child.once("exit", resolve)), + sleep(PROCESS_STOP_TIMEOUT_MS), + ]); + }; + if (!exited()) { + child.kill(); + await waitForExit(); + } + if (!exited()) { + child.kill("SIGKILL"); + await waitForExit(); + } +} + +class DevToolsSession { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (!message.id) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(message.error.message)); + else pending.resolve(message.result || {}); + }); + socket.addEventListener("close", () => { + for (const pending of this.pending.values()) pending.reject(new Error("DevTools connection closed")); + this.pending.clear(); + }); + } + + send(method, params = {}) { + const id = this.nextId; + this.nextId += 1; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + close() { + this.socket.close(); + } +} + +async function connectDevTools(webSocketDebuggerUrl) { + const socket = new WebSocket(webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", () => reject(new Error("Could not connect to Chromium DevTools")), { once: true }); + }); + return new DevToolsSession(socket); +} + +async function evaluate(session, expression) { + const result = await session.send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || "Runtime evaluation failed"); + return result.result?.value; +} + +async function pressKey(session, key, code, windowsVirtualKeyCode, modifiers = 0, text = "") { + await session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key, + code, + text, + unmodifiedText: text, + modifiers, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key, + code, + modifiers, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + }); +} + +async function pressTab(session) { + await session.send("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key: "Tab", + code: "Tab", + windowsVirtualKeyCode: 9, + nativeVirtualKeyCode: 9, + }); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Tab", + code: "Tab", + windowsVirtualKeyCode: 9, + nativeVirtualKeyCode: 9, + }); +} + +async function pressCopyShortcut(session) { + await session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); + await pressKey(session, "c", "KeyC", 67, 2, "c"); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); +} + +async function pressCommandEnter(session) { + await session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); + await pressKey(session, "Enter", "Enter", 13, 2, "\r"); + await session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Control", + code: "ControlLeft", + windowsVirtualKeyCode: 17, + nativeVirtualKeyCode: 17, + }); +} + +async function loadAxe(session, siteUrl) { + await evaluate(session, `new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = ${JSON.stringify(siteUrl + "axe.min.js")}; + script.addEventListener("load", () => resolve(true), { once: true }); + script.addEventListener("error", () => reject(new Error("Could not load axe-core")), { once: true }); + document.head.append(script); + })`); +} + +async function assertNoSeriousAxeViolations(session) { + const violations = await evaluate(session, `window.axe.run(document, { resultTypes: ["violations"] }) + .then(({ violations }) => violations + .filter(({ impact }) => impact === "serious" || impact === "critical") + .map(({ id, impact, nodes }) => ({ id, impact, targets: nodes.map(({ target }) => target.join(" ")).slice(0, 3) })))`); + if (violations?.length) { + throw new Error("axe found serious or critical violations: " + JSON.stringify(violations)); + } +} + +async function main() { + const { server, url } = await startFixtureServer(); + const profile = await mkdtemp(path.join(os.tmpdir(), "act-theme-manager-a11y-")); + const browser = startBrowser(profile); + let session = null; + + try { + await waitFor("Chromium DevTools", async () => { + browser.assertRunning(); + const activePort = await readFile(path.join(profile, "DevToolsActivePort"), "utf8").catch(() => null); + const port = Number(activePort?.split(/\r?\n/, 1)[0]); + return Number.isInteger(port) && port > 0 ? port : null; + }); + const debugPort = Number((await readFile(path.join(profile, "DevToolsActivePort"), "utf8")).split(/\r?\n/, 1)[0]); + const targetResponse = await fetch( + "http://127.0.0.1:" + debugPort + "/json/new?" + encodeURIComponent("about:blank"), + { method: "PUT" }, + ); + if (!targetResponse.ok) throw new Error("Chromium could not create an accessibility target"); + const target = await targetResponse.json(); + if (!target.webSocketDebuggerUrl) throw new Error("Chromium target did not provide a DevTools endpoint"); + + session = await connectDevTools(target.webSocketDebuggerUrl); + await Promise.all([ + session.send("Page.enable"), + session.send("Runtime.enable"), + ]); + await session.send("Page.navigate", { url }); + await waitFor("the Theme Manager accessibility fixture", async () => evaluate(session, `(() => { + const theme = document.querySelector("[data-theme-id]"); + return Boolean(theme && document.querySelector("#applySkin") && window.__ACT_A11Y__); + })()`)); + + const semantics = await evaluate(session, `(() => ({ + dialog: document.querySelector("#persistenceConsentDialog")?.tagName, + compositeRoles: document.querySelectorAll("[role='tablist'], [role='listbox']").length, + firstTheme: document.querySelector("[data-theme-id]")?.tagName, + firstThemePressed: document.querySelector("[data-theme-id]")?.getAttribute("aria-pressed"), + }))()`); + if (semantics?.dialog !== "DIALOG" || semantics.compositeRoles !== 0 + || semantics.firstTheme !== "BUTTON" || semantics.firstThemePressed !== "true") { + throw new Error("Theme Manager selector semantics are not accessible: " + JSON.stringify(semantics)); + } + + await loadAxe(session, url); + await assertNoSeriousAxeViolations(session); + + const selectedSecondTheme = await evaluate(session, `(() => { + const theme = document.querySelector("[data-theme-id='a11y-fixture-second']"); + theme?.focus(); + return document.activeElement === theme; + })()`); + if (!selectedSecondTheme) throw new Error("A theme selector is not keyboard focusable"); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("keyboard theme selection", async () => evaluate( + session, + `document.querySelector("[data-theme-id='a11y-fixture-second']")?.getAttribute("aria-pressed") === "true"`, + )); + + await evaluate(session, `document.querySelector("#search")?.focus()`); + await pressCommandEnter(session); + const inputShortcutOpenedDialog = await evaluate(session, `document.querySelector("#persistenceConsentDialog")?.open === true`); + if (inputShortcutOpenedDialog) throw new Error("Ctrl/Cmd+Enter applied a theme while an editable control was focused"); + + const focusedApply = await evaluate(session, `(() => { + const apply = document.querySelector("#applySkin"); + apply?.focus(); + return document.activeElement === apply; + })()`); + if (!focusedApply) throw new Error("The primary apply action is not keyboard focusable"); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("the native persistence dialog", async () => evaluate( + session, + `document.querySelector("#persistenceConsentDialog")?.open === true`, + )); + await assertNoSeriousAxeViolations(session); + await pressKey(session, "Escape", "Escape", 27); + await waitFor("Escape to close and restore dialog focus", async () => evaluate( + session, + `(() => !document.querySelector("#persistenceConsentDialog")?.open && document.activeElement?.id === "applySkin")()`, + )); + + await session.send("Page.navigate", { url: url + "?confirm=1" }); + await waitFor("the native confirmation fixture", async () => evaluate(session, `(() => { + const apply = document.querySelector("#applySkin"); + return Boolean(apply && window.__ACT_A11Y__ && !document.querySelector("#persistenceConsentDialog")?.open); + })()`)); + await evaluate(session, `document.querySelector("#applySkin")?.focus()`); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("the native confirmation dialog", async () => evaluate( + session, + `document.querySelector("#persistenceConsentDialog")?.open === true`, + )); + await evaluate(session, `document.querySelector("#persistenceConsentConfirm")?.focus()`); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + let confirmationState = null; + try { + await waitFor("keyboard application confirmation", async () => { + confirmationState = await evaluate(session, `(() => { + const applied = window.__ACT_A11Y__.calls.some(({ command }) => command === "enable_persistent_theme"); + return applied + && !document.querySelector("#persistenceConsentDialog")?.open + && document.querySelector("#persistenceStatus")?.textContent?.trim().length > 0; + })()`); + return confirmationState; + }); + } catch (error) { + throw new Error(error.message + ": " + JSON.stringify(confirmationState)); + } + + const copyBefore = await evaluate(session, `(() => { + const status = document.querySelector("#skinStatus"); + const range = document.createRange(); + range.selectNodeContents(status); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + return window.__ACT_A11Y__.calls.filter(({ command }) => command === "copy_theme").length; + })()`); + await pressCopyShortcut(session); + const copyAfter = await evaluate(session, `(() => window.__ACT_A11Y__.calls + .filter(({ command }) => command === "copy_theme").length)()`); + if (copyAfter !== copyBefore) { + throw new Error("Ctrl/Cmd+C intercepted selected diagnostic text instead of preserving native copy"); + } + + const restoreBefore = await evaluate(session, `(() => { + const restore = document.querySelector("#restoreSkin"); + restore?.focus(); + return { + focused: document.activeElement === restore, + calls: window.__ACT_A11Y__.calls.filter(({ command }) => command === "restore_full_skin").length, + }; + })()`); + if (!restoreBefore?.focused) throw new Error("The Restore action is not keyboard focusable"); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("keyboard restore activation", async () => evaluate( + session, + `window.__ACT_A11Y__.calls.filter(({ command }) => command === "restore_full_skin").length === ${restoreBefore.calls + 1}`, + )); + + await session.send("Emulation.setEmulatedMedia", { + features: [ + { name: "forced-colors", value: "active" }, + { name: "prefers-reduced-motion", value: "reduce" }, + ], + }); + await session.send("Page.navigate", { url: url + "?fallback=1" }); + await waitFor("the fallback consent fixture", async () => evaluate(session, `(() => { + const apply = document.querySelector("#applySkin"); + return Boolean( + apply + && window.__ACT_A11Y__ + && !document.querySelector("#persistenceConsentDialog")?.open + && matchMedia("(forced-colors: active)").matches + && matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + })()`)); + await loadAxe(session, url); + await assertNoSeriousAxeViolations(session); + await evaluate(session, `document.querySelector("#applySkin")?.focus()`); + await pressKey(session, "Enter", "Enter", 13, 0, "\r"); + await waitFor("the fallback consent dialog", async () => evaluate( + session, + `(() => { + const dialog = document.querySelector("#persistenceConsentDialog"); + return dialog?.dataset.fallback === "true" + && dialog.open === true + && document.querySelector(".app-shell")?.getAttribute("aria-hidden") === "true"; + })()`, + )); + await assertNoSeriousAxeViolations(session); + await pressTab(session); + await pressTab(session); + const fallbackFocusLooped = await evaluate(session, `document.activeElement?.id === "persistenceConsentCancel"`); + if (!fallbackFocusLooped) throw new Error("Fallback dialog did not retain Tab focus within its controls"); + await pressKey(session, "Escape", "Escape", 27); + await waitFor("fallback Escape to restore dialog focus", async () => evaluate( + session, + `(() => { + const dialog = document.querySelector("#persistenceConsentDialog"); + return !dialog?.open + && !dialog?.dataset.fallback + && !document.querySelector(".app-shell")?.hasAttribute("aria-hidden") + && document.activeElement?.id === "applySkin"; + })()`, + )); + + console.log("Theme Manager accessibility check passed: native and fallback dialog focus, keyboard select/apply/cancel/restore, shortcut context, forced-colors/reduced-motion, and axe serious/critical scans."); + } finally { + session?.close(); + await stopBrowser(browser); + await new Promise((resolve) => server.close(resolve)); + await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 }); + } +} + +try { + await main(); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/apps/theme-manager/src-tauri/src/cache.rs b/apps/theme-manager/src-tauri/src/cache.rs new file mode 100644 index 0000000..8eee560 --- /dev/null +++ b/apps/theme-manager/src-tauri/src/cache.rs @@ -0,0 +1,86 @@ +use std::{ + fs, + io::Write, + path::Path, + sync::atomic::{AtomicU64, Ordering}, +}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub fn publish_new_file(destination: &Path, bytes: &[u8], label: &str) -> Result { + let parent = destination + .parent() + .ok_or_else(|| format!("{label}路径无效"))?; + fs::create_dir_all(parent).map_err(|error| format!("无法创建{label}目录:{error}"))?; + let file_name = destination + .file_name() + .ok_or_else(|| format!("{label}路径无效"))? + .to_string_lossy(); + let temporary = parent.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed), + )); + let write_result = (|| { + let mut candidate = fs::File::create(&temporary) + .map_err(|error| format!("无法写入{label}候选文件:{error}"))?; + candidate + .write_all(bytes) + .map_err(|error| format!("无法写入{label}候选文件:{error}"))?; + candidate + .sync_all() + .map_err(|error| format!("无法同步{label}候选文件:{error}")) + })(); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary); + return Err(error); + } + + // A hard link only succeeds when the destination does not exist. This gives + // us an atomic, never-overwrite publication on every supported platform, + // leaving a valid last-known-good cache untouched if a concurrent writer wins. + match fs::hard_link(&temporary, destination) { + Ok(()) => { + let _ = fs::remove_file(&temporary); + Ok(true) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let _ = fs::remove_file(&temporary); + Ok(false) + } + Err(error) => { + let _ = fs::remove_file(&temporary); + Err(format!("无法原子发布{label}候选文件:{error}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publishing_a_candidate_never_replaces_an_existing_file() { + let directory = std::env::temp_dir().join(format!( + "awesome-codex-theme-cache-test-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed), + )); + let destination = directory.join("catalog-cache.json"); + + assert!( + publish_new_file(&destination, b"last-known-good", "测试缓存") + .expect("first candidate should publish") + ); + assert!( + !publish_new_file(&destination, b"replacement", "测试缓存") + .expect("existing candidate should be retained") + ); + assert_eq!( + fs::read(&destination).expect("existing candidate should remain readable"), + b"last-known-good", + ); + + fs::remove_dir_all(&directory).expect("test cache directory should be removable"); + } +} diff --git a/apps/theme-manager/src-tauri/src/catalog.rs b/apps/theme-manager/src-tauri/src/catalog.rs index 6beb589..264c707 100644 --- a/apps/theme-manager/src-tauri/src/catalog.rs +++ b/apps/theme-manager/src-tauri/src/catalog.rs @@ -10,12 +10,28 @@ use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager}; +use crate::{cache, network}; + pub const REMOTE_SITE_ROOT: &str = "https://rwang23.github.io/awesome-codex-theme/"; const REMOTE_CATALOG_URL: &str = "https://rwang23.github.io/awesome-codex-theme/downloads/catalog.json"; const REMOTE_REGISTRY_URL: &str = "https://rwang23.github.io/awesome-codex-theme/themes/registry.json"; const MODES: [&str; 2] = ["light", "dark"]; +const MAX_CATALOG_BYTES: usize = 256 * 1024; +const MAX_REGISTRY_BYTES: usize = 8 * 1024 * 1024; +const CACHE_FILE_PREFIX: &str = "catalog-cache-"; +const CACHE_FILE_SUFFIX: &str = ".json"; +const PRESENTATION_SIZES: [(u64, u64); 3] = [(320, 180), (480, 270), (960, 540)]; + +#[derive(Clone)] +struct ReleaseContract { + full_skin_format: String, + native_tested_version: String, + capture_version: String, + capture_package: String, + capture_fixture: String, +} #[derive(Clone)] pub struct Catalog { @@ -155,6 +171,46 @@ fn valid_sha256(value: &str) -> bool { value.len() == 64 && value.chars().all(|character| character.is_ascii_hexdigit()) } +fn validate_runtime_evidence(value: &Value) -> Result<(), String> { + // Registries produced before evidence state existed remain safe to display as + // reference-only in the renderer. New registries must make that state explicit. + if value.is_null() { + return Ok(()); + } + let evidence = object(value, "runtimeEvidence")?; + let status = evidence["status"].as_str().unwrap_or_default(); + if !matches!(status, "verified" | "reference") { + return fail("Runtime 证据状态无效"); + } + let captured = object(&evidence["capturedRuntime"], "capturedRuntime")?; + let current = object(&evidence["currentRuntime"], "currentRuntime")?; + let captured_version = captured["implementationVersion"] + .as_str() + .unwrap_or_default(); + let captured_sha = captured["sha256"].as_str().unwrap_or_default(); + let current_version = current["implementationVersion"] + .as_str() + .unwrap_or_default(); + let current_sha = current["sha256"].as_str().unwrap_or_default(); + let verification = current["verification"].as_str().unwrap_or_default(); + if captured_version.is_empty() + || current_version.is_empty() + || !valid_sha256(captured_sha) + || !valid_sha256(current_sha) + { + return fail("Runtime 证据记录无效"); + } + if (status == "verified" + && (verification != "verified" + || captured_version != current_version + || captured_sha != current_sha)) + || (status == "reference" && verification != "source-tested-recapture-required") + { + return fail("Runtime 证据状态与版本不一致"); + } + Ok(()) +} + fn valid_hex_color(value: &str) -> bool { value.len() == 7 && value.starts_with('#') @@ -163,6 +219,158 @@ fn valid_hex_color(value: &str) -> bool { .all(|character| character.is_ascii_hexdigit()) } +fn required_string(value: &Value, label: &str) -> Result { + value + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("{label} 必须是非空字符串")) +} + +fn validate_release(registry: &Value) -> Result { + let release = object(®istry["release"], "release")?; + let product = object( + release.get("product").unwrap_or(&Value::Null), + "release.product", + )?; + for field in ["name", "repository", "releases"] { + required_string( + product.get(field).unwrap_or(&Value::Null), + &format!("release.product.{field}"), + )?; + } + let desktop = object( + release.get("desktop").unwrap_or(&Value::Null), + "release.desktop", + )?; + required_string( + desktop.get("version").unwrap_or(&Value::Null), + "release.desktop.version", + )?; + if !matches!( + desktop.get("channel").and_then(Value::as_str), + Some("beta" | "stable") + ) { + return fail("release.desktop.channel 无效"); + } + let updater = object( + desktop.get("updater").unwrap_or(&Value::Null), + "release.desktop.updater", + )?; + if !matches!( + updater.get("status").and_then(Value::as_str), + Some("unreleased" | "available") + ) { + return fail("release.desktop.updater.status 无效"); + } + localized( + updater.get("message").unwrap_or(&Value::Null), + "release.desktop.updater.message", + )?; + let runtime = object( + release.get("runtime").unwrap_or(&Value::Null), + "release.runtime", + )?; + let full_skin_format = required_string( + runtime.get("fullSkinFormat").unwrap_or(&Value::Null), + "release.runtime.fullSkinFormat", + )?; + if full_skin_format != "act-full-skin-v1" { + return fail("release.runtime.fullSkinFormat 不受支持"); + } + required_string( + runtime.get("implementationVersion").unwrap_or(&Value::Null), + "release.runtime.implementationVersion", + )?; + required_string( + runtime.get("sourceArtRenderer").unwrap_or(&Value::Null), + "release.runtime.sourceArtRenderer", + )?; + let native_tested_version = required_string( + runtime.get("nativeTestedVersion").unwrap_or(&Value::Null), + "release.runtime.nativeTestedVersion", + )?; + let capture = object( + release.get("capture").unwrap_or(&Value::Null), + "release.capture", + )?; + if capture.get("channel").and_then(Value::as_str) != Some("beta") { + return fail("release.capture.channel 必须是 beta"); + } + let capture_version = required_string( + capture.get("appVersion").unwrap_or(&Value::Null), + "release.capture.appVersion", + )?; + let capture_package = required_string( + capture.get("packageFullName").unwrap_or(&Value::Null), + "release.capture.packageFullName", + )?; + if !capture_package.starts_with("OpenAI.CodexBeta_") { + return fail("release.capture.packageFullName 不是 Codex Beta 包"); + } + let capture_fixture = required_string( + capture.get("fixture").unwrap_or(&Value::Null), + "release.capture.fixture", + )?; + required_string( + capture.get("modelLabel").unwrap_or(&Value::Null), + "release.capture.modelLabel", + )?; + Ok(ReleaseContract { + full_skin_format, + native_tested_version, + capture_version, + capture_package, + capture_fixture, + }) +} + +fn validate_capabilities(value: &Value) -> Result<(), String> { + let capabilities = array(value, "capabilities")?; + if capabilities.is_empty() || capabilities.len() > 16 { + return fail("capabilities 数量超出边界"); + } + let mut ids = HashSet::new(); + for capability in capabilities { + let id = capability["id"].as_str().unwrap_or_default(); + if !safe_id(id) || !ids.insert(id.to_owned()) { + return fail("capability ID 无效或重复"); + } + if !matches!(capability["platform"].as_str(), Some("windows" | "macos")) + || !matches!( + capability["architecture"].as_str(), + Some("x64" | "aarch64" | "x86_64") + ) + { + return fail(format!("{id} capability 平台或架构无效")); + } + localized(&capability["title"], &format!("{id} capability title"))?; + for feature_name in ["fullSkin", "native", "persistence"] { + let feature = object(&capability[feature_name], &format!("{id} {feature_name}"))?; + if !matches!( + feature.get("status").and_then(Value::as_str), + Some("verified" | "build-only" | "unverified") + ) { + return fail(format!("{id} {feature_name} status 无效")); + } + feature + .get("testedVersion") + .and_then(Value::as_str) + .ok_or_else(|| format!("{id} {feature_name} testedVersion 无效"))?; + localized( + feature.get("detail").unwrap_or(&Value::Null), + &format!("{id} {feature_name} detail"), + )?; + } + } + for id in ["windows-x64", "macos-aarch64", "macos-x86-64"] { + if !ids.contains(id) { + return fail(format!("capabilities 缺少 {id}")); + } + } + Ok(()) +} + fn validate_native_theme(value: &str, mode: &str) -> Result<(), String> { let body = value .strip_prefix("codex-theme-v1:") @@ -227,6 +435,7 @@ fn validate_mode( theme: &Value, mode: &str, native_values: &mut HashSet, + release: &ReleaseContract, ) -> Result<(), String> { let record = theme["previews"] .get(mode) @@ -237,37 +446,66 @@ fn validate_mode( } let capture = &record["capture"]; - let capture_path = capture["path"].as_str().unwrap_or_default(); + let evidence = &capture["evidence"]; + let capture_path = evidence["path"].as_str().unwrap_or_default(); if !safe_relative_path(capture_path) || !capture_path.starts_with("screenshots/") - || !capture["sha256"].as_str().is_some_and(valid_sha256) - || !capture["assetSha256"].as_str().is_some_and(valid_sha256) - || !capture["runtimeSha256"].as_str().is_some_and(valid_sha256) - || capture["markerVersion"].as_str() != Some("act-full-skin-v1") - || capture["width"].as_u64() != Some(1440) - || capture["height"].as_u64() != Some(810) - || capture["fixture"].as_str() != Some("full-skin-home-v1") - || capture["appVersion"] - .as_str() - .unwrap_or_default() - .is_empty() + || !evidence["sha256"].as_str().is_some_and(valid_sha256) + || !evidence["assetSha256"].as_str().is_some_and(valid_sha256) + || !evidence["runtimeSha256"].as_str().is_some_and(valid_sha256) + || evidence["markerVersion"].as_str() != Some(release.full_skin_format.as_str()) + || evidence["width"].as_u64() != Some(1440) + || evidence["height"].as_u64() != Some(810) + || evidence["appVersion"].as_str() != Some(release.capture_version.as_str()) + || evidence["packageFullName"].as_str() != Some(release.capture_package.as_str()) + || evidence["fixture"].as_str() != Some(release.capture_fixture.as_str()) { return fail(format!("{theme_id} {mode} 实机截图记录无效")); } + let (capture_directory, capture_file) = capture_path + .rsplit_once('/') + .ok_or_else(|| format!("{theme_id} {mode} 实机截图路径无效"))?; + let capture_stem = capture_file + .strip_suffix(".png") + .ok_or_else(|| format!("{theme_id} {mode} 实机截图格式无效"))?; + let presentation = array(&capture["presentation"], "capture.presentation")?; + if presentation.len() != PRESENTATION_SIZES.len() { + return fail(format!("{theme_id} {mode} 缩略图数量无效")); + } + for (width, height) in PRESENTATION_SIZES { + let variant = presentation + .iter() + .find(|candidate| candidate["width"].as_u64() == Some(width)) + .ok_or_else(|| format!("{theme_id} {mode} 缺少 {width}px 缩略图"))?; + let variant_path = variant["path"].as_str().unwrap_or_default(); + let expected_path = format!("{capture_directory}/presentation/{capture_stem}-{width}.png"); + if !safe_relative_path(variant_path) + || variant_path != expected_path + || variant["format"].as_str() != Some("png") + || variant["height"].as_u64() != Some(height) + || !variant["sha256"].as_str().is_some_and(valid_sha256) + || variant["bytes"] + .as_u64() + .is_none_or(|bytes| bytes == 0 || bytes > 1_048_576) + || variant["evidenceSha256"] != evidence["sha256"] + || !variant["renderFingerprint"] + .as_str() + .is_some_and(valid_sha256) + { + return fail(format!("{theme_id} {mode} {width}px 缩略图记录无效")); + } + } let full_skin = &record["fullSkin"]; let full_skin_asset = full_skin["asset"].as_str().unwrap_or_default(); - if full_skin["format"].as_str() != Some("act-full-skin-v1") + if full_skin["format"].as_str() != Some(release.full_skin_format.as_str()) || !safe_relative_path(full_skin_asset) || full_skin_asset != format!("themes/{theme_id}/assets/background-{mode}.png") || !full_skin["sha256"].as_str().is_some_and(valid_sha256) || full_skin["sha256"] != record["assetSha256"] || full_skin["bytes"].as_u64().is_none() || full_skin["bytes"] != record["assetBytes"] - || full_skin["testedVersion"] - .as_str() - .unwrap_or_default() - .is_empty() + || full_skin["testedVersion"].as_str() != Some(release.capture_version.as_str()) { return fail(format!("{theme_id} {mode} Full Skin 记录无效")); } @@ -303,6 +541,7 @@ fn validate_mode( if native["format"].as_str() != Some("codex-theme-v1") || !safe_relative_path(native_path) || !native["sha256"].as_str().is_some_and(valid_sha256) + || native["testedVersion"].as_str() != Some(release.native_tested_version.as_str()) { return fail(format!("{theme_id} {mode} Native 记录无效")); } @@ -331,7 +570,10 @@ pub fn validate_registry(bytes: &[u8]) -> Result { { return fail("Registry 标准不受支持"); } + validate_runtime_evidence(®istry["runtimeEvidence"])?; + let release = validate_release(®istry)?; + validate_capabilities(®istry["capabilities"])?; let collections = array(®istry["collections"], "collections")?; let themes = array(®istry["themes"], "themes")?; if collections.is_empty() || themes.is_empty() || themes.len() > 500 { @@ -367,7 +609,7 @@ pub fn validate_registry(bytes: &[u8]) -> Result { return fail(format!("{id} 权利标记无效")); } for mode in MODES { - validate_mode(id, theme, mode, &mut native_values)?; + validate_mode(id, theme, mode, &mut native_values, &release)?; } } Ok(registry) @@ -384,30 +626,21 @@ fn bundled_registry_path(app: &AppHandle) -> Result { .map_err(|error| format!("无法定位内置 Registry:{error}")) } -fn cache_path(app: &AppHandle) -> Result { +fn cache_directory(app: &AppHandle) -> Result { app.path() .app_cache_dir() - .map(|path| path.join("catalog-cache.json")) .map_err(|error| format!("无法定位主题缓存:{error}")) } -pub fn load_bundled(app: &AppHandle) -> Result { - let bytes = fs::read(bundled_registry_path(app)?) - .map_err(|error| format!("无法读取内置 Registry:{error}"))?; - let registry = validate_registry(&bytes)?; - Ok(Catalog { - source: "bundled".into(), - registry_sha256: sha256_hex(&bytes), - registry_bytes: bytes.len(), - registry, - asset_base_url: None, - }) +fn cache_path_for_hash(directory: &Path, registry_sha256: &str) -> PathBuf { + directory.join(format!( + "{CACHE_FILE_PREFIX}{registry_sha256}{CACHE_FILE_SUFFIX}" + )) } -pub fn load_cached(app: &AppHandle) -> Result { - let bytes = fs::read(cache_path(app)?).map_err(|error| format!("无法读取主题缓存:{error}"))?; +fn catalog_from_cache_bytes(bytes: &[u8]) -> Result { let cache: CatalogCache = - serde_json::from_slice(&bytes).map_err(|error| format!("主题缓存无效:{error}"))?; + serde_json::from_slice(bytes).map_err(|error| format!("主题缓存无效:{error}"))?; if cache.schema_version != 1 || sha256_hex(cache.registry_text.as_bytes()) != cache.registry_sha256 { @@ -423,11 +656,79 @@ pub fn load_cached(app: &AppHandle) -> Result { }) } +fn cache_candidate_paths(directory: &Path) -> Result, String> { + let mut candidates = Vec::new(); + let legacy = directory.join("catalog-cache.json"); + if legacy.is_file() { + candidates.push(legacy); + } + + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidates), + Err(error) => return Err(format!("无法读取主题缓存目录:{error}")), + }; + for entry in entries { + let entry = entry.map_err(|error| format!("无法读取主题缓存目录:{error}"))?; + if !entry + .file_type() + .map_err(|error| format!("无法读取主题缓存条目:{error}"))? + .is_file() + { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(CACHE_FILE_PREFIX) && name.ends_with(CACHE_FILE_SUFFIX) { + candidates.push(entry.path()); + } + } + candidates.sort(); + Ok(candidates) +} + +pub fn load_bundled(app: &AppHandle) -> Result { + let bytes = fs::read(bundled_registry_path(app)?) + .map_err(|error| format!("无法读取内置 Registry:{error}"))?; + let registry = validate_registry(&bytes)?; + Ok(Catalog { + source: "bundled".into(), + registry_sha256: sha256_hex(&bytes), + registry_bytes: bytes.len(), + registry, + asset_base_url: None, + }) +} + +pub fn load_cached(app: &AppHandle) -> Result { + let directory = cache_directory(app)?; + let mut selected = None; + let mut last_error = None; + for path in cache_candidate_paths(&directory)? { + let candidate = fs::read(&path) + .map_err(|error| format!("无法读取主题缓存:{error}")) + .and_then(|bytes| catalog_from_cache_bytes(&bytes)); + match candidate { + Ok(candidate) + if selected + .as_ref() + .is_none_or(|current: &Catalog| revision(&candidate) > revision(current)) => + { + selected = Some(candidate); + } + Ok(_) => {} + Err(error) => last_error = Some(error), + } + } + selected.ok_or_else(|| last_error.unwrap_or_else(|| "未找到可验证的主题缓存".into())) +} + pub fn save_cached(app: &AppHandle, catalog: &Catalog, registry_text: &str) -> Result<(), String> { - let destination = cache_path(app)?; - let parent = destination.parent().ok_or("主题缓存路径无效")?; - fs::create_dir_all(parent).map_err(|error| format!("无法创建主题缓存目录:{error}"))?; - let temporary = destination.with_extension("json.tmp"); + if !valid_sha256(&catalog.registry_sha256) { + return fail("主题缓存 Registry 哈希无效"); + } + let directory = cache_directory(app)?; + let destination = cache_path_for_hash(&directory, &catalog.registry_sha256); let payload = CatalogCache { schema_version: 1, registry_sha256: catalog.registry_sha256.clone(), @@ -435,14 +736,32 @@ pub fn save_cached(app: &AppHandle, catalog: &Catalog, registry_text: &str) -> R }; let bytes = serde_json::to_vec_pretty(&payload) .map_err(|error| format!("无法序列化主题缓存:{error}"))?; - fs::write(&temporary, bytes).map_err(|error| format!("无法写入主题缓存:{error}"))?; + if let Ok(existing) = fs::read(&destination) + && let Ok(existing) = catalog_from_cache_bytes(&existing) + && existing.registry_sha256 == catalog.registry_sha256 + { + return Ok(()); + } if destination.exists() { - fs::remove_file(&destination).map_err(|error| format!("无法替换主题缓存:{error}"))?; + fs::remove_file(&destination).map_err(|error| format!("无法移除无效主题缓存:{error}"))?; + } + if cache::publish_new_file(&destination, &bytes, "主题缓存")? { + return Ok(()); + } + let existing = fs::read(&destination).map_err(|error| format!("无法读取主题缓存:{error}"))?; + let existing = catalog_from_cache_bytes(&existing)?; + if existing.registry_sha256 == catalog.registry_sha256 { + Ok(()) + } else { + fail("主题缓存候选与当前 Registry 不匹配") } - fs::rename(&temporary, &destination).map_err(|error| format!("无法提交主题缓存:{error}")) } -async fn fetch_bytes(client: &reqwest::Client, url: &str) -> Result, String> { +async fn fetch_bytes( + client: &reqwest::Client, + url: &str, + max_bytes: usize, +) -> Result, String> { let response = client .get(url) .header("Accept", "application/json") @@ -452,19 +771,17 @@ async fn fetch_bytes(client: &reqwest::Client, url: &str) -> Result, Str if !response.status().is_success() { return fail(format!("主题目录返回 HTTP {}", response.status())); } - response - .bytes() - .await - .map(|bytes| bytes.to_vec()) - .map_err(|error| format!("无法读取主题目录响应:{error}")) + network::read_capped_response(response, max_bytes, "application/json", "主题目录").await } pub async fn load_remote() -> Result<(Catalog, String), String> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(8)) + .connect_timeout(Duration::from_secs(3)) + .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| format!("无法创建主题目录客户端:{error}"))?; - let manifest_bytes = fetch_bytes(&client, REMOTE_CATALOG_URL).await?; + let manifest_bytes = fetch_bytes(&client, REMOTE_CATALOG_URL, MAX_CATALOG_BYTES).await?; let manifest: Value = serde_json::from_slice(&manifest_bytes) .map_err(|error| format!("Catalog manifest 无效:{error}"))?; if manifest["schemaVersion"].as_u64() != Some(1) @@ -480,34 +797,44 @@ pub async fn load_remote() -> Result<(Catalog, String), String> { .ok_or("远程 Registry 哈希无效")?; let expected_bytes = manifest["registry"]["bytes"] .as_u64() - .ok_or("远程 Registry 字节数无效")? as usize; + .ok_or("远程 Registry 字节数无效") + .and_then(|bytes| usize::try_from(bytes).map_err(|_| "远程 Registry 字节数无效"))?; + if expected_bytes == 0 || expected_bytes > MAX_REGISTRY_BYTES { + return fail("远程 Registry 声明字节数超出安全上限"); + } let expected_revision = manifest["registry"]["catalogRevision"] .as_u64() .filter(|revision| *revision > 0) .ok_or("远程 Registry 版本无效")?; let expected_themes = manifest["registry"]["themeCount"] .as_u64() - .ok_or("远程 Registry 主题数无效")? as usize; + .ok_or("远程 Registry 主题数无效") + .and_then(|count| usize::try_from(count).map_err(|_| "远程 Registry 主题数无效"))?; - let registry_bytes = fetch_bytes(&client, REMOTE_REGISTRY_URL).await?; + let registry_bytes = fetch_bytes(&client, REMOTE_REGISTRY_URL, expected_bytes).await?; if registry_bytes.len() != expected_bytes || sha256_hex(®istry_bytes) != expected_hash { return fail("远程 Registry 大小或哈希不匹配"); } - let registry = validate_registry(®istry_bytes)?; + let registry_text = String::from_utf8(registry_bytes) + .map_err(|error| format!("远程 Registry 不是 UTF-8:{error}"))?; + let registry = validate_registry(registry_text.as_bytes())?; if registry["catalogRevision"].as_u64() != Some(expected_revision) { return fail("远程 Registry 版本不匹配"); } if registry["themes"].as_array().map(Vec::len) != Some(expected_themes) { return fail("远程 Registry 主题数量不匹配"); } - let registry_text = String::from_utf8(registry_bytes.clone()) - .map_err(|error| format!("远程 Registry 不是 UTF-8:{error}"))?; + if manifest["release"] != registry["release"] + || manifest["capabilities"] != registry["capabilities"] + { + return fail("远程 Registry 发布元数据不匹配"); + } Ok(( Catalog { source: "remote".into(), registry, registry_sha256: expected_hash.into(), - registry_bytes: registry_bytes.len(), + registry_bytes: registry_text.len(), asset_base_url: Some(REMOTE_SITE_ROOT.into()), }, registry_text, @@ -523,6 +850,16 @@ fn local_capture_path(app: &AppHandle, relative: &str) -> Result Ok(path.to_string_lossy().into_owned()) } +fn presentation_path(capture: &Value, width: u64) -> Result<&str, String> { + let variant = array(&capture["presentation"], "capture.presentation")? + .iter() + .find(|candidate| candidate["width"].as_u64() == Some(width)) + .ok_or_else(|| format!("缺少 {width}px 缩略图"))?; + variant["path"] + .as_str() + .ok_or_else(|| format!("{width}px 缩略图路径无效")) +} + pub fn present_catalog(app: &AppHandle, catalog: &Catalog) -> Result { let collections = catalog.registry["collections"].clone(); let themes = array(&catalog.registry["themes"], "themes")? @@ -532,19 +869,28 @@ pub fn present_catalog(app: &AppHandle, catalog: &Catalog) -> Result Result Result #[cfg(test)] mod tests { use super::*; + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + time::Duration, + }; const REGISTRY: &[u8] = include_bytes!("../../../../themes/registry.json"); + const CATALOG: &[u8] = include_bytes!("../../../../themes/catalog.json"); + + fn serve_chunked_json(chunks: Vec>) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test server should bind"); + let address = listener + .local_addr() + .expect("test server should expose an address"); + let worker = thread::spawn(move || { + let (mut stream, _) = listener + .accept() + .expect("test server should accept a request"); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", + ) + .expect("test server should write response headers"); + for chunk in chunks { + stream + .write_all(format!("{:X}\r\n", chunk.len()).as_bytes()) + .expect("test server should write chunk size"); + stream + .write_all(&chunk) + .expect("test server should write chunk body"); + stream + .write_all(b"\r\n") + .expect("test server should write chunk terminator"); + } + stream + .write_all(b"0\r\n\r\n") + .expect("test server should finish chunked response"); + }); + (format!("http://{address}/catalog.json"), worker) + } + + fn serve_response( + content_type: &str, + content_length: Option, + body: Vec, + delay: Duration, + ) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test server should bind"); + let address = listener + .local_addr() + .expect("test server should expose an address"); + let content_length = content_length + .map(|length| format!("Content-Length: {length}\r\n")) + .unwrap_or_default(); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\n{content_length}Connection: close\r\n\r\n" + ); + let worker = thread::spawn(move || { + let (mut stream, _) = listener + .accept() + .expect("test server should accept a request"); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + thread::sleep(delay); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(&body); + }); + (format!("http://{address}/catalog.json"), worker) + } + + fn test_client(timeout: Duration) -> reqwest::Client { + crate::install_crypto_provider(); + reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("test client should build") + } #[test] - fn bundled_registry_has_sixty_eight_valid_dual_mode_themes() { + fn bundled_registry_matches_the_source_catalog() { let registry = validate_registry(REGISTRY).expect("registry should validate"); - assert_eq!(registry["catalogRevision"].as_u64(), Some(2026072201)); - assert_eq!(registry["themes"].as_array().map(Vec::len), Some(68)); + let catalog: Value = serde_json::from_slice(CATALOG).expect("source catalog should parse"); + assert_eq!(registry["catalogRevision"], catalog["catalogRevision"]); + assert_eq!( + registry["themes"].as_array().map(Vec::len), + catalog["themes"].as_array().map(Vec::len), + ); + } + + #[test] + fn registry_rejects_malformed_runtime_evidence() { + let mut registry: Value = + serde_json::from_slice(REGISTRY).expect("registry JSON should parse"); + registry["runtimeEvidence"]["status"] = json!("unknown"); + let encoded = serde_json::to_vec(®istry).expect("registry JSON should encode"); + assert!(validate_registry(&encoded).is_err()); } #[test] @@ -688,4 +1127,88 @@ mod tests { .starts_with("codex-theme-v1:") ); } + + #[test] + fn remote_catalog_fetch_rejects_an_oversized_chunked_response() { + let (url, worker) = serve_chunked_json(vec![vec![b'x'; 96 * 1024]]); + let client = test_client(Duration::from_secs(1)); + let result = tauri::async_runtime::block_on(fetch_bytes(&client, &url, 64 * 1024)); + worker.join().expect("test server should finish"); + + assert!( + result.is_err(), + "chunked catalog responses must be rejected before they can be fully buffered" + ); + } + + #[test] + fn remote_catalog_fetch_rejects_an_oversized_declared_response() { + let (url, worker) = serve_response( + "application/json", + Some(64 * 1024 + 1), + Vec::new(), + Duration::ZERO, + ); + let client = test_client(Duration::from_secs(1)); + let result = tauri::async_runtime::block_on(fetch_bytes(&client, &url, 64 * 1024)); + worker.join().expect("test server should finish"); + + assert!( + result.is_err(), + "declared catalog responses above the hard limit must be rejected before reading" + ); + } + + #[test] + fn remote_catalog_fetch_rejects_the_wrong_content_type() { + let (url, worker) = serve_response("text/html", Some(2), b"{}".to_vec(), Duration::ZERO); + let client = test_client(Duration::from_secs(1)); + let result = tauri::async_runtime::block_on(fetch_bytes(&client, &url, 64 * 1024)); + worker.join().expect("test server should finish"); + + assert!( + result.is_err(), + "catalog responses with an unexpected content type must be rejected" + ); + } + + #[test] + fn remote_catalog_fetch_rejects_truncated_data() { + let (url, worker) = + serve_response("application/json", Some(64), b"{}".to_vec(), Duration::ZERO); + let client = test_client(Duration::from_secs(1)); + let result = tauri::async_runtime::block_on(fetch_bytes(&client, &url, 64 * 1024)); + worker.join().expect("test server should finish"); + + assert!(result.is_err(), "truncated catalog data must be rejected"); + } + + #[test] + fn remote_catalog_fetch_times_out_before_response_headers_arrive() { + let (url, worker) = serve_response( + "application/json", + Some(2), + b"{}".to_vec(), + Duration::from_millis(250), + ); + let client = test_client(Duration::from_millis(50)); + let result = tauri::async_runtime::block_on(fetch_bytes(&client, &url, 64 * 1024)); + worker.join().expect("test server should finish"); + + assert!( + result.is_err(), + "catalog requests must respect the client timeout" + ); + } + + #[test] + fn registry_rejects_invalid_presentation_metadata() { + let mut registry: Value = serde_json::from_slice(REGISTRY).expect("registry should parse"); + registry["themes"][0]["previews"]["light"]["capture"]["presentation"][0]["format"] = + json!("jpg"); + assert!( + validate_registry(&serde_json::to_vec(®istry).expect("registry should serialize")) + .is_err() + ); + } } diff --git a/apps/theme-manager/src-tauri/src/lib.rs b/apps/theme-manager/src-tauri/src/lib.rs index 82e786e..12302a3 100644 --- a/apps/theme-manager/src-tauri/src/lib.rs +++ b/apps/theme-manager/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ +mod cache; mod catalog; +mod network; mod persistence; mod platform; mod skin_runtime; @@ -47,6 +49,7 @@ struct BootstrapView { catalog: Value, catalog_state: CatalogStatus, targets: Vec, + target_diagnostic: Option, skin_state: skin_runtime::SkinRuntimeView, persistence_state: persistence::PersistenceView, update_state: UpdateView, @@ -150,18 +153,25 @@ async fn bootstrap( .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); + let target_discovery = platform::discover_targets(); Ok(BootstrapView { app_version: app.package_info().version.to_string(), platform: platform_name(), catalog: catalog::present_catalog(&app, &catalog)?, catalog_state, - targets: platform::discover_targets(), + targets: target_discovery.targets, + target_diagnostic: target_discovery.diagnostic, skin_state: state.skin_runtime.current(), persistence_state: persistence::current(&app)?, update_state: state.updater.current(), }) } +#[tauri::command] +fn discover_targets() -> platform::TargetDiscovery { + platform::discover_targets() +} + #[tauri::command] async fn refresh_catalog(app: AppHandle, state: State<'_, DesktopState>) -> Result { publish_catalog( @@ -316,6 +326,15 @@ async fn disable_persistent_theme( persistence::disable(&app) } +#[tauri::command] +fn force_restart_persistent_theme( + app: AppHandle, + channel: String, + confirmed: bool, +) -> Result { + persistence::force_restart(&app, &channel, confirmed) +} + #[tauri::command] fn open_external(app: AppHandle, target: String) -> Result { let url = match target.as_str() { @@ -376,6 +395,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ bootstrap, + discover_targets, refresh_catalog, copy_theme, open_codex, @@ -385,6 +405,7 @@ pub fn run() { get_persistence_state, enable_persistent_theme, disable_persistent_theme, + force_restart_persistent_theme, open_external, check_for_app_update, install_app_update diff --git a/apps/theme-manager/src-tauri/src/network.rs b/apps/theme-manager/src-tauri/src/network.rs new file mode 100644 index 0000000..8f10ede --- /dev/null +++ b/apps/theme-manager/src-tauri/src/network.rs @@ -0,0 +1,46 @@ +use reqwest::{Response, header::CONTENT_TYPE}; + +pub async fn read_capped_response( + mut response: Response, + max_bytes: usize, + expected_content_type: &str, + label: &str, +) -> Result, String> { + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim); + if !content_type.is_some_and(|value| value.eq_ignore_ascii_case(expected_content_type)) { + return Err(format!("{label} 响应 Content-Type 不受支持")); + } + + if response + .content_length() + .is_some_and(|content_length| content_length > max_bytes as u64) + { + return Err(format!("{label} 响应超过安全字节上限")); + } + + let capacity = response + .content_length() + .map(|content_length| content_length.min(max_bytes as u64) as usize) + .unwrap_or_default(); + let mut bytes = Vec::with_capacity(capacity); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("无法读取{label}响应:{error}"))? + { + let next_length = bytes + .len() + .checked_add(chunk.len()) + .ok_or_else(|| format!("{label} 响应超过安全字节上限"))?; + if next_length > max_bytes { + return Err(format!("{label} 响应超过安全字节上限")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} diff --git a/apps/theme-manager/src-tauri/src/persistence.rs b/apps/theme-manager/src-tauri/src/persistence.rs index d4a2358..52f98ce 100644 --- a/apps/theme-manager/src-tauri/src/persistence.rs +++ b/apps/theme-manager/src-tauri/src/persistence.rs @@ -485,6 +485,7 @@ fn retry_delay(attempt: u8) -> Option { enum ControllerDecision { Wait, Blocked, + AwaitUserClose, ApplyStopped, Refresh, Active, @@ -500,10 +501,18 @@ fn controller_decision( if phase == "retry-blocked" { return ControllerDecision::Blocked; } + if target_running && matches!(phase, "needs-user-close" | "force-closing") { + return ControllerDecision::AwaitUserClose; + } if !target_running { return if matches!( phase, - APPLY_REQUESTED_PHASE | "restarting" | "retrying" | "error" + APPLY_REQUESTED_PHASE + | "restarting" + | "retrying" + | "error" + | "needs-user-close" + | "force-closing" ) { ControllerDecision::ApplyStopped } else { @@ -531,6 +540,43 @@ async fn wait_until_stopped(target: &platform::TargetView) -> Result<(), String> Err("ChatGPT 没有在安全等待时间内退出".into()) } +fn force_restart_is_authorized(confirmed: bool, phase: &str) -> Result<(), String> { + if !confirmed { + return Err("必须确认未发送的输入可能丢失,才能强制关闭 ChatGPT。".into()); + } + if !matches!(phase, "needs-user-close" | "force-closing") { + return Err("只有正常退出超时后才能请求强制关闭 ChatGPT。".into()); + } + Ok(()) +} + +pub fn force_restart( + app: &AppHandle, + channel: &str, + confirmed: bool, +) -> Result { + let root = persistence_root(app)?; + let selection = read_state_from(&root)?; + if !selection.enabled || selection.channel.as_deref() != Some(channel) { + return Err("所选 ChatGPT 不是当前启用的常驻主题目标。".into()); + } + force_restart_is_authorized(confirmed, &selection.phase)?; + let target = platform::find_target(channel)?; + if !platform::target_is_running(&target)? { + return current(app); + } + platform::force_stop_target(&target)?; + update_status( + &root, + "force-closing", + Some("已请求强制关闭所选 ChatGPT,正在等待其退出后重新应用。".into()), + selection.attempts, + target.version, + selection.tested_version, + )?; + current(app) +} + async fn run_controller(app: AppHandle) -> Result<(), String> { let root = persistence_root(&app)?; fs::create_dir_all(&root).map_err(|error| format!("无法创建主题常驻状态目录:{error}"))?; @@ -629,6 +675,10 @@ async fn run_controller(app: AppHandle) -> Result<(), String> { tokio::time::sleep(POLL_INTERVAL).await; continue; } + ControllerDecision::AwaitUserClose => { + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } ControllerDecision::Wait => { attempts = 0; next_retry = tokio::time::Instant::now(); @@ -798,13 +848,15 @@ async fn run_controller(app: AppHandle) -> Result<(), String> { if let Err(error) = wait_until_stopped(&target).await { update_status( &root, - "retrying", - Some(error), + "needs-user-close", + Some(format!( + "已请求所选 ChatGPT 正常退出,但安全等待超时:{error}" + )), attempts, installed, Some(skin.tested_version), )?; - next_retry = tokio::time::Instant::now() + delay; + next_retry = tokio::time::Instant::now(); continue; } match skin_runtime::apply(&app, &state.skin_runtime, &catalog, theme_id, mode, channel) @@ -978,6 +1030,14 @@ mod tests { controller_decision("retry-blocked", false, false, false), ControllerDecision::Blocked ); + assert_eq!( + controller_decision("needs-user-close", false, false, false), + ControllerDecision::ApplyStopped + ); + assert_eq!( + controller_decision("force-closing", false, false, false), + ControllerDecision::ApplyStopped + ); } #[test] @@ -994,5 +1054,21 @@ mod tests { controller_decision(APPLY_REQUESTED_PHASE, true, false, false), ControllerDecision::Restart ); + assert_eq!( + controller_decision("needs-user-close", true, false, false), + ControllerDecision::AwaitUserClose + ); + assert_eq!( + controller_decision("force-closing", true, false, false), + ControllerDecision::AwaitUserClose + ); + } + + #[test] + fn force_restart_requires_a_second_confirmation_after_graceful_timeout() { + assert!(force_restart_is_authorized(false, "needs-user-close").is_err()); + assert!(force_restart_is_authorized(true, "active").is_err()); + assert!(force_restart_is_authorized(true, "needs-user-close").is_ok()); + assert!(force_restart_is_authorized(true, "force-closing").is_ok()); } } diff --git a/apps/theme-manager/src-tauri/src/platform.rs b/apps/theme-manager/src-tauri/src/platform.rs index 6b24dba..59e91e1 100644 --- a/apps/theme-manager/src-tauri/src/platform.rs +++ b/apps/theme-manager/src-tauri/src/platform.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TargetView { pub channel: String, @@ -27,8 +27,37 @@ pub struct TargetView { executable_path: Option, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveryDiagnostic { + pub code: String, + pub detail: String, +} + +impl DiscoveryDiagnostic { + fn new(code: &str, detail: impl Into) -> Self { + Self { + code: code.into(), + detail: detail.into(), + } + } + + fn message(&self) -> String { + format!("[{}] {}", self.code, self.detail) + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetDiscovery { + pub targets: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostic: Option, +} + #[cfg(target_os = "windows")] const WINDOWS_TARGET_SCRIPT: &str = r#" +$ErrorActionPreference = "Stop" $definitions = @( @{ channel = "stable"; package = "OpenAI.Codex"; executable = "app\ChatGPT.exe"; label = "ChatGPT" }, @{ channel = "beta"; package = "OpenAI.CodexBeta"; executable = "app\ChatGPT (Beta).exe"; label = "ChatGPT Beta" } @@ -43,11 +72,13 @@ foreach ($definition in $definitions) { $applications = @($manifest.Package.Applications.Application | Where-Object { "$($_.Executable)".Replace("/", "\") -ieq $definition.executable }) - if ($applications.Count -ne 1) { continue } + if ($applications.Count -ne 1) { + throw "ACT_MALFORMED_PACKAGE: $($definition.package) has unexpected application metadata" + } $family = "$($package.PackageFamilyName)" $applicationId = "$($applications[0].Id)" if ($family -notmatch "^[A-Za-z0-9._-]{1,128}$" -or $applicationId -notmatch "^[A-Za-z0-9._-]{1,64}$") { - continue + throw "ACT_MALFORMED_PACKAGE: $($definition.package) has an invalid package identity" } $targets += @{ channel = $definition.channel @@ -71,8 +102,59 @@ fn hidden_command(program: &str) -> std::process::Command { command } +#[cfg(any(target_os = "windows", target_os = "macos", test))] +fn discovery_error_code(error: &std::io::Error) -> &'static str { + match error.kind() { + std::io::ErrorKind::NotFound => "command-unavailable", + std::io::ErrorKind::PermissionDenied => "permission-denied", + _ => "command-failed", + } +} + +fn discovery_output_error_code(detail: &str) -> &'static str { + let detail = detail.to_ascii_lowercase(); + if detail.contains("act_malformed_package") { + "malformed-package" + } else if detail.contains("access is denied") + || detail.contains("permission denied") + || detail.contains("operation not permitted") + || detail.contains("拒绝访问") + { + "permission-denied" + } else { + "command-failed" + } +} + +#[cfg(any(target_os = "windows", test))] +fn parse_windows_targets(text: &str) -> Result, DiscoveryDiagnostic> { + if text.trim().is_empty() { + return Ok(Vec::new()); + } + let value: serde_json::Value = serde_json::from_str(text.trim()).map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("Windows 应用检查结果无效:{error}"), + ) + })?; + if value.is_array() { + serde_json::from_value(value).map_err(|error| { + DiscoveryDiagnostic::new("malformed-output", format!("Windows 应用列表无效:{error}")) + }) + } else { + serde_json::from_value(value) + .map(|target| vec![target]) + .map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("Windows 应用记录无效:{error}"), + ) + }) + } +} + #[cfg(target_os = "windows")] -fn discover_platform_targets() -> Result, String> { +fn discover_platform_targets() -> Result, DiscoveryDiagnostic> { let output = hidden_command("powershell.exe") .args([ "-NoProfile", @@ -83,24 +165,31 @@ fn discover_platform_targets() -> Result, String> { WINDOWS_TARGET_SCRIPT, ]) .output() - .map_err(|error| format!("无法检查 Windows ChatGPT 应用:{error}"))?; + .map_err(|error| { + DiscoveryDiagnostic::new( + discovery_error_code(&error), + format!("无法检查 Windows ChatGPT 应用:{error}"), + ) + })?; if !output.status.success() { - return Err("Windows ChatGPT 应用检查失败".into()); - } - let text = String::from_utf8(output.stdout) - .map_err(|error| format!("Windows 应用检查结果不是 UTF-8:{error}"))?; - if text.trim().is_empty() { - return Ok(Vec::new()); - } - let value: serde_json::Value = serde_json::from_str(text.trim()) - .map_err(|error| format!("Windows 应用检查结果无效:{error}"))?; - if value.is_array() { - serde_json::from_value(value).map_err(|error| format!("Windows 应用列表无效:{error}")) - } else { - serde_json::from_value(value) - .map(|target| vec![target]) - .map_err(|error| format!("Windows 应用记录无效:{error}")) + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let code = discovery_output_error_code(&detail); + return Err(DiscoveryDiagnostic::new( + code, + if detail.is_empty() { + "Windows ChatGPT 应用检查失败".into() + } else { + format!("Windows ChatGPT 应用检查失败:{detail}") + }, + )); } + let text = String::from_utf8(output.stdout).map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("Windows 应用检查结果不是 UTF-8:{error}"), + ) + })?; + parse_windows_targets(&text) } #[cfg(any(target_os = "macos", test))] @@ -173,13 +262,17 @@ fn macos_target_from_bundle( } #[cfg(target_os = "macos")] -fn discover_macos_target(channel: &str, label: &str, expected_bundle: &str) -> Option { +fn discover_macos_target( + channel: &str, + label: &str, + expected_bundle: &str, +) -> Result, DiscoveryDiagnostic> { let home = std::env::var("HOME").ok().unwrap_or_default(); let names = macos_application_names(channel); for candidate in macos_named_bundle_paths(&home, names) { if let Some(target) = macos_target_from_bundle(channel, label, expected_bundle, &candidate) { - return Some(target); + return Ok(Some(target)); } } let output = std::process::Command::new("/usr/bin/mdfind") @@ -187,34 +280,67 @@ fn discover_macos_target(channel: &str, label: &str, expected_bundle: &str) -> O "kMDItemCFBundleIdentifier == \"{expected_bundle}\"" )) .output() - .ok()?; + .map_err(|error| { + DiscoveryDiagnostic::new( + discovery_error_code(&error), + format!("无法检查 macOS ChatGPT 应用:{error}"), + ) + })?; if !output.status.success() { - return None; + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(DiscoveryDiagnostic::new( + discovery_output_error_code(&detail), + if detail.is_empty() { + "macOS ChatGPT 应用检查失败".into() + } else { + format!("macOS ChatGPT 应用检查失败:{detail}") + }, + )); } - String::from_utf8(output.stdout) - .ok()? + let output = String::from_utf8(output.stdout).map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("macOS 应用检查结果不是 UTF-8:{error}"), + ) + })?; + Ok(output .lines() - .find_map(|candidate| macos_target_from_bundle(channel, label, expected_bundle, candidate)) + .find_map(|candidate| macos_target_from_bundle(channel, label, expected_bundle, candidate))) } #[cfg(target_os = "macos")] -fn discover_platform_targets() -> Result, String> { - Ok([ +fn discover_platform_targets() -> Result, DiscoveryDiagnostic> { + let mut targets = Vec::new(); + for (channel, label, bundle_id) in [ ("stable", "ChatGPT", "com.openai.codex"), ("beta", "ChatGPT Beta", "com.openai.codex.beta"), - ] - .into_iter() - .filter_map(|(channel, label, bundle_id)| discover_macos_target(channel, label, bundle_id)) - .collect()) + ] { + if let Some(target) = discover_macos_target(channel, label, bundle_id)? { + targets.push(target); + } + } + Ok(targets) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] -fn discover_platform_targets() -> Result, String> { - Ok(Vec::new()) +fn discover_platform_targets() -> Result, DiscoveryDiagnostic> { + Err(DiscoveryDiagnostic::new( + "unsupported-platform", + "当前平台暂不支持 ChatGPT Full Skin 目标检测", + )) } -pub fn discover_targets() -> Vec { - discover_platform_targets().unwrap_or_default() +pub fn discover_targets() -> TargetDiscovery { + match discover_platform_targets() { + Ok(targets) => TargetDiscovery { + targets, + diagnostic: None, + }, + Err(diagnostic) => TargetDiscovery { + targets: Vec::new(), + diagnostic: Some(diagnostic), + }, + } } fn safe_channel(channel: &str) -> bool { @@ -225,10 +351,11 @@ pub fn find_target(channel: &str) -> Result { if !safe_channel(channel) { return Err("未知的 ChatGPT 渠道".into()); } - discover_platform_targets()? + discover_platform_targets() + .map_err(|diagnostic| diagnostic.message())? .into_iter() .find(|target| target.channel == channel) - .ok_or_else(|| "没有检测到所选 ChatGPT 应用".into()) + .ok_or_else(|| "[not-installed] 没有检测到所选 ChatGPT 应用".into()) } fn full_skin_target_can_start_runtime_probe( @@ -244,7 +371,7 @@ pub fn validate_full_skin_target(target: &TargetView) -> Result<(), String> { } if !full_skin_target_can_start_runtime_probe(&target.channel, target.version.as_deref()) { return Err(format!( - "无法读取所选 {} 的准确版本;Full Skin 无法把本机兼容性探测绑定到该应用。", + "[unsupported-version] 无法读取所选 {} 的准确版本;Full Skin 无法把本机兼容性探测绑定到该应用。", target.label )); } @@ -418,7 +545,19 @@ if ($matches.Count -eq 0) { "false" } else { "true" } .env("ACT_PACKAGE", package) .output() .map_err(|error| format!("无法检查 ChatGPT 进程:{error}"))?; - Ok(output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true") + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(if detail.is_empty() { + "无法检查 ChatGPT 进程".into() + } else { + format!("无法检查 ChatGPT 进程:{detail}") + }); + } + match String::from_utf8_lossy(&output.stdout).trim() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err("ChatGPT 进程检查结果无效".into()), + } } #[cfg(target_os = "macos")] @@ -451,8 +590,66 @@ pub fn target_is_running(_target: &TargetView) -> Result { Ok(false) } +#[cfg(any(target_os = "windows", test))] +const WINDOWS_GRACEFUL_STOP_SCRIPT: &str = r#" +$ErrorActionPreference = "Stop" +$matches = @(Get-Process | ForEach-Object { + try { $path = $_.Path } catch { $path = $null } + if ($path -and + $path -ieq $env:ACT_EXECUTABLE -and + $path -like ("*\" + $env:ACT_PACKAGE + "\*")) { + $_ + } +}) +if ($matches.Count -eq 0) { "not-running"; exit 0 } +foreach ($match in $matches) { + [void]$match.CloseMainWindow() +} +"requested" +"#; + +#[cfg(any(target_os = "windows", test))] +fn graceful_stop_was_requested(output: &str) -> bool { + matches!(output.trim(), "requested" | "not-running") +} + #[cfg(target_os = "windows")] pub fn stop_target(target: &TargetView) -> Result<(), String> { + let executable = target + .executable_path + .as_deref() + .ok_or("Windows 可执行文件身份缺失")?; + let package = target + .package_full_name + .as_deref() + .ok_or("Windows 包身份缺失")?; + let output = hidden_command("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + WINDOWS_GRACEFUL_STOP_SCRIPT, + ]) + .env("ACT_EXECUTABLE", executable) + .env("ACT_PACKAGE", package) + .output() + .map_err(|error| format!("无法关闭所选 ChatGPT:{error}"))?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(if detail.is_empty() { + "无法请求所选 ChatGPT 正常退出".into() + } else { + format!("无法请求所选 ChatGPT 正常退出:{detail}") + }); + } + if !graceful_stop_was_requested(&String::from_utf8_lossy(&output.stdout)) { + return Err("所选 ChatGPT 没有接受正常退出请求".into()); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn force_stop_target(target: &TargetView) -> Result<(), String> { let executable = target .executable_path .as_deref() @@ -474,16 +671,16 @@ $matches = @(Get-Process | ForEach-Object { foreach ($match in $matches) { Stop-Process -Id $match.Id -Force -ErrorAction Stop } -"true" +"stopped" "#; let output = hidden_command("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", script]) .env("ACT_EXECUTABLE", executable) .env("ACT_PACKAGE", package) .output() - .map_err(|error| format!("无法关闭所选 ChatGPT:{error}"))?; - if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != "true" { - return Err("无法安全关闭所选 ChatGPT".into()); + .map_err(|error| format!("无法强制关闭所选 ChatGPT:{error}"))?; + if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != "stopped" { + return Err("无法强制关闭所选 ChatGPT".into()); } Ok(()) } @@ -502,11 +699,21 @@ pub fn stop_target(target: &TargetView) -> Result<(), String> { Ok(()) } +#[cfg(target_os = "macos")] +pub fn force_stop_target(_target: &TargetView) -> Result<(), String> { + Err("请手动关闭 macOS ChatGPT;Theme Manager 不会强制终止它。".into()) +} + #[cfg(not(any(target_os = "windows", target_os = "macos")))] pub fn stop_target(_target: &TargetView) -> Result<(), String> { Err("当前平台暂不支持受控重启".into()) } +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +pub fn force_stop_target(_target: &TargetView) -> Result<(), String> { + Err("当前平台暂不支持强制关闭 ChatGPT".into()) +} + #[cfg(any(target_os = "windows", test))] fn windows_listener_owner_pid(output: &str, port: u16) -> Option { output.lines().find_map(|line| { @@ -699,8 +906,29 @@ fn macos_process_is_target_or_descendant(pid: &str, executable: &str) -> Result< } #[cfg(any(target_os = "macos", test))] -fn persistence_host_path_is_durable(path: &str) -> bool { - !path.starts_with("/Volumes/") && path.contains(".app/Contents/MacOS/") +fn persistence_host_path_is_durable(path: &str, home: &str) -> bool { + let path = std::path::Path::new(path); + [ + std::path::PathBuf::from("/Applications"), + std::path::Path::new(home).join("Applications"), + ] + .iter() + .any(|root| { + let Ok(relative) = path.strip_prefix(root) else { + return false; + }; + let mut parts = relative.components(); + let Some(std::path::Component::Normal(bundle)) = parts.next() else { + return false; + }; + if !bundle.to_string_lossy().ends_with(".app") + || !matches!(parts.next(), Some(std::path::Component::Normal(part)) if part == std::ffi::OsStr::new("Contents")) + || !matches!(parts.next(), Some(std::path::Component::Normal(part)) if part == std::ffi::OsStr::new("MacOS")) + { + return false; + } + matches!(parts.next(), Some(std::path::Component::Normal(_))) && parts.next().is_none() + }) } #[cfg(target_os = "macos")] @@ -709,7 +937,8 @@ pub fn validate_persistence_host() -> Result<(), String> { .and_then(std::fs::canonicalize) .map_err(|error| format!("无法定位 Theme Manager 安装路径:{error}"))?; let path = executable.to_string_lossy(); - if !persistence_host_path_is_durable(&path) { + let home = std::env::var("HOME").map_err(|error| format!("无法定位当前用户主目录:{error}"))?; + if !persistence_host_path_is_durable(&path, &home) { return Err("请先把 Awesome Codex Theme 拖入 Applications,再开启“始终应用”。".into()); } Ok(()) @@ -733,16 +962,88 @@ mod tests { fn macos_persistence_rejects_transient_disk_images() { assert!(!persistence_host_path_is_durable( "/Volumes/Awesome Codex Theme/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", )); assert!(persistence_host_path_is_durable( "/Applications/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", )); assert!(persistence_host_path_is_durable( "/Users/test/Applications/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", )); assert!(!persistence_host_path_is_durable( - "/usr/local/bin/awesome-codex-theme" + "/usr/local/bin/awesome-codex-theme", + "/Users/test", )); + assert!(!persistence_host_path_is_durable( + "/tmp/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", + )); + assert!(!persistence_host_path_is_durable( + "/Users/other/Applications/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_graceful_stop_never_forces_termination() { + assert!(WINDOWS_GRACEFUL_STOP_SCRIPT.contains("CloseMainWindow")); + assert!(!WINDOWS_GRACEFUL_STOP_SCRIPT.contains("-Force")); + } + + #[cfg(target_os = "windows")] + #[test] + fn malformed_windows_discovery_is_a_diagnostic() { + let diagnostic = parse_windows_targets("not json").expect_err("invalid JSON should fail"); + assert_eq!(diagnostic.code, "malformed-output"); + } + + #[test] + fn discovery_error_codes_preserve_command_and_permission_failures() { + assert_eq!( + discovery_error_code(&std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing" + )), + "command-unavailable" + ); + assert_eq!( + discovery_error_code(&std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )), + "permission-denied" + ); + assert_eq!( + discovery_output_error_code("Access is denied"), + "permission-denied" + ); + assert_eq!( + discovery_output_error_code("ACT_MALFORMED_PACKAGE: invalid metadata"), + "malformed-package" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_discovery_keeps_multiple_targets() { + let targets = parse_windows_targets( + r#"[{"channel":"stable","label":"ChatGPT","version":"1"},{"channel":"beta","label":"ChatGPT Beta","version":"2"}]"#, + ) + .expect("two valid targets should parse"); + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].channel, "stable"); + assert_eq!(targets[1].channel, "beta"); + } + + #[cfg(target_os = "windows")] + #[test] + fn graceful_stop_accepts_a_normal_close_or_already_stopped_target() { + assert!(graceful_stop_was_requested("requested")); + assert!(graceful_stop_was_requested("not-running")); + assert!(!graceful_stop_was_requested("stopped")); } #[test] diff --git a/apps/theme-manager/src-tauri/src/skin_runtime.rs b/apps/theme-manager/src-tauri/src/skin_runtime.rs index e4ec9b6..0bad791 100644 --- a/apps/theme-manager/src-tauri/src/skin_runtime.rs +++ b/apps/theme-manager/src-tauri/src/skin_runtime.rs @@ -19,14 +19,15 @@ use tauri::{AppHandle, Manager}; use tungstenite::{Message, WebSocket, connect, stream::MaybeTlsStream}; use crate::{ + cache, catalog::{self, Catalog, FullSkinDescriptor}, - platform, + network, platform, }; const RUNTIME_CSS: &str = include_str!("../../../../packages/full-skin/runtime.css"); const RUNTIME_JS: &str = include_str!("../../../../packages/full-skin/runtime.js"); const RUNTIME_FORMAT: &str = "act-full-skin-v1"; -const RUNTIME_IMPLEMENTATION: &str = "act-full-skin-runtime-v2"; +const RUNTIME_IMPLEMENTATION: &str = "act-full-skin-runtime-v3"; const MAX_ART_BYTES: usize = 16 * 1024 * 1024; const APPLY_TIMEOUT: Duration = Duration::from_secs(45); const CDP_CALL_TIMEOUT: Duration = Duration::from_secs(20); @@ -49,7 +50,7 @@ const TARGET_RUNTIME_STATE_TEMPLATE: &str = r#"(() => { healthy: Boolean( root.classList.contains("act-full-skin") && state?.version === "act-full-skin-v1" - && state?.implementationVersion === "act-full-skin-runtime-v2" + && state?.implementationVersion === "act-full-skin-runtime-v3" && state?.themeId === expected.themeId && state?.mode === expected.mode && state?.artwork?.loaded === true @@ -62,10 +63,20 @@ const TARGET_RUNTIME_STATE_TEMPLATE: &str = r#"(() => { }; })()"#; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MonitorState { + Healthy, + Recovering, + Degraded, + Stopped, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkinRuntimeView { pub active: bool, + pub monitor_state: MonitorState, #[serde(skip_serializing_if = "Option::is_none")] pub theme_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -94,19 +105,23 @@ struct SkinSession { tested_version: String, port: u16, target: platform::TargetView, + script: String, targets: Arc>>, monitor_stop: Arc, monitor_running: Arc, + monitor_state: Arc>, } pub struct SkinRuntime { session: Mutex>, + operation: tokio::sync::Mutex<()>, } impl Default for SkinRuntime { fn default() -> Self { Self { session: Mutex::new(None), + operation: tokio::sync::Mutex::new(()), } } } @@ -120,6 +135,10 @@ impl SkinRuntime { .clone(); SkinRuntimeView { active: session.is_some(), + monitor_state: session + .as_ref() + .map(monitor_state_for) + .unwrap_or(MonitorState::Stopped), theme_id: session.as_ref().map(|value| value.theme_id.clone()), mode: session.as_ref().map(|value| value.mode.clone()), channel: session.as_ref().map(|value| value.channel.clone()), @@ -137,6 +156,13 @@ impl SkinRuntime { .take() } + fn snapshot(&self) -> Option { + self.session + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + fn replace(&self, session: SkinSession) { *self .session @@ -145,6 +171,29 @@ impl SkinRuntime { } } +fn monitor_state_for(session: &SkinSession) -> MonitorState { + *session + .monitor_state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn set_monitor_state(session: &SkinSession, state: MonitorState) { + *session + .monitor_state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = state; +} + +fn start_session_monitor(session: SkinSession) { + session.monitor_stop.store(false, Ordering::Release); + set_monitor_state(&session, MonitorState::Healthy); + let script = session.script.clone(); + let _ = tauri::async_runtime::spawn(async move { + monitor_skin_session(session, script).await; + }); +} + #[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct CdpTarget { @@ -269,9 +318,7 @@ fn cache_path(app: &AppHandle, skin: &FullSkinDescriptor) -> Result Result, let url = format!("{}{}", catalog::REMOTE_SITE_ROOT, skin.asset); let response = reqwest::Client::builder() .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| format!("无法创建素材下载客户端:{error}"))? .get(&url) @@ -301,17 +350,8 @@ async fn load_art(app: &AppHandle, skin: &FullSkinDescriptor) -> Result, .map_err(|error| format!("Full Skin 素材下载失败:{error}"))? .error_for_status() .map_err(|error| format!("Full Skin 素材返回错误:{error}"))?; - if response - .content_length() - .is_some_and(|bytes| bytes != skin.bytes as u64) - { - return Err("Full Skin 素材响应字节数不匹配".into()); - } - let bytes = response - .bytes() - .await - .map_err(|error| format!("无法读取 Full Skin 素材:{error}"))? - .to_vec(); + let bytes = + network::read_capped_response(response, skin.bytes, "image/png", "Full Skin 素材").await?; if bytes.len() != skin.bytes || sha256_hex(&bytes) != skin.sha256 { return Err("Full Skin 素材哈希或字节数不匹配".into()); } @@ -319,16 +359,10 @@ async fn load_art(app: &AppHandle, skin: &FullSkinDescriptor) -> Result, return Err("Full Skin 素材不是经过声明的 PNG".into()); } - let parent = destination.parent().ok_or("Full Skin 缓存目录无效")?; - fs::create_dir_all(parent).map_err(|error| format!("无法创建 Full Skin 缓存:{error}"))?; - let temporary = destination.with_extension("png.tmp"); - fs::write(&temporary, &bytes).map_err(|error| format!("无法写入 Full Skin 缓存:{error}"))?; - if destination.exists() { - fs::remove_file(&destination) - .map_err(|error| format!("无法更新 Full Skin 缓存:{error}"))?; - } - fs::rename(&temporary, &destination) - .map_err(|error| format!("无法提交 Full Skin 缓存:{error}"))?; + // Cache keys include the complete digest. If a stale or corrupt entry + // already occupies that key, keep it intact and use this verified download + // for the current session rather than deleting a possible last-known-good file. + let _ = cache::publish_new_file(&destination, &bytes, "Full Skin 缓存")?; Ok(bytes) } @@ -502,12 +536,21 @@ fn inject_target(target: CdpTarget, script: &str) -> Result format!("候选页面注入失败并已清理:{apply_error}"), + Err(cleanup_error) => { + format!("候选页面注入失败:{apply_error};候选清理也失败:{cleanup_error}") + } + }); + } + Ok(injected) } fn refresh_target(target: &CdpTarget, script: &str) -> Result<(), String> { @@ -515,10 +558,26 @@ fn refresh_target(target: &CdpTarget, script: &str) -> Result<(), String> { evaluate_target_runtime(&mut socket, 1, &target.id, script) } -fn inject_targets(targets: Vec, script: String) -> Result, String> { +fn inject_transactionally( + targets: Vec, + mut inject_one: impl FnMut(&T) -> Result, + mut rollback: impl FnMut(&[T], &[R]) -> Result<(), String>, +) -> Result, String> { let mut injected = Vec::new(); + let mut touched = Vec::new(); for target in targets { - injected.push(inject_target(target, &script)?); + touched.push(target.clone()); + match inject_one(&target) { + Ok(candidate) => injected.push(candidate), + Err(apply_error) => { + return Err(match rollback(&touched, &injected) { + Ok(()) => format!("候选 Full Skin 注入失败并已回滚:{apply_error}"), + Err(rollback_error) => format!( + "候选 Full Skin 注入失败:{apply_error};候选回滚也失败:{rollback_error}" + ), + }); + } + } } if injected.is_empty() { return Err("没有 ChatGPT 页面接受 Full Skin".into()); @@ -563,6 +622,18 @@ fn health_check_due(target: &InjectedTarget, now: Instant) -> bool { now.saturating_duration_since(target.last_health_check) >= TARGET_HEALTH_INTERVAL } +fn monitor_state_after_cycle(keep_monitoring: bool, consecutive_errors: u8) -> MonitorState { + if !keep_monitoring { + MonitorState::Stopped + } else if consecutive_errors >= TARGET_MONITOR_MAX_ERRORS { + MonitorState::Degraded + } else if consecutive_errors == 0 { + MonitorState::Healthy + } else { + MonitorState::Recovering + } +} + fn remove_from_targets( targets: Vec, injected_targets: Vec, @@ -628,6 +699,89 @@ fn remove_from_targets( Ok(()) } +fn remove_early_scripts_from_targets( + targets: &[CdpTarget], + injected_targets: &[InjectedTarget], +) -> Result<(), String> { + let mut errors = Vec::new(); + for record in injected_targets { + let Some(target) = targets.iter().find(|target| target.id == record.id) else { + continue; + }; + let mut socket = match connect_target_with_timeout(target, CDP_CLEANUP_TIMEOUT) { + Ok(socket) => socket, + Err(error) => { + errors.push(format!("{}: {error}", record.id)); + continue; + } + }; + if let Err(error) = cdp_call( + &mut socket, + 1, + "Page.removeScriptToEvaluateOnNewDocument", + json!({ "identifier": record.script_id }), + ) { + if !missing_early_script_registration(&error) { + errors.push(format!("{}: {error}", record.id)); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(format!( + "旧 Full Skin 早期脚本没有完成清理:{}", + errors.join("; ") + )) + } +} + +fn restore_targets(targets: &[CdpTarget], script: &str) -> Result<(), String> { + let mut errors = Vec::new(); + for target in targets { + if let Err(error) = refresh_target(target, script) { + errors.push(format!("{}: {error}", target.id)); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(format!("旧 Full Skin 没有完成恢复:{}", errors.join("; "))) + } +} + +fn rollback_candidate_targets( + targets: &[CdpTarget], + candidate: &[InjectedTarget], + previous_script: Option<&str>, +) -> Result<(), String> { + let cleanup = remove_from_targets(targets.to_vec(), candidate.to_vec()); + let restore = previous_script.map(|script| restore_targets(targets, script)); + match (cleanup, restore) { + (Ok(()), None | Some(Ok(()))) => Ok(()), + (Err(cleanup_error), None) => Err(cleanup_error), + (Err(cleanup_error), Some(Ok(()))) => Err(cleanup_error), + (Ok(()), Some(Err(restore_error))) => Err(restore_error), + (Err(cleanup_error), Some(Err(restore_error))) => { + Err(format!("{cleanup_error};{restore_error}")) + } + } +} + +fn inject_candidate_targets( + targets: Vec, + script: String, + previous_script: Option, +) -> Result, String> { + inject_transactionally( + targets, + |target| inject_target(target.clone(), &script), + |touched, candidate| { + rollback_candidate_targets(touched, candidate, previous_script.as_deref()) + }, + ) +} + async fn listener_matches_session_target(session: &SkinSession) -> Result { let port = session.port; let target = session.target.clone(); @@ -689,6 +843,7 @@ async fn register_target_runtime( async fn monitor_skin_session(session: SkinSession, script: String) { if session.monitor_stop.load(Ordering::Acquire) { + set_monitor_state(&session, MonitorState::Stopped); return; } session.monitor_running.store(true, Ordering::Release); @@ -700,7 +855,7 @@ async fn monitor_skin_session(session: SkinSession, script: String) { let mut keep_monitoring = true; match cdp_targets(session.port).await { Ok((_, targets)) => { - let mut had_error = false; + let mut had_error = targets.is_empty(); let active_ids: HashSet = targets.iter().map(|target| target.id.clone()).collect(); retain_active_targets(&session.targets, &active_ids); @@ -757,11 +912,20 @@ async fn monitor_skin_session(session: SkinSession, script: String) { consecutive_errors = consecutive_errors.saturating_add(1); } } - if !keep_monitoring || consecutive_errors >= TARGET_MONITOR_MAX_ERRORS { + let monitor_state = monitor_state_after_cycle(keep_monitoring, consecutive_errors); + if matches!( + monitor_state, + MonitorState::Stopped | MonitorState::Degraded + ) { + set_monitor_state(&session, monitor_state); break; } + set_monitor_state(&session, monitor_state); tokio::time::sleep(TARGET_DISCOVERY_INTERVAL).await; } + if session.monitor_stop.load(Ordering::Acquire) { + set_monitor_state(&session, MonitorState::Stopped); + } session.monitor_running.store(false, Ordering::Release); } @@ -789,6 +953,17 @@ async fn remove_session(session: SkinSession) -> Result<(), String> { .map_err(|error| format!("Full Skin 清理任务失败:{error}"))? } +async fn restore_session_runtime(session: &SkinSession) -> Result<(), String> { + if !listener_matches_session_target(session).await? { + return Err("无法确认旧 Full Skin 会话仍属于所选 ChatGPT".into()); + } + let (_, targets) = cdp_targets(session.port).await?; + let script = session.script.clone(); + tauri::async_runtime::spawn_blocking(move || restore_targets(&targets, &script)) + .await + .map_err(|error| format!("旧 Full Skin 恢复任务失败:{error}"))? +} + pub async fn apply( app: &AppHandle, runtime: &SkinRuntime, @@ -797,29 +972,21 @@ pub async fn apply( mode: &str, channel: &str, ) -> Result { - let mut reusable_port = None; - if let Some(previous) = runtime.take() { - let same_channel = previous.channel == channel; - let previous_port = previous.port; - if let Err(error) = remove_session(previous.clone()).await { - runtime.replace(previous); - return Err(error); - } - if same_channel { - reusable_port = Some(previous_port); - } - } - + let _operation = runtime.operation.lock().await; let skin = catalog::full_skin_for(catalog, theme_id, mode)?; let target = platform::find_target(channel)?; platform::validate_full_skin_target(&target)?; let art = load_art(app, &skin).await?; let script = runtime_script(&skin, &art)?; + let previous = runtime.snapshot(); let running = platform::target_is_running(&target)?; + let reusable_port = previous.as_ref().and_then(|session| { + (session.channel == channel + && platform::listener_belongs_to_target(session.port, &target).unwrap_or(false)) + .then_some(session.port) + }); let port = if running { - reusable_port - .filter(|port| platform::listener_belongs_to_target(*port, &target).unwrap_or(false)) - .ok_or("所选 ChatGPT 已经在普通模式运行。请先关闭它,再应用完整皮肤。")? + reusable_port.ok_or("所选 ChatGPT 已经在普通模式运行。请先关闭它,再应用完整皮肤。")? } else { select_loopback_port()? }; @@ -830,11 +997,33 @@ pub async fn apply( )?; } let (_, targets) = wait_for_targets(port, &target).await?; - let initial_script = script.clone(); - let injected = - tauri::async_runtime::spawn_blocking(move || inject_targets(targets, initial_script)) - .await - .map_err(|error| format!("Full Skin 注入任务失败:{error}"))??; + if let Some(previous) = &previous { + if let Err(error) = stop_session_monitor(previous).await { + set_monitor_state(previous, MonitorState::Degraded); + return Err(error); + } + } + + let candidate_targets = targets.clone(); + let candidate_script = script.clone(); + let rollback_script = previous + .as_ref() + .filter(|session| session.port == port) + .map(|session| session.script.clone()); + let injected = match tauri::async_runtime::spawn_blocking(move || { + inject_candidate_targets(targets, candidate_script, rollback_script) + }) + .await + .map_err(|error| format!("Full Skin 候选注入任务失败:{error}"))? + { + Ok(injected) => injected, + Err(error) => { + if let Some(previous) = previous { + start_session_monitor(previous); + } + return Err(error); + } + }; let session = SkinSession { theme_id: skin.theme_id, mode: skin.mode, @@ -842,20 +1031,64 @@ pub async fn apply( tested_version: skin.tested_version, port, target, + script: script.clone(), targets: Arc::new(Mutex::new(injected)), monitor_stop: Arc::new(AtomicBool::new(false)), monitor_running: Arc::new(AtomicBool::new(false)), + monitor_state: Arc::new(Mutex::new(MonitorState::Healthy)), }; + + if let Some(previous) = &previous { + let previous_records = injected_targets_snapshot(&previous.targets); + let cleanup = if previous.port == port { + let targets = candidate_targets.clone(); + tauri::async_runtime::spawn_blocking(move || { + remove_early_scripts_from_targets(&targets, &previous_records) + }) + .await + .map_err(|error| format!("旧 Full Skin 脚本清理任务失败:{error}"))? + } else { + remove_session(previous.clone()).await + }; + if let Err(cleanup_error) = cleanup { + let candidates = injected_targets_snapshot(&session.targets); + let targets = candidate_targets.clone(); + let restore_script = (previous.port == port).then(|| previous.script.clone()); + let rollback = tauri::async_runtime::spawn_blocking(move || { + rollback_candidate_targets(&targets, &candidates, restore_script.as_deref()) + }) + .await + .map_err(|error| format!("候选 Full Skin 回滚任务失败:{error}"))?; + let previous_restore = if previous.port == port { + Ok(()) + } else { + restore_session_runtime(previous).await + }; + start_session_monitor(previous.clone()); + return Err(match (rollback, previous_restore) { + (Ok(()), Ok(())) => format!("旧 Full Skin 清理失败,候选已回滚:{cleanup_error}"), + (Err(rollback_error), Ok(())) => format!( + "旧 Full Skin 清理失败:{cleanup_error};候选回滚也失败:{rollback_error}" + ), + (Ok(()), Err(restore_error)) => format!( + "旧 Full Skin 清理失败:{cleanup_error};旧主题恢复也失败:{restore_error}" + ), + (Err(rollback_error), Err(restore_error)) => format!( + "旧 Full Skin 清理失败:{cleanup_error};候选回滚失败:{rollback_error};旧主题恢复失败:{restore_error}" + ), + }); + } + } runtime.replace(session.clone()); - let _ = tauri::async_runtime::spawn(async move { - monitor_skin_session(session, script).await; - }); + start_session_monitor(session); Ok(runtime.current()) } pub async fn restore(runtime: &SkinRuntime) -> Result { + let _operation = runtime.operation.lock().await; if let Some(session) = runtime.take() { if let Err(error) = remove_session(session.clone()).await { + set_monitor_state(&session, MonitorState::Degraded); runtime.replace(session); return Err(error); } @@ -866,6 +1099,7 @@ pub async fn restore(runtime: &SkinRuntime) -> Result { #[cfg(test)] mod tests { use super::*; + use std::{cell::RefCell, rc::Rc}; #[test] fn runtime_template_expands_without_executable_theme_fields() { @@ -897,7 +1131,7 @@ mod tests { }; let script = runtime_script(&skin, b"\x89PNG\r\n\x1a\n").expect("runtime should build"); assert!(script.contains("act-full-skin-v1")); - assert!(script.contains("act-full-skin-runtime-v2")); + assert!(script.contains("act-full-skin-runtime-v3")); assert!(script.contains("test-theme")); assert!(script.contains("DOMContentLoaded")); assert!(!script.contains("__ACT_THEME_JSON__")); @@ -941,7 +1175,7 @@ mod tests { assert!(expression.contains("test-theme")); assert!(expression.contains("state?.version === \"act-full-skin-v1\"")); assert!( - expression.contains("state?.implementationVersion === \"act-full-skin-runtime-v2\"") + expression.contains("state?.implementationVersion === \"act-full-skin-runtime-v3\"") ); assert!(expression.contains("state.artUrl.startsWith(\"blob:\")")); assert!(expression.contains("artValue.includes(state.artUrl)")); @@ -966,6 +1200,17 @@ mod tests { assert!(TARGET_HEALTH_INTERVAL > TARGET_DISCOVERY_INTERVAL * 3); } + #[test] + fn monitor_failures_remain_visible_after_the_retry_budget_is_exhausted() { + assert_eq!(monitor_state_after_cycle(true, 0), MonitorState::Healthy); + assert_eq!(monitor_state_after_cycle(true, 1), MonitorState::Recovering); + assert_eq!( + monitor_state_after_cycle(true, TARGET_MONITOR_MAX_ERRORS), + MonitorState::Degraded + ); + assert_eq!(monitor_state_after_cycle(false, 0), MonitorState::Stopped); + } + #[test] fn selected_loopback_port_is_available() { let port = select_loopback_port().expect("loopback port should be selected"); @@ -974,4 +1219,38 @@ mod tests { .expect("selected port should be available immediately after selection"); assert_eq!(listener.local_addr().expect("local address").port(), port); } + + #[test] + fn candidate_transaction_rolls_back_the_first_target_when_the_second_fails() { + let events = Rc::new(RefCell::new(Vec::new())); + let inject_events = events.clone(); + let rollback_events = events.clone(); + let result = inject_transactionally( + vec!["target-1", "target-2"], + move |target| { + inject_events.borrow_mut().push(format!("inject:{target}")); + if *target == "target-2" { + Err("target-2 rejected the candidate".into()) + } else { + Ok(format!("candidate:{target}")) + } + }, + move |touched, injected| { + rollback_events + .borrow_mut() + .push(format!("rollback:{touched:?}:{injected:?}")); + Ok(()) + }, + ); + + assert!(result.is_err()); + assert_eq!( + events.borrow().as_slice(), + [ + "inject:target-1", + "inject:target-2", + "rollback:[\"target-1\", \"target-2\"]:[\"candidate:target-1\"]", + ] + ); + } } diff --git a/apps/theme-manager/src-tauri/tauri.conf.json b/apps/theme-manager/src-tauri/tauri.conf.json index c50a9f4..75a8f7f 100644 --- a/apps/theme-manager/src-tauri/tauri.conf.json +++ b/apps/theme-manager/src-tauri/tauri.conf.json @@ -27,7 +27,9 @@ "csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost https://rwang23.github.io data:; style-src 'self'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; form-action 'none'", "assetProtocol": { "enable": true, - "scope": ["$RESOURCE/catalog/screenshots/**"] + "scope": [ + "$RESOURCE/catalog/screenshots/**" + ] } } }, @@ -35,7 +37,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZEMTlENzE5NjIzRkIwREQKUldUZHNEOWlHZGNaL1NYb25kMnRpNDRUMFNFaHVOeU5KeEFSeXYxUlUwSXFRbVppcTUwdWV1YlMK", "endpoints": [ - "https://github.com/rwang23/awesome-codex-theme/releases/latest/download/latest.json" + "https://rwang23.github.io/awesome-codex-theme/updates/stable.json" ], "windows": { "installMode": "passive" @@ -70,7 +72,10 @@ "nsis": { "installMode": "currentUser", "displayLanguageSelector": true, - "languages": ["SimpChinese", "English"] + "languages": [ + "SimpChinese", + "English" + ] } }, "macOS": { diff --git a/apps/theme-manager/src/renderer/app.js b/apps/theme-manager/src/renderer/app.js index 917c80d..f28ee00 100644 --- a/apps/theme-manager/src/renderer/app.js +++ b/apps/theme-manager/src/renderer/app.js @@ -1,4 +1,5 @@ import "./bridge.js"; +import { canUseGlobalActionShortcut } from "./shortcuts.js"; const translations = { en: { @@ -27,7 +28,7 @@ const translations = { archiveEyebrow: "THEME LIBRARY", archiveTitleOne: "Find your next", archiveTitleTwo: "workspace.", - archiveDescription: "Filter by style and collection, then check the real Beta capture before applying.", + archiveDescription: "Filter by style and collection, then check the recorded Beta capture before applying.", searchPlaceholder: "Search themes, cities, or moods", allCollections: "All", rightsFacet: "Source", @@ -41,11 +42,17 @@ const translations = { scene: "Scene", resultsCount: "{count} themes", emptyState: "No themes match these filters.", + viewDetails: "View details", light: "Light", dark: "Dark", verifiedCapture: "Real Beta capture", captureAlt: "real Beta capture", captureProof: "REAL APP CAPTURE", + referenceCapture: "Recorded Beta capture", + referenceCaptureAlt: "recorded Beta capture", + referenceCaptureProof: "RECORDED APP CAPTURE · RECAPTURE REQUIRED", + captureVersionVerified: "Beta {version} · current", + captureVersionReference: "Beta {version} · reference", originalRights: "Original · CC0", fanArtRights: "Unofficial fan art · non-commercial", applyStep: "APPLY TO CODEX", @@ -63,22 +70,45 @@ const translations = { persistenceRetrying: "Retrying after a safe failure", persistenceBlocked: "Paused · reapply to verify this version", persistenceError: "Paused after a bounded failure", + persistenceNeedsUserClose: "Waiting for you to close the selected ChatGPT safely", + persistenceForceClosing: "Closing the selected ChatGPT after your confirmation", persistenceOther: "Another theme is kept: {theme} · {mode}", persistenceConsent: "Keep this theme after every future launch of the selected ChatGPT version once it completes a local verification?\n\nThis is the normal result of Apply & Keep Full Skin. Theme Manager will start for your user account at sign-in. When you open ChatGPT normally, it may close and relaunch only that exact app once with a loopback-only debugging port. A detected Stable or Beta build stays native until you explicitly choose Apply & Keep Full Skin to run a controlled local probe. Only a successful app-target and runtime-marker readback enables future replay for that exact version. It never edits ChatGPT files, shortcuts, or chats. You can turn this off here at any time.", consentEyebrow: "APPLY & KEEP", consentTitle: "Keep this Full Skin?", cancel: "Cancel", confirmApply: "Apply & Keep", + forceRestartEyebrow: "RESTART SAFETY", + forceRestartTitle: "Force close the selected ChatGPT?", + forceRestartWarning: "ChatGPT did not close after a normal request. Force closing may lose unsent prompts or other unsaved input. Cancel leaves the app and theme unchanged.", + forceRestartConfirm: "Force close & restart", + forceRestart: "Force close & restart", copyNative: "Copy Native palette only", skinIdle: "Theme packs contain only declarative data and images. The manager uses a temporary loopback-only debug session to load the background without modifying WindowsApps, app.asar, or ChatGPT private data.", skinActive: "Active in {target}: {theme} · {mode}. Restore the skin first; quit and reopen ChatGPT normally to close the debug port.", + monitorHealthy: "Runtime monitor healthy.", + monitorRecovering: "Runtime monitor is retrying a page check.", + monitorDegraded: "Runtime monitor is degraded. Restore or reapply to verify the current session.", + monitorStopped: "Runtime monitor stopped because the verified session is no longer available.", noTarget: "ChatGPT was not found", + retryTargetDiscovery: "Retry detection", + copyDiagnostic: "Copy diagnostic", + targetDiagnosticCommandUnavailable: "Target detection could not start.", + targetDiagnosticPermissionDenied: "Target detection was blocked by permissions.", + targetDiagnosticMalformedPackage: "Target detection found malformed package metadata.", + targetDiagnosticMalformedOutput: "Target detection returned invalid data.", + targetDiagnosticCommandFailed: "Target detection failed.", + targetDiagnosticUnsupported: "This platform does not support target detection.", + targetUnsupportedVersionError: "The selected ChatGPT version could not be verified.", onlineGallery: "Online Gallery", githubRepository: "GitHub repository", checkUpdate: "Check for app updates", registryCount: "{count} themes / {source}", sourceRemote: "online", sourceLocal: "local", + capabilityVerified: "verified", + capabilityBuildOnly: "build-only", + capabilityUnverified: "unverified", updateDevelopment: "Development build · v{version}", updateUnsupported: "Catalog updates only on this platform", updateIdle: "Check for app updates", @@ -100,6 +130,8 @@ const translations = { toastPersistenceEnabled: "Always apply is on for this theme.", toastPersistenceDisabled: "Always apply is off and the login task was removed.", toastPersistenceFailed: "Could not change the always-apply setting.", + toastDiagnosticCopied: "Diagnostic copied.", + toastForceRestartRequested: "Force close requested. The controller will reapply after ChatGPT exits.", toastUpdateFailed: "App update check failed.", startupFailed: "Theme Manager could not start.", closeCodexError: "Quit the selected ChatGPT app completely, then apply the theme again.", @@ -137,7 +169,7 @@ const translations = { archiveEyebrow: "主题浏览", archiveTitleOne: "找到适合你的", archiveTitleTwo: "工作区。", - archiveDescription: "按风格和系列筛选,应用前先看真实 Beta 截图。", + archiveDescription: "按风格和系列筛选,应用前先看已记录的 Beta 截图。", searchPlaceholder: "搜索主题、城市或氛围", allCollections: "全部", rightsFacet: "来源", @@ -151,11 +183,17 @@ const translations = { scene: "名场面", resultsCount: "{count} 套主题", emptyState: "没有找到符合条件的主题。", + viewDetails: "查看详情", light: "明亮", dark: "暗色", verifiedCapture: "Beta 实机截图", captureAlt: "Beta 实机截图", captureProof: "真实应用截图", + referenceCapture: "已记录 Beta 截图", + referenceCaptureAlt: "已记录 Beta 截图", + referenceCaptureProof: "已记录应用截图 · 需要重新采集", + captureVersionVerified: "Beta {version} · 当前验证", + captureVersionReference: "Beta {version} · 参考证据", originalRights: "原创 · CC0", fanArtRights: "非官方 Fan Art · 非商业", applyStep: "应用到 Codex", @@ -173,22 +211,45 @@ const translations = { persistenceRetrying: "遇到错误,正在安全重试", persistenceBlocked: "已暂停,请重新应用以验证当前版本", persistenceError: "有限重试失败,已暂停", + persistenceNeedsUserClose: "正在等待你安全关闭所选 ChatGPT", + persistenceForceClosing: "正按你的确认关闭所选 ChatGPT", persistenceOther: "当前保持的是:{theme} · {mode}", persistenceConsent: "要让这套主题在所选 ChatGPT 版本完成本机验证后,于以后每次打开时继续生效吗?\n\n这也是“应用并保持完整皮肤”的默认结果。Theme Manager 会为当前用户注册登录启动项。当你正常打开 ChatGPT 时,控制器可能先关闭并只重开这一个准确应用一次,再通过仅限本机回环的调试端口加载主题。检测到的 Stable/Beta 版本会先保持原生;只有你明确选择“应用并保持完整皮肤”时,才会进行一次受控本机探测。只有端口、页面和运行时标记读回成功后,才会在以后自动重放该准确版本。它不会修改 ChatGPT 文件、快捷方式或聊天。你可以随时在这里关闭。", consentEyebrow: "应用并保持", consentTitle: "保持使用这套完整皮肤?", cancel: "取消", confirmApply: "应用并保持", + forceRestartEyebrow: "重启安全", + forceRestartTitle: "强制关闭所选 ChatGPT?", + forceRestartWarning: "ChatGPT 在正常退出请求后仍未关闭。强制关闭可能丢失未发送的提示词或其他未保存输入。取消不会改变应用或主题状态。", + forceRestartConfirm: "强制关闭并重新应用", + forceRestart: "强制关闭并重新应用", copyNative: "只复制原生配色", skinIdle: "主题包只含声明式配置与图片。管理器通过仅限本机回环的临时调试会话加载背景,不修改 WindowsApps、app.asar 或 ChatGPT 私有数据。", skinActive: "正在 {target} 使用 {theme} · {mode}。恢复后如需关闭调试端口,请退出并正常重开 ChatGPT。", + monitorHealthy: "运行时监控正常。", + monitorRecovering: "运行时监控正在重试页面检查。", + monitorDegraded: "运行时监控已降级,请恢复或重新应用以验证当前会话。", + monitorStopped: "已验证会话不再可用,运行时监控已停止。", noTarget: "未检测到 ChatGPT", + retryTargetDiscovery: "重新检测", + copyDiagnostic: "复制诊断", + targetDiagnosticCommandUnavailable: "无法启动目标检测。", + targetDiagnosticPermissionDenied: "目标检测被权限阻止。", + targetDiagnosticMalformedPackage: "目标检测发现了损坏的软件包元数据。", + targetDiagnosticMalformedOutput: "目标检测返回了无效数据。", + targetDiagnosticCommandFailed: "目标检测失败。", + targetDiagnosticUnsupported: "当前平台不支持目标检测。", + targetUnsupportedVersionError: "无法验证所选 ChatGPT 的准确版本。", onlineGallery: "在线 Gallery", githubRepository: "GitHub 仓库", checkUpdate: "检查应用更新", registryCount: "{count} 套 / {source}", sourceRemote: "在线", sourceLocal: "本地", + capabilityVerified: "已验证", + capabilityBuildOnly: "仅构建", + capabilityUnverified: "未验证", updateDevelopment: "开发模式 · v{version}", updateUnsupported: "当前平台仅提供目录更新", updateIdle: "检查应用更新", @@ -210,6 +271,8 @@ const translations = { toastPersistenceEnabled: "已为这套主题开启始终应用。", toastPersistenceDisabled: "已关闭始终应用并移除登录启动项。", toastPersistenceFailed: "无法更改始终应用设置。", + toastDiagnosticCopied: "诊断信息已复制。", + toastForceRestartRequested: "已请求强制关闭;ChatGPT 退出后控制器会重新应用。", toastUpdateFailed: "应用更新检查失败。", startupFailed: "无法启动主题管理器。", closeCodexError: "请先完全退出所选 ChatGPT,再重新应用主题。", @@ -245,6 +308,7 @@ const state = { catalog: null, catalogState: null, targets: [], + targetDiagnostic: null, skinState: null, persistenceState: null, updateState: null, @@ -254,6 +318,9 @@ const state = { style: "all", query: "", themeId: null, + themeCards: new Map(), + thumbnailObserver: null, + catalogKey: null, mode: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", }; @@ -274,8 +341,10 @@ const elements = { themeList: document.querySelector("#themeList"), emptyState: document.querySelector("#emptyState"), modeSwitch: document.querySelector("#modeSwitch"), + captureBadgeLabel: document.querySelector("#captureBadgeLabel"), captureVersion: document.querySelector("#captureVersion"), themeCapture: document.querySelector("#themeCapture"), + captureProofLabel: document.querySelector("#captureProofLabel"), captureHash: document.querySelector("#captureHash"), collectionName: document.querySelector("#collectionName"), themeName: document.querySelector("#themeName"), @@ -287,9 +356,11 @@ const elements = { applySkinLabel: document.querySelector("#applySkinLabel"), applyShortcut: document.querySelector("#applyShortcut"), copyTheme: document.querySelector("#copyTheme"), - copyShortcut: document.querySelector("#copyShortcut"), targetSelect: document.querySelector("#targetSelect"), restoreSkin: document.querySelector("#restoreSkin"), + targetDiagnosticActions: document.querySelector("#targetDiagnosticActions"), + retryTargetDiscovery: document.querySelector("#retryTargetDiscovery"), + copyTargetDiagnostic: document.querySelector("#copyTargetDiagnostic"), persistenceControl: document.querySelector("#persistenceControl"), persistenceToggle: document.querySelector("#persistenceToggle"), persistenceStatus: document.querySelector("#persistenceStatus"), @@ -297,35 +368,135 @@ const elements = { appVersion: document.querySelector("#appVersion"), registryHash: document.querySelector("#registryHash"), themeCount: document.querySelector("#themeCount"), + capabilitySummary: document.querySelector("#capabilitySummary"), openGallery: document.querySelector("#openGallery"), openRepository: document.querySelector("#openRepository"), updateButton: document.querySelector("#updateButton"), updateLabel: document.querySelector("#updateLabel"), + persistenceConsentBackdrop: document.querySelector("#persistenceConsentBackdrop"), persistenceConsentDialog: document.querySelector("#persistenceConsentDialog"), persistenceConsentCancel: document.querySelector("#persistenceConsentCancel"), persistenceConsentConfirm: document.querySelector("#persistenceConsentConfirm"), + forceRestart: document.querySelector("#forceRestart"), + forceRestartDialog: document.querySelector("#forceRestartDialog"), + forceRestartCancel: document.querySelector("#forceRestartCancel"), + forceRestartConfirm: document.querySelector("#forceRestartConfirm"), toast: document.querySelector("#toast"), }; let consentResolver = null; +let consentTrigger = null; +let consentUsesNativeDialog = false; +const FALLBACK_FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(", "); +let forceRestartResolver = null; + +function supportsNativeDialog() { + return typeof elements.persistenceConsentDialog.showModal === "function" + && typeof elements.persistenceConsentDialog.close === "function"; +} + +function setFallbackBackgroundInert(inert) { + if ("inert" in elements.appShell) elements.appShell.inert = inert; + if (inert) elements.appShell.setAttribute("aria-hidden", "true"); + else elements.appShell.removeAttribute("aria-hidden"); +} + +function openConsentDialog() { + consentUsesNativeDialog = supportsNativeDialog(); + if (consentUsesNativeDialog) { + elements.persistenceConsentDialog.showModal(); + return; + } + elements.persistenceConsentDialog.dataset.fallback = "true"; + elements.persistenceConsentBackdrop.hidden = false; + setFallbackBackgroundInert(true); + elements.persistenceConsentDialog.setAttribute("open", ""); +} + +function closeConsentDialog() { + if (consentUsesNativeDialog) { + if (elements.persistenceConsentDialog.open) elements.persistenceConsentDialog.close(); + } else { + elements.persistenceConsentDialog.removeAttribute("open"); + delete elements.persistenceConsentDialog.dataset.fallback; + elements.persistenceConsentBackdrop.hidden = true; + setFallbackBackgroundInert(false); + } + consentUsesNativeDialog = false; +} + +function trapFallbackConsentFocus(event) { + if (!elements.persistenceConsentDialog.dataset.fallback) return; + if (event.key === "Escape") { + event.preventDefault(); + finishPersistenceConsent(false); + return; + } + if (event.key !== "Tab") return; + const focusable = [...elements.persistenceConsentDialog.querySelectorAll(FALLBACK_FOCUSABLE_SELECTOR)] + .filter((node) => !node.hidden && node.getAttribute("aria-hidden") !== "true" && node.offsetParent !== null); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} + +function restoreConsentTrigger() { + const trigger = consentTrigger; + consentTrigger = null; + if (trigger instanceof HTMLElement && trigger.isConnected) { + trigger.focus({ preventScroll: true }); + } +} function finishPersistenceConsent(confirmed) { - if (!consentResolver) return; const resolve = consentResolver; consentResolver = null; - elements.persistenceConsentDialog.hidden = true; - resolve(confirmed); + closeConsentDialog(); + restoreConsentTrigger(); + resolve?.(confirmed); } -function requestPersistenceConsent() { - if (consentResolver) return Promise.resolve(false); - elements.persistenceConsentDialog.hidden = false; - elements.persistenceConsentConfirm.focus(); +function requestPersistenceConsent(trigger) { + if (consentResolver || elements.persistenceConsentDialog.open) return Promise.resolve(false); + consentTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement; + openConsentDialog(); + elements.persistenceConsentCancel.focus({ preventScroll: true }); return new Promise((resolve) => { consentResolver = resolve; }); } +function finishForceRestartConsent(confirmed) { + if (!forceRestartResolver) return; + const resolve = forceRestartResolver; + forceRestartResolver = null; + elements.forceRestartDialog.hidden = true; + resolve(confirmed); +} + +function requestForceRestartConsent() { + if (forceRestartResolver) return Promise.resolve(false); + elements.forceRestartDialog.hidden = false; + elements.forceRestartConfirm.focus(); + return new Promise((resolve) => { + forceRestartResolver = resolve; + }); +} + function localized(value) { return value?.[state.locale] || value?.en || value?.["zh-CN"] || ""; } @@ -338,6 +509,7 @@ function friendlyError(error, fallbackKey) { return reason ? `${t(key)}\n${t("errorReason")}: ${reason}` : t(key); }; if (/完全退出|正在普通模式运行|already running/i.test(raw)) return withReason("closeCodexError"); + if (/\[unsupported-version\]/i.test(raw)) return withReason("targetUnsupportedVersionError"); if (/没有检测到|not found/i.test(raw)) return withReason("targetMissingError"); if (/素材|哈希|PNG|integrity/i.test(raw)) return withReason("assetError"); if (/CDP|调试|Full Skin 会话|loopback|LaunchServices/i.test(raw)) return withReason("sessionError"); @@ -368,6 +540,11 @@ function currentCollection() { return state.catalog?.collections.find((collection) => collection.id === theme?.collection); } +function captureEvidenceIsVerified() { + const evidence = state.catalog?.runtimeEvidence; + return evidence?.status === "verified" && evidence.currentRuntime?.verification === "verified"; +} + function audiencePriority(record) { const audience = record?.audience || "global"; if (audience === state.locale) return 0; @@ -421,6 +598,26 @@ function renderCatalogStatus() { : "—"; } +function capabilityStatusLabel(status) { + const keys = { + verified: "capabilityVerified", + "build-only": "capabilityBuildOnly", + unverified: "capabilityUnverified", + }; + return t(keys[status] || "capabilityUnverified"); +} + +function renderCapabilitySummary() { + const capabilities = state.catalog?.capabilities; + if (!Array.isArray(capabilities) || capabilities.length === 0) { + elements.capabilitySummary.textContent = "—"; + return; + } + elements.capabilitySummary.textContent = capabilities.map((capability) => ( + localized(capability.title) + " " + capabilityStatusLabel(capability.fullSkin?.status) + )).join(" · "); +} + function renderAppVersion() { elements.appVersion.textContent = state.appVersion ? `v${state.appVersion}` : "—"; } @@ -460,9 +657,8 @@ function renderStyleTabs() { styleDefinitions.forEach(([id, labelKey, symbol]) => { const button = document.createElement("button"); button.type = "button"; - button.role = "tab"; button.dataset.style = id; - button.setAttribute("aria-selected", String(id === state.style)); + button.setAttribute("aria-pressed", String(id === state.style)); button.classList.toggle("is-active", id === state.style); const icon = document.createElement("span"); @@ -494,9 +690,8 @@ function renderCollectionTabs() { records.forEach((collection) => { const button = document.createElement("button"); button.type = "button"; - button.role = "tab"; button.dataset.collection = collection.id; - button.setAttribute("aria-selected", String(collection.id === state.collection)); + button.setAttribute("aria-pressed", String(collection.id === state.collection)); button.classList.toggle("is-active", collection.id === state.collection); const label = document.createElement("span"); label.textContent = localized(collection.name); @@ -550,52 +745,151 @@ function filteredThemes() { }).sort(compareThemePriority); } +function previewSource(preview, kind) { + if (kind === "thumbnail") { + return preview.thumbnailUrl || preview.thumbnailPath || preview.imageUrl || preview.imagePath || ""; + } + return preview.imageUrl || preview.imagePath || ""; +} + +function updateObservedThumbnail(card, force = false) { + if (!card.visible || (!force && !card.nearViewport)) return; + const source = previewSource(card.theme.previews[state.mode], "thumbnail"); + if (card.image.dataset.source === source) return; + card.image.src = source; + card.image.dataset.source = source; +} + +function ensureThumbnailObserver() { + if (state.thumbnailObserver || !("IntersectionObserver" in window)) return; + state.thumbnailObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + const card = state.themeCards.get(entry.target.dataset.themeId); + if (!card) return; + card.nearViewport = entry.isIntersecting; + if (entry.isIntersecting) updateObservedThumbnail(card); + }); + }, { + root: elements.themeList, + rootMargin: "180px 0px", + }); +} + +function resetThumbnailObserver() { + state.thumbnailObserver?.disconnect(); + state.thumbnailObserver = null; +} + +function createThemeCard(theme) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.themeId = theme.id; + + const thumb = document.createElement("span"); + thumb.className = "theme-thumb"; + const image = document.createElement("img"); + image.alt = ""; + image.width = 320; + image.height = 180; + image.loading = "lazy"; + image.decoding = "async"; + thumb.append(image); + + const copy = document.createElement("span"); + copy.className = "theme-list-copy"; + const meta = document.createElement("small"); + meta.className = "theme-card-meta"; + const title = document.createElement("strong"); + const subtitle = document.createElement("small"); + copy.append(meta, title, subtitle); + + const detail = document.createElement("span"); + detail.className = "theme-detail-action"; + button.append(thumb, copy, detail); + const card = { + theme, + button, + image, + meta, + title, + subtitle, + detail, + visible: false, + nearViewport: false, + }; + state.themeCards.set(theme.id, card); + return card; +} + +function ensureThemeCards() { + if (!state.catalog) return; + const catalogIds = state.catalog.themes.map((theme) => theme.id); + const intact = catalogIds.length === state.themeCards.size + && catalogIds.every((id) => state.themeCards.has(id)); + if (intact) return; + resetThumbnailObserver(); + state.themeCards.clear(); + const fragment = document.createDocumentFragment(); + state.catalog.themes.forEach((theme) => fragment.append(createThemeCard(theme).button)); + elements.themeList.replaceChildren(fragment); +} + +function updateThemeCard(theme, visible, visibleIndex) { + const card = state.themeCards.get(theme.id); + if (!card) return; + const active = theme.id === state.themeId; + card.button.hidden = !visible; + card.button.classList.toggle("is-active", active); + card.button.setAttribute("aria-pressed", String(active)); + card.visible = visible; + if (!visible) { + card.nearViewport = false; + state.thumbnailObserver?.unobserve(card.button); + card.image.removeAttribute("src"); + delete card.image.dataset.source; + return; + } + + const collection = state.catalog.collections.find((record) => record.id === theme.collection); + const name = localized(theme.name); + card.meta.textContent = localized(collection?.name) + " · " + t(theme.variant); + card.title.textContent = name; + card.subtitle.textContent = localized(theme.tagline); + card.detail.textContent = t("viewDetails") + " →"; + card.button.setAttribute("aria-label", name + " · " + t("viewDetails")); + ensureThumbnailObserver(); + if (state.thumbnailObserver) { + state.thumbnailObserver.observe(card.button); + updateObservedThumbnail(card); + } else if (visibleIndex < 4) { + updateObservedThumbnail(card, true); + } + card.image.fetchPriority = visibleIndex < 2 ? "high" : "auto"; +} + function renderThemeList() { const themes = filteredThemes(); + ensureThemeCards(); elements.resultCount.textContent = t("resultsCount", { count: themes.length }); if (themes.length && !themes.some((theme) => theme.id === state.themeId)) { state.themeId = themes[0].id; } if (!themes.length) state.themeId = null; - const fragment = document.createDocumentFragment(); - themes.forEach((theme) => { - const button = document.createElement("button"); - button.type = "button"; - button.role = "option"; - button.dataset.themeId = theme.id; - button.setAttribute("aria-selected", String(theme.id === state.themeId)); - button.classList.toggle("is-active", theme.id === state.themeId); - - const thumb = document.createElement("span"); - thumb.className = "theme-thumb"; - const image = document.createElement("img"); - image.src = theme.previews[state.mode].imageUrl; - image.alt = ""; - thumb.append(image); - const copy = document.createElement("span"); - copy.className = "theme-list-copy"; - const meta = document.createElement("small"); - meta.className = "theme-card-meta"; - meta.textContent = localized(state.catalog.collections.find((collection) => collection.id === theme.collection)?.name) - + " · " + t(theme.variant); - const title = document.createElement("strong"); - title.textContent = localized(theme.name); - const subtitle = document.createElement("small"); - subtitle.textContent = localized(theme.tagline); - copy.append(meta, title, subtitle); - const arrow = document.createElement("span"); - arrow.className = "theme-arrow"; - arrow.textContent = "↗"; - button.append(thumb, copy, arrow); - fragment.append(button); + const visibleIndexes = new Map(themes.map((theme, index) => [theme.id, index])); + state.catalog.themes.forEach((theme) => { + updateThemeCard(theme, visibleIndexes.has(theme.id), visibleIndexes.get(theme.id)); }); - elements.themeList.replaceChildren(fragment); + themes.forEach((theme) => elements.themeList.append(state.themeCards.get(theme.id).button)); elements.emptyState.hidden = themes.length > 0; if (themes.length) { renderSelectedTheme(); } else { elements.themeCapture.removeAttribute("src"); elements.themeCapture.alt = ""; + elements.captureBadgeLabel.textContent = ""; + elements.captureVersion.textContent = ""; + elements.captureProofLabel.textContent = ""; + elements.captureHash.textContent = ""; elements.collectionName.textContent = ""; elements.themeName.textContent = t("emptyState"); elements.themeTagline.textContent = ""; @@ -611,9 +905,16 @@ function renderSelectedTheme() { if (!theme) return; elements.applySkin.disabled = state.targets.length === 0; const preview = theme.previews[state.mode]; - elements.themeCapture.src = preview.imageUrl; - elements.themeCapture.alt = localized(theme.name) + " · " + t(state.mode) + " · " + t("captureAlt"); - elements.captureVersion.textContent = preview.capture.appVersion; + const captureVerified = captureEvidenceIsVerified(); + elements.themeCapture.src = previewSource(preview, "image"); + elements.themeCapture.alt = localized(theme.name) + " · " + t(state.mode) + " · " + + t(captureVerified ? "captureAlt" : "referenceCaptureAlt"); + elements.captureBadgeLabel.textContent = t(captureVerified ? "verifiedCapture" : "referenceCapture"); + elements.captureVersion.textContent = t( + captureVerified ? "captureVersionVerified" : "captureVersionReference", + { version: preview.capture.appVersion }, + ); + elements.captureProofLabel.textContent = t(captureVerified ? "captureProof" : "referenceCaptureProof"); elements.captureHash.textContent = shortHash(preview.capture.sha256); elements.collectionName.textContent = localized(currentCollection()?.name) + " / " + t(theme.variant).toUpperCase(); elements.themeName.textContent = localized(theme.name); @@ -630,18 +931,63 @@ function renderSelectedTheme() { elements.themeList.querySelectorAll("[data-theme-id]").forEach((button) => { const active = button.dataset.themeId === state.themeId; button.classList.toggle("is-active", active); - button.setAttribute("aria-selected", String(active)); + button.setAttribute("aria-pressed", String(active)); }); renderSkinState(); renderPersistenceState(); } +function targetDiagnosticLabel(diagnostic) { + const labels = { + "command-unavailable": "targetDiagnosticCommandUnavailable", + "permission-denied": "targetDiagnosticPermissionDenied", + "malformed-package": "targetDiagnosticMalformedPackage", + "malformed-output": "targetDiagnosticMalformedOutput", + "command-failed": "targetDiagnosticCommandFailed", + "unsupported-platform": "targetDiagnosticUnsupported", + }; + return t(labels[diagnostic?.code] || "targetDiagnosticCommandFailed"); +} + +function targetDiagnosticText() { + const diagnostic = state.targetDiagnostic; + if (!diagnostic) return ""; + const detail = String(diagnostic.detail || "").replace(/\s+/gu, " ").trim(); + return `${targetDiagnosticLabel(diagnostic)}\nCode: ${diagnostic.code || "command-failed"}${detail ? `\n${detail}` : ""}`; +} + +async function refreshTargets() { + const discovery = await window.act.discoverTargets(); + state.targets = discovery.targets || []; + state.targetDiagnostic = discovery.diagnostic || null; + renderTargets(); +} + +async function copyPlainText(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.append(input); + input.select(); + const copied = document.execCommand("copy"); + input.remove(); + if (!copied) throw new Error("Clipboard is unavailable"); +} + function renderTargets() { const fragment = document.createDocumentFragment(); if (!state.targets.length) { const option = document.createElement("option"); option.value = ""; - option.textContent = t("noTarget"); + option.textContent = state.targetDiagnostic + ? targetDiagnosticLabel(state.targetDiagnostic) + : t("noTarget"); fragment.append(option); } else { state.targets.forEach((target) => { @@ -652,6 +998,7 @@ function renderTargets() { }); } elements.targetSelect.replaceChildren(fragment); + elements.targetDiagnosticActions.hidden = !state.targetDiagnostic; elements.applySkin.disabled = state.targets.length === 0 || !state.themeId; renderSkinState(); renderPersistenceState(); @@ -669,13 +1016,23 @@ function renderSkinState() { elements.applySkinLabel.textContent = sameSelection ? t("reapplySkin") : t("applySkin"); elements.restoreSkin.disabled = !skin.active && !state.persistenceState?.enabled; const active = sameSessionSelection ? skin : persistentActive ? state.persistenceState : null; - elements.skinStatus.textContent = active + const monitorKey = { + healthy: "monitorHealthy", + recovering: "monitorRecovering", + degraded: "monitorDegraded", + stopped: "monitorStopped", + }[skin.monitorState]; + const monitorSuffix = skin.active && monitorKey ? " " + t(monitorKey) : ""; + const status = (active ? t("skinActive", { target: active.channel === "beta" ? "ChatGPT Beta" : "ChatGPT", theme: active.themeId, mode: t(active.mode), }) - : t("skinIdle"); + : t("skinIdle")) + monitorSuffix; + elements.skinStatus.textContent = state.targetDiagnostic && !state.targets.length + ? targetDiagnosticText() + : status; } function persistenceMatchesSelection(persistence, channel = elements.targetSelect.value) { @@ -714,10 +1071,12 @@ function renderPersistenceState() { "version-blocked": "persistenceBlocked", "retry-blocked": "persistenceError", error: "persistenceError", + "needs-user-close": "persistenceNeedsUserClose", + "force-closing": "persistenceForceClosing", }; const label = t(labels[persistence.phase] || "persistenceDisabled"); elements.persistenceStatus.textContent = persistence.detail - && ["blocked", "target-missing", "version-blocked", "retrying", "retry-blocked", "error"].includes(persistence.phase) + && ["blocked", "target-missing", "version-blocked", "retrying", "retry-blocked", "error", "needs-user-close", "force-closing"].includes(persistence.phase) ? `${label} · ${persistence.detail}` : label; } @@ -725,6 +1084,12 @@ function renderPersistenceState() { const busy = ["starting", "restarting"].includes(persistence.phase); elements.persistenceToggle.disabled = busy || (!sameSelection && (state.targets.length === 0 || !state.themeId || !channel)); + const forceRestartAvailable = state.platform === "win32" + && persistence.enabled + && persistence.channel === channel + && ["needs-user-close", "force-closing"].includes(persistence.phase); + elements.forceRestart.hidden = !forceRestartAvailable; + elements.forceRestart.disabled = !forceRestartAvailable; elements.restoreSkin.disabled = !(state.skinState?.active || persistence.enabled); } @@ -750,7 +1115,6 @@ function renderUpdateState() { function renderShortcuts() { const modifier = state.platform === "darwin" ? "⌘" : "Ctrl"; elements.searchShortcut.textContent = modifier + " K"; - elements.copyShortcut.textContent = modifier + " C"; elements.applyShortcut.textContent = modifier + " ↵"; } @@ -780,6 +1144,7 @@ function renderLanguage() { if (state.catalog) { normalizeCollectionSelection(); renderCatalogStatus(); + renderCapabilitySummary(); renderStyleTabs(); renderCollectionTabs(); renderFacetOptions(); @@ -798,7 +1163,20 @@ function acceptCatalog(payload) { status: payload.status, message: payload.message, }; - if (payload.catalog) state.catalog = payload.catalog; + if (payload.catalog) { + const nextKey = [ + payload.catalog.registrySha256 || "", + payload.catalog.source || "", + payload.catalog.themes?.map((theme) => theme.id).join(",") || "", + ].join("|"); + if (nextKey !== state.catalogKey) { + resetThumbnailObserver(); + state.themeCards.clear(); + elements.themeList.replaceChildren(); + state.catalogKey = nextKey; + } + state.catalog = payload.catalog; + } if (!state.catalog) return; const ordered = state.catalog.themes.slice().sort(compareThemePriority); state.themeId = state.catalog.themes.some((theme) => theme.id === previousTheme) @@ -806,6 +1184,7 @@ function acceptCatalog(payload) { : ordered[0]?.id; normalizeCollectionSelection(); renderCatalogStatus(); + renderCapabilitySummary(); renderStyleTabs(); renderCollectionTabs(); renderFacetOptions(); @@ -919,6 +1298,28 @@ elements.refreshCatalog.addEventListener("click", async () => { } }); +elements.retryTargetDiscovery.addEventListener("click", async () => { + elements.retryTargetDiscovery.disabled = true; + try { + await refreshTargets(); + } catch (error) { + toast(friendlyError(error, "targetDiagnosticCommandFailed"), "error"); + } finally { + elements.retryTargetDiscovery.disabled = false; + } +}); + +elements.copyTargetDiagnostic.addEventListener("click", async () => { + const text = targetDiagnosticText(); + if (!text) return; + try { + await copyPlainText(text); + toast(t("toastDiagnosticCopied")); + } catch { + toast(t("toastCopyFailed"), "error"); + } +}); + elements.copyTheme.addEventListener("click", async () => { try { await window.act.copyTheme(state.themeId, state.mode); @@ -928,10 +1329,25 @@ elements.copyTheme.addEventListener("click", async () => { } }); +elements.forceRestart.addEventListener("click", async () => { + const channel = elements.targetSelect.value; + if (!channel || !await requestForceRestartConsent()) return; + elements.forceRestart.disabled = true; + try { + state.persistenceState = await window.act.forceRestartPersistentTheme(channel, true); + renderPersistenceState(); + toast(t("toastForceRestartRequested")); + } catch (error) { + toast(friendlyError(error, "toastPersistenceFailed"), "error"); + } finally { + renderPersistenceState(); + } +}); + elements.applySkin.addEventListener("click", async () => { const channel = elements.targetSelect.value; if (!channel || !state.themeId) return; - if (!state.persistenceState?.enabled && !await requestPersistenceConsent()) return; + if (!state.persistenceState?.enabled && !await requestPersistenceConsent(elements.applySkin)) return; elements.applySkin.disabled = true; elements.applySkinLabel.textContent = t("applyingSkin"); try { @@ -990,7 +1406,7 @@ elements.persistenceToggle.addEventListener("change", async () => { renderPersistenceState(); return; } - if (enable && !await requestPersistenceConsent()) { + if (enable && !await requestPersistenceConsent(elements.persistenceToggle)) { renderPersistenceState(); return; } @@ -1011,15 +1427,27 @@ elements.persistenceToggle.addEventListener("change", async () => { elements.persistenceConsentCancel.addEventListener("click", () => finishPersistenceConsent(false)); elements.persistenceConsentConfirm.addEventListener("click", () => finishPersistenceConsent(true)); -elements.persistenceConsentDialog.addEventListener("click", (event) => { - if (event.target === elements.persistenceConsentDialog) finishPersistenceConsent(false); +elements.persistenceConsentDialog.addEventListener("cancel", (event) => { + if (!consentUsesNativeDialog) return; + event.preventDefault(); + finishPersistenceConsent(false); +}); +elements.persistenceConsentDialog.addEventListener("close", () => { + if (consentUsesNativeDialog && consentResolver) finishPersistenceConsent(false); +}); +elements.forceRestartCancel.addEventListener("click", () => finishForceRestartConsent(false)); +elements.forceRestartConfirm.addEventListener("click", () => finishForceRestartConsent(true)); +elements.forceRestartDialog.addEventListener("click", (event) => { + if (event.target === elements.forceRestartDialog) finishForceRestartConsent(false); }); document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && !elements.persistenceConsentDialog.hidden) { + if (event.key === "Escape" && !elements.forceRestartDialog.hidden) { event.preventDefault(); - finishPersistenceConsent(false); + finishForceRestartConsent(false); } }); +elements.persistenceConsentBackdrop.addEventListener("click", () => finishPersistenceConsent(false)); +document.addEventListener("keydown", trapFallbackConsentFocus); elements.openGallery.addEventListener("click", () => window.act.openExternal("gallery")); elements.openRepository.addEventListener("click", () => window.act.openExternal("repository")); @@ -1037,16 +1465,18 @@ elements.updateButton.addEventListener("click", async () => { }); document.addEventListener("keydown", (event) => { + if (event.defaultPrevented) return; const command = event.metaKey || event.ctrlKey; - if (command && event.key.toLowerCase() === "k") { + const key = event.key.toLowerCase(); + if (command && key === "k" && !elements.persistenceConsentDialog.open) { event.preventDefault(); elements.search.focus(); } - if (command && event.key.toLowerCase() === "c" && document.activeElement !== elements.search) { - event.preventDefault(); - elements.copyTheme.click(); - } - if (command && event.key === "Enter") { + if (command && key === "enter" && canUseGlobalActionShortcut({ + dialogOpen: elements.persistenceConsentDialog.open, + selection: window.getSelection(), + target: document.activeElement, + })) { event.preventDefault(); elements.applySkin.click(); } @@ -1067,6 +1497,7 @@ try { state.platform = bootstrap.platform; renderWindowChrome(); state.targets = bootstrap.targets; + state.targetDiagnostic = bootstrap.targetDiagnostic || null; state.skinState = bootstrap.skinState; state.persistenceState = bootstrap.persistenceState; state.updateState = bootstrap.updateState; @@ -1080,7 +1511,9 @@ try { setTimeout(() => void refreshAppUpdateState().catch(() => {}), 1800); setInterval(async () => { try { + state.skinState = await window.act.getSkinState(); state.persistenceState = await window.act.getPersistenceState(); + renderSkinState(); renderPersistenceState(); } catch { // Keep the last verified state; the next poll may recover. diff --git a/apps/theme-manager/src/renderer/bridge.js b/apps/theme-manager/src/renderer/bridge.js index f3dab95..2695564 100644 --- a/apps/theme-manager/src/renderer/bridge.js +++ b/apps/theme-manager/src/renderer/bridge.js @@ -7,6 +7,10 @@ function normalizeCatalog(catalog) { for (const theme of catalog.themes || []) { for (const mode of ["light", "dark"]) { const preview = theme.previews?.[mode]; + if (preview?.thumbnailPath) { + preview.thumbnailUrl = convertFileSrc(preview.thumbnailPath); + delete preview.thumbnailPath; + } if (preview?.imagePath) { preview.imageUrl = convertFileSrc(preview.imagePath); delete preview.imagePath; @@ -39,6 +43,7 @@ function subscribe(channel, callback) { window.act = Object.freeze({ bootstrap: async () => normalizeCatalogPayload(await invoke("bootstrap")), + discoverTargets: () => invoke("discover_targets"), refreshCatalog: async () => normalizeCatalogPayload(await invoke("refresh_catalog")), copyTheme: (themeId, mode) => invoke("copy_theme", { themeId, mode }), openCodex: (channel) => invoke("open_codex", { channel }), @@ -49,6 +54,8 @@ window.act = Object.freeze({ enablePersistentTheme: (themeId, mode, channel, consent, applyNow = false) => invoke("enable_persistent_theme", { themeId, mode, channel, consent, applyNow }), disablePersistentTheme: () => invoke("disable_persistent_theme"), + forceRestartPersistentTheme: (channel, confirmed) => + invoke("force_restart_persistent_theme", { channel, confirmed }), openExternal: (target) => invoke("open_external", { target }), checkForAppUpdate: () => invoke("check_for_app_update"), installAppUpdate: () => invoke("install_app_update"), diff --git a/apps/theme-manager/src/renderer/index.html b/apps/theme-manager/src/renderer/index.html index 7001fdb..26cb278 100644 --- a/apps/theme-manager/src/renderer/index.html +++ b/apps/theme-manager/src/renderer/index.html @@ -53,7 +53,7 @@

主题浏览

找到适合你的工作区。

-

按风格和系列筛选,应用前先看真实 Beta 截图。

+

按风格和系列筛选,应用前先看已记录的 Beta 截图。

@@ -74,13 +74,13 @@

找到适合你的视觉风格 先选整体风格

-
+
主题系列 再按系列筛选
-
+
@@ -99,7 +99,7 @@

找到适合你的

-
+
@@ -111,7 +111,7 @@

找到适合你的
- Beta 实机截图 + 已记录 Beta 截图
@@ -120,7 +120,7 @@

找到适合你的
- 真实应用截图 + 已记录应用截图 · 需要重新采集
@@ -154,6 +154,10 @@

+ +

主题包只含声明式配置与图片。管理器通过仅限本机回环的临时调试会话加载背景,不修改 WindowsApps、app.asar 或 ChatGPT 私有数据。

@@ -182,6 +187,7 @@

+
-
- +
- +
- 53 + 套主题
@@ -140,16 +140,16 @@

Windows + macOS 桌面管理器

选一套喜欢的主题,让工作区更像你自己。

- 浏览已验证的主题,比较明亮和暗色模式,然后应用到你选定的桌面应用。 + 浏览主题,比较明亮和暗色模式,然后应用到你选定的桌面应用。

  • 先看效果,再做选择 - 每种模式都有真实应用截图,先看清楚再应用。 + 每种模式都有已记录的应用截图,先看清楚再应用。
  • 一键应用完整皮肤 - Theme Manager 会把已验证的背景、材质、配色和主题文字一起应用。 + Theme Manager 会把背景、材质、配色和主题文字一起应用。
  • 随时恢复原样 @@ -175,7 +175,7 @@

    选一套喜欢的主题,让工

- Windows 上的 Theme Manager · 内嵌真实主题截图 + Windows 上的 Theme Manager · 内嵌已记录主题截图 __ACT_DESKTOP_VERSION__
@@ -224,7 +224,7 @@

原创世界、城市灯火
- 53 套主题 +
@@ -267,7 +267,7 @@

验证

03

导出

-

生成 Full Skin 记录、实机证据和 Native 配色回退。

+

生成 Full Skin 记录、应用截图证据和 Native 配色回退。

@@ -300,6 +300,7 @@

完整皮肤是主交 复制后手动导入 +

@@ -406,6 +407,7 @@

+
@@ -452,6 +454,10 @@

下载原生主题 + + 查看原始证据 + + 标准 .act-theme diff --git a/tests/fixtures/full-skin-semantic-states.html b/tests/fixtures/full-skin-semantic-states.html new file mode 100644 index 0000000..2788768 --- /dev/null +++ b/tests/fixtures/full-skin-semantic-states.html @@ -0,0 +1,16 @@ + + + +
+ Native link +

Native error

+

Native warning

+

Native success

+ + + Selectable text +
+ added line
+
- removed line
+
+ + diff --git a/tests/full-skin-runtime.test.mjs b/tests/full-skin-runtime.test.mjs index 4f4b723..735e4dd 100644 --- a/tests/full-skin-runtime.test.mjs +++ b/tests/full-skin-runtime.test.mjs @@ -2,342 +2,102 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; -import vm from "node:vm"; import { fileURLToPath } from "node:url"; +import { createFixture, executeRuntime } from "./lib/full-skin-runtime-fixture.mjs"; + const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const RUNTIME = await readFile(path.join(ROOT, "packages", "full-skin", "runtime.js"), "utf8"); -class FakeStyle { - constructor() { - this.values = new Map(); - } - - setProperty(name, value) { - this.values.set(name, String(value)); - } - - getPropertyValue(name) { - return this.values.get(name) || ""; - } - - removeProperty(name) { - this.values.delete(name); - } - - [Symbol.iterator]() { - return this.values.keys(); - } -} - -class FakeClassList { - constructor() { - this.values = new Set(); - } - - add(...names) { - for (const name of names) this.values.add(name); - } - - remove(...names) { - for (const name of names) this.values.delete(name); - } - - contains(name) { - return this.values.has(name); - } - - toggle(name, force) { - const enabled = force === undefined ? !this.values.has(name) : Boolean(force); - if (enabled) this.values.add(name); - else this.values.delete(name); - return enabled; - } -} - -class FakeElement { - constructor(document, tagName) { - this.ownerDocument = document; - this.tagName = tagName.toUpperCase(); - this.children = []; - this.parentElement = null; - this.classList = new FakeClassList(); - this.dataset = {}; - this.style = new FakeStyle(); - this.hidden = false; - this.textContent = ""; - this.attributes = new Map(); - } - - append(...children) { - for (const child of children) this.appendChild(child); - } - - appendChild(child) { - child.remove(); - child.parentElement = this; - this.children.push(child); - this.ownerDocument.register(child); - return child; - } - - prepend(child) { - child.remove(); - child.parentElement = this; - this.children.unshift(child); - this.ownerDocument.register(child); - } - - replaceChildren(...children) { - for (const child of [...this.children]) child.remove(); - this.children = []; - this.append(...children); - } - - remove() { - if (this.parentElement) { - this.parentElement.children = this.parentElement.children.filter((child) => child !== this); - } - this.parentElement = null; - this.ownerDocument.unregister(this); - } - - setAttribute(name, value) { - this.attributes.set(name, String(value)); - } -} - -class FakeImageElement extends FakeElement { - constructor(document) { - super(document, "img"); - this.complete = true; - this.naturalWidth = 32; - this.naturalHeight = 18; - this.currentSrc = ""; - } - - set src(value) { - this.currentSrc = String(value); - } - - get src() { - return this.currentSrc; - } - - addEventListener() {} - removeEventListener() {} -} - -function createFixture() { - const byId = new Map(); - const observers = []; - const intervals = new Map(); - const revoked = []; - let blobSequence = 0; - let intervalSequence = 0; - let home = true; - - const document = { - readyState: "complete", - register(element) { - if (element.id) byId.set(element.id, element); - for (const child of element.children) this.register(child); - }, - unregister(element) { - if (element.id && byId.get(element.id) === element) byId.delete(element.id); - for (const child of element.children) this.unregister(child); - }, - createElement(tagName) { - return tagName.toLowerCase() === "img" - ? new FakeImageElement(this) - : new FakeElement(this, tagName); - }, - getElementById(id) { - return byId.get(id) || null; - }, - querySelector(selector) { - if (selector.includes("home-suggestions") || selector.includes("home-icon")) { - return home ? main : null; - } - if (selector.includes("main.main-surface") || selector === "main" || selector.includes("[role='main']")) { - return main; - } - return null; - }, - querySelectorAll(selector) { - if (selector === "[role='main']") return [main]; - if (selector.includes("h1, h2")) return []; - return []; - }, - addEventListener() {}, - }; - document.documentElement = new FakeElement(document, "html"); - document.head = new FakeElement(document, "head"); - document.body = new FakeElement(document, "body"); - document.documentElement.append(document.head, document.body); - const main = new FakeElement(document, "main"); - main.classList.add("main-surface"); - main.setAttribute("role", "main"); - document.body.append(main); - - class FakeMutationObserver { - constructor(callback) { - this.callback = callback; - this.disconnected = false; - observers.push(this); - } - - observe() {} - - disconnect() { - this.disconnected = true; - } - - trigger() { - if (!this.disconnected) this.callback([]); - } - } - - class PreloadImage { - constructor() { - this.complete = true; - this.naturalWidth = 2560; - this.naturalHeight = 1440; - } - - set src(value) { - this.currentSrc = String(value); - } - - async decode() {} - addEventListener() {} - removeEventListener() {} - } - - const context = vm.createContext({ - atob, - Blob, - clearInterval(id) { - intervals.delete(id); - }, - clearTimeout, - console, - document, - getComputedStyle(element) { - if (element === document.body) { - return { - backgroundImage: document.documentElement.style.getPropertyValue("--act-art-image"), - }; - } - return { position: "fixed" }; - }, - HTMLImageElement: FakeImageElement, - Image: PreloadImage, - MutationObserver: FakeMutationObserver, - setInterval(callback, milliseconds) { - const id = ++intervalSequence; - intervals.set(id, { callback, milliseconds }); - return id; - }, - setTimeout, - Uint8Array, - URL: { - createObjectURL() { - blobSequence += 1; - return `blob:act-runtime-${blobSequence}`; - }, - revokeObjectURL(value) { - revoked.push(String(value)); - }, - }, - }); - context.window = context; - - return { - context, - document, - intervals, - observers, - revoked, - setHome(value) { - home = Boolean(value); - }, - }; -} - -function expandedRuntime(themeId) { - const theme = { - id: themeId, - mode: "dark", - name: `Theme ${themeId}`, - tagline: "A stable route-spanning skin", - art: { focusX: 0.72, focusY: 0.45, safeArea: "left" }, - tokens: { - background: "#101010", - surface: "#181818", - surfaceAlt: "#202020", - text: "#f5f5f5", - muted: "#b8b8b8", - accent: "#d4a94a", - accentContrast: "#101010", - border: "#555555", - }, - }; - return RUNTIME - .replace("__ACT_THEME_JSON__", JSON.stringify(theme)) - .replace("__ACT_CSS_JSON__", JSON.stringify("html.act-full-skin body { background-image: var(--act-art-image); }")) - .replace("__ACT_IMAGE_JSON__", JSON.stringify("data:image/png;base64,iVBORw0KGgo=")); -} - -async function execute(fixture, themeId) { - return vm.runInContext(expandedRuntime(themeId), fixture.context, { timeout: 2000 }); +function wait(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); } test("Full Skin uses a Blob-backed document background instead of a removable image node", async function () { const fixture = createFixture(); - const result = await execute(fixture, "route-stable"); + const result = await executeRuntime(RUNTIME, fixture, "route-stable"); const state = fixture.context.__ACT_FULL_SKIN_STATE__; assert.equal(result.pass, true); assert.equal(result.version, "act-full-skin-v1"); - assert.equal(result.implementationVersion, "act-full-skin-runtime-v2"); + assert.equal(result.implementationVersion, "act-full-skin-runtime-v3"); assert.equal(fixture.document.getElementById("act-full-skin-art"), null); assert.match(fixture.document.documentElement.style.getPropertyValue("--act-art-image"), /^url\("blob:act-runtime-1"\)$/); assert.equal(state.artwork.loaded, true); assert.equal(state.artwork.width, 2560); assert.equal(state.artwork.height, 1440); assert.equal(fixture.intervals.size, 1); + assert.equal(state.observer.target, fixture.document.body); + assert.deepEqual(state.observer.options, { childList: true, subtree: true }); }); -test("Full Skin coalesces a mutation storm and repairs route-owned markers once", async function () { +test("Full Skin ignores unrelated output mutations and reconciles a changed route once", async function () { const fixture = createFixture(); - await execute(fixture, "coalesced"); + await executeRuntime(RUNTIME, fixture, "coalesced"); const state = fixture.context.__ACT_FULL_SKIN_STATE__; const initialEnsures = state.metrics.ensureCalls; - fixture.setHome(false); - for (let index = 0; index < 50; index += 1) fixture.observers.at(-1).trigger(); - await new Promise((resolve) => setTimeout(resolve, 260)); + for (let index = 0; index < 200; index += 1) fixture.emitOutputMutation(); + await wait(260); + + assert.equal(state.metrics.mutationCallbacks, 200); + assert.equal(state.metrics.ignoredMutationCallbacks, 200); + assert.equal(state.metrics.scheduledReconciles, 0); + assert.equal(state.metrics.ensureCalls, initialEnsures); - assert.equal(state.metrics.mutationCallbacks, 50); - assert.equal(state.metrics.scheduledEnsures, 1); + fixture.setHome(false); + fixture.emitRouteMutation(); + await wait(260); + + assert.equal(state.metrics.relevantMutationCallbacks, 1); + assert.equal(state.metrics.scheduledReconciles, 1); assert.equal(state.metrics.ensureCalls, initialEnsures + 1); assert.equal(fixture.document.documentElement.classList.contains("act-full-skin-home"), false); assert.equal(fixture.document.documentElement.classList.contains("act-full-skin-task"), true); +}); + +test("Full Skin repairs a missing runtime marker, main target replacement, repeated navigation, and cleanup", async function () { + const fixture = createFixture(); + await executeRuntime(RUNTIME, fixture, "repairable"); + const state = fixture.context.__ACT_FULL_SKIN_STATE__; + const initialEnsures = state.metrics.ensureCalls; + + fixture.document.documentElement.classList.remove("act-full-skin"); + fixture.runIntervals(); + assert.equal(state.metrics.fallbackHealthChecks, 1); + assert.equal(state.metrics.ensureCalls, initialEnsures + 1); + assert.equal(fixture.document.documentElement.classList.contains("act-full-skin"), true); + + fixture.replaceMain(false); + await wait(260); + assert.equal(fixture.document.documentElement.classList.contains("act-full-skin-task"), true); - const settledEnsures = state.metrics.ensureCalls; - const settledSchedules = state.metrics.scheduledEnsures; - for (let index = 0; index < 50; index += 1) fixture.observers.at(-1).trigger(); - await new Promise((resolve) => setTimeout(resolve, 260)); - assert.equal(state.metrics.ensureCalls, settledEnsures); - assert.equal(state.metrics.scheduledEnsures, settledSchedules); + const previousObserver = state.observer; + fixture.replaceBody(true); + fixture.runIntervals(); + assert.equal(state.observer.target, fixture.document.body); + assert.equal(previousObserver.disconnected, true); + assert.equal(fixture.document.documentElement.classList.contains("act-full-skin-home"), true); + + for (const home of [true, false, true]) { + fixture.setHome(home); + fixture.emitRouteMutation(); + await wait(260); + assert.equal(fixture.document.documentElement.classList.contains("act-full-skin-home"), home); + } + + assert.equal(state.cleanup(), true); + assert.equal(fixture.intervals.size, 0); + assert.equal(state.observer.disconnected, true); + assert.equal(fixture.document.getElementById("act-full-skin-style"), null); + assert.equal(fixture.document.getElementById("act-full-skin-caption"), null); }); test("A retired runtime cannot clean up a newer theme", async function () { const fixture = createFixture(); - await execute(fixture, "first"); + await executeRuntime(RUNTIME, fixture, "first"); const first = fixture.context.__ACT_FULL_SKIN_STATE__; - await execute(fixture, "second"); + await executeRuntime(RUNTIME, fixture, "second"); const second = fixture.context.__ACT_FULL_SKIN_STATE__; assert.notEqual(first, second); diff --git a/tests/lib/full-skin-runtime-fixture.mjs b/tests/lib/full-skin-runtime-fixture.mjs new file mode 100644 index 0000000..b80868f --- /dev/null +++ b/tests/lib/full-skin-runtime-fixture.mjs @@ -0,0 +1,378 @@ +import vm from "node:vm"; + +class FakeStyle { + constructor() { + this.values = new Map(); + } + + setProperty(name, value) { + this.values.set(name, String(value)); + } + + getPropertyValue(name) { + return this.values.get(name) || ""; + } + + removeProperty(name) { + this.values.delete(name); + } + + [Symbol.iterator]() { + return this.values.keys(); + } +} + +class FakeClassList { + constructor() { + this.values = new Set(); + } + + add(...names) { + for (const name of names) this.values.add(name); + } + + remove(...names) { + for (const name of names) this.values.delete(name); + } + + contains(name) { + return this.values.has(name); + } + + toggle(name, force) { + const enabled = force === undefined ? !this.values.has(name) : Boolean(force); + if (enabled) this.values.add(name); + else this.values.delete(name); + return enabled; + } + + includes(fragment) { + return [...this.values].some((name) => name.includes(fragment)); + } +} + +class FakeElement { + constructor(document, tagName) { + this.ownerDocument = document; + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentElement = null; + this.classList = new FakeClassList(); + this.dataset = {}; + this.style = new FakeStyle(); + this.hidden = false; + this.id = ""; + this.textContent = ""; + this.attributes = new Map(); + } + + append(...children) { + for (const child of children) this.appendChild(child); + } + + appendChild(child) { + child.remove(); + child.parentElement = this; + this.children.push(child); + this.ownerDocument.register(child); + return child; + } + + replaceChildren(...children) { + for (const child of [...this.children]) child.remove(); + this.children = []; + this.append(...children); + } + + remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((child) => child !== this); + } + this.parentElement = null; + this.ownerDocument.unregister(this); + } + + setAttribute(name, value) { + const normalized = String(value); + this.attributes.set(name, normalized); + if (name === "id") this.id = normalized; + } + + getAttribute(name) { + return this.attributes.get(name) || null; + } + + matches(selector) { + return selector.split(",").some((candidate) => { + const value = candidate.trim(); + if (!value) return false; + if (value.startsWith("#")) return this.id === value.slice(1); + if (value === "main") return this.tagName === "MAIN"; + if (value.includes("main.main-surface")) { + return this.tagName === "MAIN" && this.classList.contains("main-surface"); + } + if (value.includes("[role='main']") || value.includes('[role="main"]')) { + return this.getAttribute("role") === "main"; + } + if (value.includes("[data-testid='home-icon']")) { + return this.getAttribute("data-testid") === "home-icon"; + } + if (value.includes("home-suggestions")) return this.classList.includes("home-suggestions"); + if (value === "h1" || value === "h2") return this.tagName === value.toUpperCase(); + if (value.includes("[class*='title']") || value.includes('[class*="title"]')) { + return this.classList.includes("title"); + } + return false; + }); + } + + querySelector(selector) { + if (this.matches(selector)) return this; + for (const child of this.children) { + const match = child.querySelector(selector); + if (match) return match; + } + return null; + } + + querySelectorAll(selector) { + const matches = []; + if (this.matches(selector)) matches.push(this); + for (const child of this.children) matches.push(...child.querySelectorAll(selector)); + return matches; + } +} + +class FakeImageElement extends FakeElement { + constructor(document) { + super(document, "img"); + this.complete = true; + this.naturalWidth = 32; + this.naturalHeight = 18; + this.currentSrc = ""; + } + + set src(value) { + this.currentSrc = String(value); + } + + get src() { + return this.currentSrc; + } + + addEventListener() {} + removeEventListener() {} +} + +export function createFixture() { + const byId = new Map(); + const observers = []; + const intervals = new Map(); + const revoked = []; + let blobSequence = 0; + let intervalSequence = 0; + let home = true; + let main; + + const document = { + readyState: "complete", + register(element) { + if (element.id) byId.set(element.id, element); + for (const child of element.children) this.register(child); + }, + unregister(element) { + if (element.id && byId.get(element.id) === element) byId.delete(element.id); + for (const child of element.children) this.unregister(child); + }, + createElement(tagName) { + return tagName.toLowerCase() === "img" + ? new FakeImageElement(this) + : new FakeElement(this, tagName); + }, + getElementById(id) { + return byId.get(id) || null; + }, + querySelector(selector) { + if (selector.includes(":has([data-testid='home-icon'])")) return home ? main : null; + if (selector.includes("home-suggestions")) return home ? main : null; + if (selector.includes("main.main-surface") || selector === "main" || selector.includes("[role='main']")) { + return main; + } + return this.documentElement.querySelector(selector); + }, + querySelectorAll(selector) { + if (selector === "[role='main']") return main ? [main] : []; + if (selector.includes("h1, h2")) return []; + return this.documentElement.querySelectorAll(selector); + }, + addEventListener() {}, + }; + document.documentElement = new FakeElement(document, "html"); + document.head = new FakeElement(document, "head"); + document.body = new FakeElement(document, "body"); + document.documentElement.append(document.head, document.body); + + const createMain = () => { + const element = new FakeElement(document, "main"); + element.classList.add("main-surface"); + element.setAttribute("role", "main"); + return element; + }; + main = createMain(); + document.body.append(main); + + class FakeMutationObserver { + constructor(callback) { + this.callback = callback; + this.disconnected = false; + this.target = null; + this.options = null; + observers.push(this); + } + + observe(target, options) { + this.target = target; + this.options = { ...options }; + } + + disconnect() { + this.disconnected = true; + } + + trigger(records = []) { + if (!this.disconnected) this.callback(records); + } + } + + class PreloadImage { + constructor() { + this.complete = true; + this.naturalWidth = 2560; + this.naturalHeight = 1440; + } + + set src(value) { + this.currentSrc = String(value); + } + + async decode() {} + addEventListener() {} + removeEventListener() {} + } + + const context = vm.createContext({ + atob, + Blob, + clearInterval(id) { + intervals.delete(id); + }, + clearTimeout, + console, + document, + getComputedStyle(element) { + if (element === document.body) { + return { + backgroundImage: document.documentElement.style.getPropertyValue("--act-art-image"), + }; + } + return { position: "fixed" }; + }, + HTMLImageElement: FakeImageElement, + Image: PreloadImage, + MutationObserver: FakeMutationObserver, + setInterval(callback, milliseconds) { + const id = ++intervalSequence; + intervals.set(id, { callback, milliseconds }); + return id; + }, + setTimeout, + Uint8Array, + URL: { + createObjectURL() { + blobSequence += 1; + return `blob:act-runtime-${blobSequence}`; + }, + revokeObjectURL(value) { + revoked.push(String(value)); + }, + }, + }); + context.window = context; + + const activeObserver = () => observers.at(-1); + const trigger = (record) => activeObserver()?.trigger([record]); + + return { + context, + document, + intervals, + observers, + revoked, + get main() { + return main; + }, + setHome(value) { + home = Boolean(value); + }, + emitOutputMutation() { + const output = document.createElement("div"); + output.classList.add("streaming-output"); + trigger({ type: "childList", target: main, addedNodes: [output], removedNodes: [] }); + }, + emitRouteMutation() { + const marker = document.createElement(home ? "div" : "h1"); + if (home) marker.setAttribute("data-testid", "home-icon"); + trigger({ type: "childList", target: main, addedNodes: home ? [marker] : [], removedNodes: home ? [] : [marker] }); + }, + replaceMain(nextHome) { + const previous = main; + const replacement = createMain(); + document.body.append(replacement); + previous.remove(); + main = replacement; + home = Boolean(nextHome); + trigger({ type: "childList", target: document.body, addedNodes: [replacement], removedNodes: [previous] }); + }, + replaceBody(nextHome) { + const previous = document.body; + const replacement = new FakeElement(document, "body"); + previous.remove(); + document.documentElement.append(replacement); + document.body = replacement; + main = createMain(); + replacement.append(main); + home = Boolean(nextHome); + }, + runIntervals() { + for (const entry of [...intervals.values()]) entry.callback(); + }, + }; +} + +export function expandedRuntime(runtime, themeId, cssText = "html.act-full-skin body { background-image: var(--act-art-image); }") { + const theme = { + id: themeId, + mode: "dark", + name: `Theme ${themeId}`, + tagline: "A stable route-spanning skin", + art: { focusX: 0.72, focusY: 0.45, safeArea: "left" }, + tokens: { + background: "#101010", + surface: "#181818", + surfaceAlt: "#202020", + text: "#f5f5f5", + muted: "#b8b8b8", + accent: "#d4a94a", + accentContrast: "#101010", + border: "#555555", + }, + }; + return runtime + .replace("__ACT_THEME_JSON__", JSON.stringify(theme)) + .replace("__ACT_CSS_JSON__", JSON.stringify(cssText)) + .replace("__ACT_IMAGE_JSON__", JSON.stringify("data:image/png;base64,iVBORw0KGgo=")); +} + +export async function executeRuntime(runtime, fixture, themeId) { + return vm.runInContext(expandedRuntime(runtime, themeId), fixture.context, { timeout: 2000 }); +} diff --git a/tests/repository.test.mjs b/tests/repository.test.mjs index c3ecb5e..63aebdf 100644 --- a/tests/repository.test.mjs +++ b/tests/repository.test.mjs @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; import { buildSite } from "../scripts/build.mjs"; import { buildWindowsInstaller } from "../scripts/build-installer.mjs"; @@ -29,14 +31,19 @@ async function exists(target) { test("repository validates sixty-eight dual-mode code-free themes in eleven collections", async function () { const result = await validateRepository(); - assert.deepEqual(result, { + assert.equal(result.sources, 68); + assert.equal(result.themes, 68); + assert.equal(result.modes, 136); + assert.equal(result.packages, 68); + assert.equal(result.fullSkinExports, 136); + assert.equal(result.nativeExports, 136); + assert.equal(result.captures, 136); + assert.equal(result.presentationVariants, 408); + assert.ok(result.conservativeGalleryInitialBytes <= 3 * 1024 * 1024); + assert.deepEqual(result.focalCropDiagnostics, { + horizontallyAdjustable: 0, sources: 68, - themes: 68, - modes: 136, - packages: 68, - fullSkinExports: 136, - nativeExports: 136, - captures: 136 + verticallyAdjustable: 68, }); }); @@ -46,8 +53,20 @@ test("registry exposes original and disclosed fan-art collections", async functi readFile(path.join(ROOT, "themes", "registry.json"), "utf8").then(JSON.parse), readFile(path.join(ROOT, "screenshots", "codex-beta-26.715.3651.0", "manifest.json"), "utf8").then(JSON.parse), ]); - assert.equal(catalog.catalogRevision, 2026072201); + assert.equal(catalog.catalogRevision, 2026072701); assert.equal(registry.catalogRevision, catalog.catalogRevision); + assert.deepEqual(registry.runtimeEvidence, { + status: "reference", + capturedRuntime: { + implementationVersion: "act-full-skin-runtime-v2", + sha256: "351922db02564b328caabaa762600653e89abbddd93b6eee640d4da28cc4a594", + }, + currentRuntime: { + implementationVersion: "act-full-skin-runtime-v3", + sha256: "7e601b402ce1aae3b1a72e55cfaffc2b23cf03951401100d3e0918138ca2445d", + verification: "source-tested-recapture-required", + }, + }); assert.ok( String(catalog.catalogRevision).slice(0, 8) >= captureManifest.capturedAt.slice(0, 10).replaceAll("-", ""), @@ -86,15 +105,22 @@ test("registry exposes original and disclosed fan-art collections", async functi assert.equal(registry.themes.every(function (theme) { return ["light", "dark"].every(function (mode) { const capture = theme.previews[mode].capture; - return capture?.appVersion === "26.715.3651.0" - && capture.fixture === "full-skin-home-v1" - && capture.modelLabel === "5.6 Sol Max" - && capture.width === 1440 - && capture.height === 810 - && capture.assetSha256 === theme.previews[mode].fullSkin.sha256 - && capture.markerVersion === "act-full-skin-v1" - && capture.selectors.main === true - && capture.selectors.composer === true; + const evidence = capture?.evidence; + return evidence?.appVersion === "26.715.3651.0" + && evidence.fixture === "full-skin-home-v1" + && evidence.modelLabel === "5.6 Sol Max" + && evidence.width === 1440 + && evidence.height === 810 + && evidence.assetSha256 === theme.previews[mode].fullSkin.sha256 + && evidence.markerVersion === "act-full-skin-v1" + && evidence.selectors.main === true + && evidence.selectors.composer === true + && capture.presentation?.length === 3 + && [320, 480, 960].every(function (width) { + return capture.presentation.some(function (variant) { + return variant.width === width && variant.evidenceSha256 === evidence.sha256; + }); + }); }); }), true); assert.equal(registry.themes.every(function (theme) { @@ -119,6 +145,28 @@ test("registry exposes original and disclosed fan-art collections", async functi }), true); }); +test("focal-aware review artifacts cover every current source image", async function () { + const [catalog, approval] = await Promise.all([ + readFile(path.join(ROOT, "themes", "catalog.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "source-art", "reviews", "focal-aware-v3.json"), "utf8").then(JSON.parse), + ]); + assert.equal(approval.renderer.before, "act-source-art-renderer-v2"); + assert.equal(approval.renderer.after, "act-source-art-renderer-v3"); + assert.equal(approval.decisions.length, catalog.themes.length); + assert.deepEqual( + approval.decisions.map((decision) => decision.themeId), + catalog.themes.map((theme) => theme.id), + ); + assert.equal(approval.decisions.every((decision) => decision.decision === "approved"), true); + assert.equal(approval.fixtureSheet.order.rows.join(","), "left,right,center,none"); + assert.deepEqual(approval.fixtureSheet.order.columns, ["home", "task", "diff", "settings", "dialog", "composer"]); + for (const artifact of [...approval.contactSheets, approval.fixtureSheet]) { + const bytes = await readFile(path.join(ROOT, ...artifact.path.split("/"))); + assert.equal(bytes.length, artifact.bytes, artifact.path + " byte count"); + assert.equal(createHash("sha256").update(bytes).digest("hex"), artifact.sha256, artifact.path + " hash"); + } +}); + test("canonical archives contain only declared art, manifest, and Codex Native themes", async function () { const registry = JSON.parse(await readFile(path.join(ROOT, "themes", "registry.json"), "utf8")); for (const theme of registry.themes) { @@ -238,15 +286,22 @@ test("static gallery builds with every public contract artifact", async function for (const mode of ["light", "dark"]) { const fullSkinPath = theme.previews[mode].fullSkin.asset; const nativePath = theme.previews[mode].nativeTheme.path; - const capturePath = theme.previews[mode].capture.path; + const capture = theme.previews[mode].capture; + const capturePath = capture.evidence.path; assert.equal(await exists(path.join(output, ...fullSkinPath.split("/"))), true, fullSkinPath); assert.equal(await exists(path.join(output, ...nativePath.split("/"))), true, nativePath); assert.equal(await exists(path.join(output, ...capturePath.split("/"))), true, capturePath); + for (const variant of capture.presentation) { + assert.equal(await exists(path.join(output, ...variant.path.split("/"))), true, variant.path); + } } } assert.match(html, /Awesome Codex Theme/); assert.match(html, /id="trustNote"/); assert.match(html, /id="dialogRights"/); + assert.match(html, /id="evidenceMetadata"/); + assert.match(html, /id="viewEvidence"/); + assert.doesNotMatch(html, /id="heroPreview" src=/); assert.match(html, /docs\/fan-art-policy\.md/); assert.match(html, /data-filter="scene"/); assert.match(html, /id="rightsGroup"/); @@ -266,7 +321,14 @@ test("static gallery builds with every public contract artifact", async function assert.match(app, /Download Theme Manager/); assert.match(app, /社区投稿暂未开放/); assert.match(app, /nativeTheme\.path/); - assert.match(app, /modeRecord\.capture/); + assert.match(app, /capture\.evidence/); + assert.match(app, /function presentationSrcset/); + assert.match(app, /viewEvidence/); + assert.match(app, /presentationFallback/); + assert.match(app, /image\.onerror = fallback/); + assert.match(app, /function fallbackVisual/); + const updateDialog = app.slice(app.indexOf("function updateDialog()"), app.indexOf("function showToast(")); + assert.match(updateDialog, /visualFor\(modeRecord, "hero"\)/); assert.match(app, /function renderCollectionIntro/); assert.doesNotMatch(app, /39 original themes|39 套原创主题/); assert.doesNotMatch(app, /Beta 26\.707\.3351\.0/); @@ -318,6 +380,9 @@ test("Tauri manager keeps theme values in Rust and limits desktop capabilities", })); assert.match(catalog, /native_value_for/); assert.match(catalog, /REMOTE_CATALOG_URL/); + assert.match(catalog, /PRESENTATION_SIZES/); + assert.match(catalog, /thumbnailUrl/); + assert.match(catalog, /presentation_path/); assert.match(catalog, /registry_bytes\.len\(\) != expected_bytes/); assert.match(backend, /copy_theme/); assert.match(backend, /native_value_for\(&catalog, &theme_id, &mode\)/); @@ -362,7 +427,10 @@ test("Tauri manager keeps theme values in Rust and limits desktop capabilities", assert.doesNotMatch(fullSkinRuntime, /createElement\("img"\)|art\.src = imageData/); assert.match(fullSkinRuntime, /installToken/); assert.match(fullSkinRuntime, /metrics/); - assert.match(fullSkinRuntime, /new MutationObserver\(\(\) =>/); + assert.match(fullSkinRuntime, /implementationVersion = "act-full-skin-runtime-v3"/); + assert.match(fullSkinRuntime, /new MutationObserver\(onMutations\)/); + assert.match(fullSkinRuntime, /observer\.observe\(nextRoot, \{ childList: true, subtree: true \}\)/); + assert.match(fullSkinRuntime, /ignoredMutationCallbacks/); assert.match(fullSkinRuntime, /clearTimeout\(ensureTimer\)/); assert.match(fullSkinRuntime, /}, 180\)/); assert.match(fullSkinRuntime, /}, 5000\)/); @@ -395,15 +463,64 @@ test("Tauri manager keeps theme values in Rust and limits desktop capabilities", assert.match(platform, /\["ChatGPT Beta", "Codex Beta"\]/); assert.match(platform, /std::process::Command::new\(executable\)/); assert.doesNotMatch(bridge, /nativeTheme.*value|nativeValue/i); + assert.match(bridge, /preview\.thumbnailUrl = convertFileSrc\(preview\.thumbnailPath\)/); + assert.match(bridge, /delete preview\.thumbnailPath/); assert.match(bridge, /applyNow = false/); assert.match(bridge, /applyNow \}/); assert.match(updater, /ACT_UPDATER_PUBKEY/); - assert.match(config.plugins.updater.endpoints[0], /^https:\/\/github\.com\/rwang23\/awesome-codex-theme\/releases\//); + assert.equal(config.plugins.updater.endpoints[0], "https://rwang23.github.io/awesome-codex-theme/updates/stable.json"); + assert.doesNotMatch(config.plugins.updater.endpoints[0], /github\.com\/.*\/releases\/(?:latest|download)/); assert.match(appPackage, /@tauri-apps\/cli/); assert.doesNotMatch(appPackage, /electron/i); assert.doesNotMatch(backend + catalog + runtime + platform, /WindowsApps.*(?:write|copy)|app\.asar.*(?:write|copy)/i); }); +test("Tauri bridge converts both local presentation paths before the list renders", async function () { + const bridge = await readFile(path.join(ROOT, "apps", "theme-manager", "src", "renderer", "bridge.js"), "utf8"); + const catalog = { + themes: [{ + previews: { + light: { + thumbnailPath: "/catalog/screenshots/presentation/fixture-light-320.png", + imagePath: "/catalog/screenshots/presentation/fixture-light-960.png", + }, + dark: {}, + }, + }], + }; + const eventTarget = { addEventListener() {} }; + const context = { + document: { querySelector: () => eventTarget }, + window: { + __TAURI__: { + core: { + convertFileSrc: (value) => "asset://" + value, + invoke: async (command) => { + assert.equal(command, "bootstrap"); + return { catalog }; + }, + }, + event: { listen: async () => () => {} }, + window: { + getCurrentWindow: () => ({ + toggleMaximize() {}, + startDragging() {}, + minimize() {}, + close() {}, + }), + }, + }, + }, + }; + runInNewContext(bridge, context); + const payload = await context.window.act.bootstrap(); + const preview = payload.catalog.themes[0].previews.light; + assert.equal(preview.thumbnailUrl, "asset:///catalog/screenshots/presentation/fixture-light-320.png"); + assert.equal(preview.imageUrl, "asset:///catalog/screenshots/presentation/fixture-light-960.png"); + assert.equal("thumbnailPath" in preview, false); + assert.equal("imagePath" in preview, false); +}); + test("Theme Manager renders a compact Copy action, footer version, and macOS traffic-light chrome", async function () { const [html, app, styles] = await Promise.all([ readFile(path.join(ROOT, "apps", "theme-manager", "src", "renderer", "index.html"), "utf8"), @@ -429,7 +546,7 @@ test("Theme Manager renders a compact Copy action, footer version, and macOS tra assert.match(styles, /@media \(min-width: 1051px\) and \(max-height: 840px\)/); }); -test("real screenshot evidence covers every mode and confirms Beta restoration", async function () { +test("recorded screenshot evidence covers every mode and distinguishes the current runtime", async function () { const manifest = JSON.parse(await readFile( path.join(ROOT, "screenshots", "codex-beta-26.715.3651.0", "manifest.json"), "utf8", @@ -445,6 +562,12 @@ test("real screenshot evidence covers every mode and confirms Beta restoration", assert.equal(manifest.fixture.nativeSettingsChanged, false); assert.equal(manifest.runtime.format, "act-full-skin-v1"); assert.equal(manifest.runtime.implementationVersion, "act-full-skin-runtime-v2"); + assert.equal(manifest.runtime.evidenceStatus, "reference"); + assert.deepEqual(manifest.runtime.currentRuntime, { + implementationVersion: "act-full-skin-runtime-v3", + sha256: "7e601b402ce1aae3b1a72e55cfaffc2b23cf03951401100d3e0918138ca2445d", + verification: "source-tested-recapture-required", + }); assert.equal(manifest.runtime.earlyInjection, true); assert.equal(manifest.runtime.removedAfterCapture, true); assert.equal(manifest.captures.length, 136); @@ -545,7 +668,8 @@ test("desktop manager localizes from the system and makes visual styles the prim assert.match(app, /Apply & Keep Full Skin/); assert.match(app, /应用并保持完整皮肤/); assert.doesNotMatch(app, /window\.confirm\(/); - assert.match(app, /await requestPersistenceConsent\(\)/); + assert.match(app, /await requestPersistenceConsent\(elements\.applySkin\)/); + assert.match(app, /await requestPersistenceConsent\(elements\.persistenceToggle\)/); assert.match(html, /id="persistenceConsentDialog"/); assert.match(html, /id="persistenceConsentConfirm"/); assert.doesNotMatch(app, /await window\.act\.applyFullSkin\(/); @@ -553,6 +677,16 @@ test("desktop manager localizes from the system and makes visual styles the prim assert.match(app, /async function waitForPersistentApply\(/); assert.match(app, /"retry-blocked"/); assert.match(app, /state\.persistenceState = await waitForPersistentApply/); + assert.match(app, /themeCards: new Map\(\)/); + assert.match(app, /function ensureThemeCards\(\)/); + assert.match(app, /image\.loading = "lazy"/); + assert.match(app, /thumbnailUrl/); + assert.match(app, /new IntersectionObserver/); + assert.match(app, /root: elements\.themeList/); + assert.match(app, /if \(!card\.visible \|\| \(!force && !card\.nearViewport\)\) return;/); + const renderThemeList = app.slice(app.indexOf("function renderThemeList()"), app.indexOf("function renderSelectedTheme()")); + assert.match(renderThemeList, /updateThemeCard/); + assert.doesNotMatch(renderThemeList, /replaceChildren/); }); test("READMEs recommend Agent installation first and manual desktop installation last", async function () { @@ -582,13 +716,24 @@ test("Tibo copy uses a deliberate two-line God of Reset lockup", async function assert.deepEqual(immortal.tagline, { en: "God of Reset", "zh-CN": "重置之神" }); }); -test("desktop beta release requires updater signing and discloses deferred platform trust", async function () { - const [workflow, tauriConfigText] = await Promise.all([ +test("desktop beta release uses a static updater feed and discloses deferred platform trust", async function () { + const [workflow, tauriConfigText, releaseMetadata] = await Promise.all([ readFile(path.join(ROOT, ".github", "workflows", "desktop.yml"), "utf8"), - readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "tauri.conf.json"), "utf8") + readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "tauri.conf.json"), "utf8"), + readFile(path.join(ROOT, "metadata", "release.json"), "utf8").then(JSON.parse), ]); const tauriConfig = JSON.parse(tauriConfigText); - assert.match(workflow, /npm run generate && npm run validate && npm run desktop:prepare/); + assert.match(workflow, /run: npm run test:ci/); + assert.match(workflow, /run: npm run desktop:prepare/); + assert.match(workflow, /name: act-generated-theme-assets/); + assert.match(workflow, /screenshots\/\*\/presentation\/\*\*/); + const releaseLabel = releaseMetadata.desktop.channel.slice(0, 1).toUpperCase() + + releaseMetadata.desktop.channel.slice(1); + const versionPattern = releaseMetadata.desktop.version.replaceAll(".", "\\."); + assert.match( + workflow, + /Generate, validate, and test public artifacts once[\s\S]*run: npm run test:ci[\s\S]*Prepare desktop assets once[\s\S]*run: npm run desktop:prepare/, + ); assert.match(workflow, /DESKTOP_RELEASE_READY/); assert.match(workflow, /TAURI_SIGNING_PRIVATE_KEY/); assert.match(workflow, /TAURI_SIGNING_PRIVATE_KEY_PASSWORD/); @@ -596,9 +741,12 @@ test("desktop beta release requires updater signing and discloses deferred platf assert.match(workflow, /Validate Windows portable helper/); assert.match(workflow, /npm run installer:validate/); assert.match(workflow, /Updater-signed Tauri Theme Manager beta/); - assert.match(workflow, /releaseName: "Awesome Codex Theme \$\{\{ github\.ref_name \}\} Beta"/); + assert.match(workflow, new RegExp('releaseName: "Awesome Codex Theme v' + versionPattern + " " + releaseLabel + '"')); + assert.match(workflow, /Verify release tag matches metadata/); + assert.match(workflow, /RELEASE_TAG: \$\{\{ github\.ref_name \}\}/); + assert.match(workflow, new RegExp('run: test "\\$RELEASE_TAG" = "v' + versionPattern + '"')); assert.match(workflow, /unknown-publisher warning/); - assert.match(workflow, /prerelease: false/); + assert.match(workflow, new RegExp("prerelease: " + releaseMetadata.desktop.githubPrerelease)); assert.match(workflow, /releaseDraft: true/); assert.doesNotMatch(workflow, /WINDOWS_CERTIFICATE/); assert.doesNotMatch(workflow, /APPLE_CERTIFICATE/); @@ -617,13 +765,15 @@ test("desktop beta release requires updater signing and discloses deferred platf assert.doesNotMatch(workflow, /desktop-macos-(?:arm64|x64)/); assert.equal(tauriConfig.bundle.macOS.signingIdentity, "-"); assert.match(tauriConfig.plugins.updater.pubkey, /^[A-Za-z0-9+/=]{100,}$/); + assert.equal(tauriConfig.plugins.updater.endpoints[0], releaseMetadata.desktop.updater.endpoint); + assert.doesNotMatch(tauriConfig.plugins.updater.endpoints[0], /github\.com\/.*\/releases\/(?:latest|download)/); }); -test("desktop preparation derives compact thumbnails from verified captures", async function () { +test("desktop preparation derives compact thumbnails from recorded captures", async function () { const prepare = await readFile(path.join(ROOT, "scripts", "prepare-desktop.mjs"), "utf8"); - assert.match(prepare, /CAPTURE_WIDTH = 720/); - assert.match(prepare, /CAPTURE_HEIGHT = 405/); - assert.match(prepare, /resizePng/); + assert.match(prepare, /PRESENTATION_SIZES/); + assert.match(prepare, /copyFile/); + assert.match(prepare, /capture\?\.presentation/); + assert.doesNotMatch(prepare, /resizePng/); assert.match(prepare, /DESKTOP_CATALOG\.startsWith\(DESKTOP_BUILD \+ path\.sep\)/); - assert.match(prepare, /capture\?\.path/); }); diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs new file mode 100644 index 0000000..b916602 --- /dev/null +++ b/tests/source-contract.test.mjs @@ -0,0 +1,714 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { assertGeneratedArtifacts } from "../scripts/ensure-generated.mjs"; +import { + generateRepository, + parseGenerationOptions, + readVerifiedSourceProvenance, +} from "../scripts/generate-themes.mjs"; +import { + CODEX_THEME_PREFIX, + parseCodexNativeTheme, +} from "../scripts/lib/codex-native-theme.mjs"; +import { readReleaseMetadata, releaseForRegistry } from "../scripts/lib/release-metadata.mjs"; +import { + computeCoverCrop, + renderFingerprint, + safeAreaWeight, + SOURCE_ART_RENDERER_CONSTANTS, +} from "../scripts/lib/source-art-contract.mjs"; +import { + CAPTURE_PRESENTATION_SIZES, + GALLERY_MEDIA_BUDGET, + generateCapturePresentations, + presentationFingerprint, + presentationPathFor, +} from "../scripts/lib/capture-presentation.mjs"; +import { + isGalleryRequestFailure, + parseOptions as parseGalleryCheckOptions, +} from "../scripts/verify-gallery.mjs"; +import { canUseGlobalActionShortcut } from "../apps/theme-manager/src/renderer/shortcuts.js"; +import { + resolveSourceArtReview, + validateSourceArtReviewLedger, +} from "../scripts/lib/source-art-reviews.mjs"; +import { syncReleaseMetadata } from "../scripts/sync-release-metadata.mjs"; +import { + assessSourceArt, + parseImageSize, +} from "../scripts/lib/source-art-policy.mjs"; +import { decodePng, encodePng, renderSourceArtImage } from "../scripts/lib/png.mjs"; +import { contrastRatio, isSafeRelativePath } from "../scripts/validate.mjs"; +import { + assertRuntimeBudget, + runFullSkinRuntimeBenchmark, +} from "../scripts/lib/full-skin-runtime-benchmark.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("source catalog and image-job provenance stay aligned without generated artifacts", async function () { + const [catalog, jobs, themeSchema, registrySchema, reviewSchema, reviews] = await Promise.all([ + readFile(path.join(ROOT, "themes", "catalog.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "source-art", "jobs.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "schemas", "theme-pack.schema.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "schemas", "registry.schema.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "schemas", "source-art-review.schema.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "source-art", "reviews.json"), "utf8").then(JSON.parse), + ]); + + assert.equal(catalog.catalogRevision, 2026072701); + assert.equal(catalog.themes.length, 68); + assert.equal(catalog.collections.length, 11); + assert.equal(themeSchema.$schema.includes("2020-12"), true); + assert.equal(registrySchema.$schema.includes("2020-12"), true); + assert.equal(reviewSchema.$schema.includes("2020-12"), true); + assert.deepEqual( + jobs.jobs.map((job) => job.id).sort(), + catalog.themes.map((theme) => theme.id).sort(), + ); + assert.deepEqual( + jobs.sourceArtPolicy.reviewedUpscaleExceptions.flatMap((exception) => exception.themeIds).sort(), + catalog.themes.map((theme) => theme.id).sort(), + ); + assert.deepEqual(jobs.sourceArtPolicy.minimum, { + width: 2560, + height: 1440, + aspectRatio: "16:9", + }); + + for (const theme of catalog.themes) { + const rightsProfile = theme.rightsProfile || "original"; + assert.equal(["original", "fan-art"].includes(rightsProfile), true); + assert.equal(typeof theme.name.en, "string"); + assert.equal(typeof theme.name["zh-CN"], "string"); + if (rightsProfile === "fan-art") { + assert.equal(theme.fanArt.unofficial, true); + assert.equal(theme.fanArt.commercialUse, false); + assert.equal(theme.fanArt.officialAssetsUsed, false); + } + } + assert.equal(validateSourceArtReviewLedger(catalog, reviews).records.size, 49); +}); + +test("source-art approval is explicit, source-hash-bound, and excludes fan art", async function () { + const [catalog, reviews] = await Promise.all([ + readFile(path.join(ROOT, "themes", "catalog.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "source-art", "reviews.json"), "utf8").then(JSON.parse), + ]); + const parsed = validateSourceArtReviewLedger(catalog, reviews); + const original = catalog.themes.find((theme) => (theme.rightsProfile || "original") === "original"); + const fanArt = catalog.themes.find((theme) => theme.rightsProfile === "fan-art"); + const provenance = JSON.parse(await readFile( + path.join(ROOT, "themes", "source-art", original.id + ".provenance.json"), + "utf8", + )); + const review = resolveSourceArtReview(original, provenance, parsed); + assert.equal(review.decision, "approved"); + assert.equal(review.sourceSha256, provenance.sourceSha256); + assert.equal(resolveSourceArtReview(fanArt, {}, parsed), null); + assert.throws( + () => resolveSourceArtReview(original, { ...provenance, sourceSha256: "0".repeat(64) }, parsed), + /approval source hash does not match provenance/, + ); +}); + +test("release metadata is the synchronized source for desktop version and capability disclosure", async function () { + const metadata = readReleaseMetadata(ROOT); + const [rootPackage, managerPackage, tauriConfig, catalog, capture, smoke, skinRuntime, readmeText, chineseReadmeText] = await Promise.all([ + readFile(path.join(ROOT, "package.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "apps", "theme-manager", "package.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "tauri.conf.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "themes", "catalog.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "scripts", "capture-full-skin-screenshots.mjs"), "utf8"), + readFile(path.join(ROOT, "scripts", "smoke-theme-manager.mjs"), "utf8"), + readFile(path.join(ROOT, "apps", "theme-manager", "src-tauri", "src", "skin_runtime.rs"), "utf8"), + readFile(path.join(ROOT, "README.md"), "utf8"), + readFile(path.join(ROOT, "README.zh-CN.md"), "utf8"), + ]); + assert.equal(rootPackage.version, metadata.desktop.version); + assert.equal(managerPackage.version, metadata.desktop.version); + assert.equal(tauriConfig.version, metadata.desktop.version); + assert.deepEqual(tauriConfig.plugins.updater.endpoints, [metadata.desktop.updater.endpoint]); + assert.doesNotMatch(tauriConfig.plugins.updater.endpoints[0], /releases\/latest/); + assert.equal(metadata.desktop.githubPrerelease, true); + assert.equal(metadata.capabilities.length, 3); + assert.deepEqual(catalog.defaults, { + author: "Awesome Codex Theme", + license: "CC0-1.0", + codexDesktopTested: metadata.runtime.nativeTestedVersion, + }); + assert.deepEqual(releaseForRegistry(metadata).capture, { + channel: metadata.capture.channel, + appVersion: metadata.capture.appVersion, + packageFullName: metadata.capture.packageFullName, + fixture: metadata.capture.fixture, + modelLabel: metadata.capture.modelLabel, + }); + assert.match(capture, /readReleaseMetadata/); + assert.match(smoke, /readReleaseMetadata/); + assert.doesNotMatch(capture, /const CAPTURE_VERSION = "\d+\.\d+\.\d+"/); + assert.doesNotMatch(smoke, /const BETA_PACKAGE = "OpenAI\.CodexBeta_/); + assert.match(skinRuntime, new RegExp(metadata.runtime.implementationVersion)); + for (const currentReadme of [readmeText, chineseReadmeText]) { + assert.match(currentReadme, //); + assert.match(currentReadme, //); + } + await assert.doesNotReject(syncReleaseMetadata(["--check"])); +}); + +test("Gallery and compatibility docs consume generated metadata instead of stale fallback claims", async function () { + const [index, gallery, adapters, build] = await Promise.all([ + readFile(path.join(ROOT, "site", "index.html"), "utf8"), + readFile(path.join(ROOT, "site", "assets", "app.js"), "utf8"), + readFile(path.join(ROOT, "docs", "adapters.md"), "utf8"), + readFile(path.join(ROOT, "scripts", "build.mjs"), "utf8"), + ]); + assert.match(index, /id="themeStatValue">—/); + assert.match(index, /id="resultSummary">—/); + assert.match(index, /id="capabilityMatrix"/); + assert.match(gallery, /renderCapabilityMatrix/); + assert.match(gallery, /state\.registry\?\.themes\?\.length/); + assert.doesNotMatch(gallery, /all 106 theme modes/); + assert.match(adapters, /Blob URL/); + assert.match(adapters, /--act-art-image/); + assert.doesNotMatch(adapters, /assigns that verified image to a manager-created/); + assert.match(build, /registry\.release\?\.desktop\?\.version/); +}); + +test("generated-artifact guard describes the missing prerequisite", async function () { + const temporary = await mkdtemp(path.join(os.tmpdir(), "awesome-codex-theme-source-test-")); + + try { + await assert.rejects( + assertGeneratedArtifacts(temporary), + /Generated theme artifacts are unavailable: themes\/registry\.json\. Run `npm run generate`/, + ); + + await mkdir(path.join(temporary, "themes")); + await writeFile(path.join(temporary, "themes", "registry.json"), "{}", "utf8"); + await assert.doesNotReject(assertGeneratedArtifacts(temporary)); + } finally { + const temporaryRoot = path.resolve(os.tmpdir()); + const resolved = path.resolve(temporary); + if (resolved.startsWith(temporaryRoot + path.sep)) { + await rm(resolved, { recursive: true, force: true }); + } + } +}); + +test("source-only guards reject unsafe paths and undeclared Native fields", function () { + assert.equal(isSafeRelativePath("themes/qinglan/manifest.json"), true); + assert.equal(isSafeRelativePath("../registry.json"), false); + assert.equal(isSafeRelativePath("themes/../../outside"), false); + assert.equal(isSafeRelativePath("C:\\outside\\theme.json"), false); + assert.equal(isSafeRelativePath("/outside/theme.json"), false); + assert.ok(contrastRatio("#111111", "#FFFFFF") > 15); + assert.ok(contrastRatio("#777777", "#888888") < 4.5); + + const payload = { + codeThemeId: "codex", + theme: { + accent: "#246A4B", + contrast: 45, + fonts: { code: null, ui: null }, + ink: "#15231A", + opaqueWindows: true, + semanticColors: { + diffAdded: "#00A240", + diffRemoved: "#BA2623", + skill: "#246A4B", + }, + surface: "#EDF3EE", + script: "not allowed", + }, + variant: "light", + }; + + assert.throws( + () => parseCodexNativeTheme(CODEX_THEME_PREFIX + JSON.stringify(payload)), + /fields are invalid/, + ); +}); + +test("Full Skin semantic-state fixture preserves native semantic colors outside structural surfaces", async function () { + const [css, fixture] = await Promise.all([ + readFile(path.join(ROOT, "packages", "full-skin", "runtime.css"), "utf8"), + readFile(path.join(ROOT, "tests", "fixtures", "full-skin-semantic-states.html"), "utf8"), + ]); + + for (const state of [ + "link", + "error", + "warning", + "success", + "disabled", + "focus", + "selection", + "diff-added", + "diff-removed", + ]) { + assert.match(fixture, new RegExp(`data-act-semantic-state="${state}"`)); + } + + assert.match(css, /:focus-visible/); + assert.match(css, /::selection/); + assert.match(css, /:disabled/); + assert.match(css, /@media \(forced-colors: active\)/); + assert.doesNotMatch(css, /\.composer-surface-chrome \*/); + assert.doesNotMatch(css, /\[class\*="text-token-foreground"\]/); + assert.doesNotMatch(css, /\[class\*="text-"\]/); + assert.doesNotMatch(css, /\.composer-surface-chrome svg/); +}); + +test("Full Skin long-session benchmark records a bounded unrelated-output path", async function () { + const result = await runFullSkinRuntimeBenchmark({ iterations: 2000 }); + + assertRuntimeBudget(result); + assert.equal(result.observerCallbacks, 2000); + assert.equal(result.ignoredMutationCallbacks, 2000); + assert.equal(result.relevantMutationCallbacks, 0); + assert.equal(result.ensureCalls, 0); + assert.ok(result.ensureDurationMs >= 0); + assert.ok(result.cpuMs >= 0); + assert.ok(Number.isFinite(result.heapGrowthBytes)); + assert.ok(result.eventLoopDelayMs >= 0); +}); + +test("public and desktop capture labels derive current verification from runtime evidence", async function () { + const [gallery, manager, managerHtml, validator] = await Promise.all([ + readFile(path.join(ROOT, "site", "assets", "app.js"), "utf8"), + readFile(path.join(ROOT, "apps", "theme-manager", "src", "renderer", "app.js"), "utf8"), + readFile(path.join(ROOT, "apps", "theme-manager", "src", "renderer", "index.html"), "utf8"), + readFile(path.join(ROOT, "scripts", "validate.mjs"), "utf8"), + ]); + + assert.match(gallery, /function captureEvidenceIsVerified/); + assert.match(gallery, /capabilityRuntimePending/); + assert.match(gallery, /referenceVisualKind/); + assert.match(manager, /function captureEvidenceIsVerified/); + assert.match(manager, /referenceCaptureProof/); + assert.match(managerHtml, /id="captureBadgeLabel"/); + assert.match(managerHtml, /id="captureProofLabel"/); + assert.doesNotMatch(managerHtml, /data-i18n="verifiedCapture"/); + assert.match(validator, /source-tested-recapture-required/); + assert.match(validator, /Registry runtime evidence does not match/); +}); + +test("capture presentation paths and integrity fingerprints are deterministic", function () { + const capture = { + path: "screenshots/codex-beta-26.715.3651.0/fixture-dark.png", + sha256: "a".repeat(64), + }; + assert.deepEqual(CAPTURE_PRESENTATION_SIZES, [ + { width: 320, height: 180 }, + { width: 480, height: 270 }, + { width: 960, height: 540 }, + ]); + assert.equal( + presentationPathFor(capture.path, 480), + "screenshots/codex-beta-26.715.3651.0/presentation/fixture-dark-480.png", + ); + assert.notEqual( + presentationFingerprint(capture, CAPTURE_PRESENTATION_SIZES[0]), + presentationFingerprint(capture, CAPTURE_PRESENTATION_SIZES[1]), + ); + assert.equal(GALLERY_MEDIA_BUDGET.maxCardBytes, 768 * 1024); + assert.equal(GALLERY_MEDIA_BUDGET.maxInitialBytes, 3 * 1024 * 1024); +}); + +test("capture presentation generation detects a missing cached variant", async function () { + const temporary = await mkdtemp(path.join(os.tmpdir(), "awesome-codex-theme-presentation-")); + const capturePath = "screenshots/codex-beta-fixture/fixture-light.png"; + const source = encodePng(16, 9, function (x, y) { + return [x * 16, y * 28, 120, 255]; + }); + const capture = { + themeId: "fixture", + mode: "light", + path: capturePath, + sha256: createHash("sha256").update(source).digest("hex"), + bytes: source.length, + width: 16, + height: 9, + }; + + try { + const sourcePath = path.join(temporary, ...capturePath.split("/")); + await mkdir(path.dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, source); + const captureContext = { manifest: { captures: [capture] } }; + const generated = await generateCapturePresentations({ root: temporary, captureContext }); + assert.equal(generated.cacheMisses, 3); + const variants = generated.index.get("fixture|light"); + assert.equal(variants.length, 3); + + const missingPath = variants[1].path; + await rm(path.join(temporary, ...missingPath.split("/"))); + const checked = await generateCapturePresentations({ root: temporary, captureContext, check: true }); + assert.deepEqual(checked.drift, [missingPath]); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +}); + +test("package scripts keep source checks fast and generated checks explicit", async function () { + const [packageJson, desktopPackageJson] = await Promise.all([ + readFile(path.join(ROOT, "package.json"), "utf8").then(JSON.parse), + readFile(path.join(ROOT, "apps", "theme-manager", "package.json"), "utf8").then(JSON.parse), + ]); + + assert.equal(packageJson.scripts.test, "npm run test:source"); + assert.match(packageJson.scripts["test:source"], /source-contract\.test\.mjs/); + assert.match(packageJson.scripts["test:generated"], /ensure-generated\.mjs/); + assert.equal(packageJson.scripts["runtime:benchmark"], "node scripts/benchmark-full-skin-runtime.mjs"); + assert.match(packageJson.scripts["test:ci"], /^npm run generate/); + assert.equal(packageJson.scripts["metadata:check"], "node scripts/sync-release-metadata.mjs --check"); + assert.equal(packageJson.scripts["gallery:check"], "node scripts/verify-gallery.mjs"); + assert.equal(packageJson.scripts["art:review"], "node scripts/review-source-art.mjs --check"); + assert.match(packageJson.scripts["test:ci"], /npm run art:review/); + assert.match(packageJson.scripts["check:full"], /npm run test:ci/); + assert.match(desktopPackageJson.scripts.check, /^node \.\.\/\.\.\/scripts\/prepare-desktop\.mjs && cargo fmt/); +}); + +test("gallery check accepts both standard screenshot argument forms", function () { + assert.equal( + parseGalleryCheckOptions(["--screenshot=gallery-home.png"]).screenshot, + "gallery-home.png", + ); + assert.equal( + parseGalleryCheckOptions(["--screenshot", "gallery-home.png"]).screenshot, + "gallery-home.png", + ); + assert.throws( + () => parseGalleryCheckOptions(["--screenshot"]), + /--screenshot requires a relative output path/, + ); +}); + +test("gallery browser check ignores Chromium's undeclared favicon fallback", function () { + const siteUrl = "http://127.0.0.1:4173/"; + assert.equal( + isGalleryRequestFailure({ + method: "Network.responseReceived", + params: { + type: "Other", + response: { status: 404, url: siteUrl + "favicon.ico" }, + }, + }, siteUrl), + false, + ); + assert.equal( + isGalleryRequestFailure({ + method: "Network.responseReceived", + params: { + type: "Fetch", + response: { status: 404, url: siteUrl + "themes/registry.json" }, + }, + }, siteUrl), + true, + ); +}); + +test("gallery browser check retries isolated profile cleanup after Chromium exits", async function () { + const galleryCheck = await readFile(path.join(ROOT, "scripts", "verify-gallery.mjs"), "utf8"); + assert.match(galleryCheck, /maxRetries:\s*PROFILE_REMOVE_RETRIES/); + assert.match(galleryCheck, /retryDelay:\s*PROFILE_REMOVE_RETRY_DELAY_MS/); + assert.match(galleryCheck, /child\.signalCode !== null/); + assert.match(galleryCheck, /text:\s*"\\r"/); + assert.match(galleryCheck, /unmodifiedText:\s*"\\r"/); +}); + +test("Theme Manager uses native dialog semantics and simple button selectors", async function () { + const [html, app, styles, packageJson] = await Promise.all([ + readFile(path.join(ROOT, "apps", "theme-manager", "src", "renderer", "index.html"), "utf8"), + readFile(path.join(ROOT, "apps", "theme-manager", "src", "renderer", "app.js"), "utf8"), + readFile(path.join(ROOT, "apps", "theme-manager", "src", "renderer", "styles.css"), "utf8"), + readFile(path.join(ROOT, "apps", "theme-manager", "package.json"), "utf8").then(JSON.parse), + ]); + + assert.match(html, /]*role="dialog"|role="dialog"[^>]*id="persistenceConsentDialog"/, + ); + assert.match(app, /persistenceConsentDialog\.showModal\(\)/); + assert.match(app, /persistenceConsentDialog\.addEventListener\("cancel"/); + assert.match(app, /setFallbackBackgroundInert/); + assert.match(app, /trapFallbackConsentFocus/); + assert.doesNotMatch(app, /button\.role\s*=\s*"tab"|button\.role\s*=\s*"option"/); + assert.match(app, /button\.setAttribute\("aria-pressed"/); + assert.doesNotMatch(app, /key\s*===\s*"c"/); + assert.match(styles, /dialog\.consent-dialog::backdrop/); + assert.match(styles, /\.consent-fallback-backdrop/); + assert.match(styles, /@media \(forced-colors: active\)/); + assert.match(styles, /\.action-card > p \{[^}]*font-size: 11px/s); + assert.match(styles, /\.persistence-copy small \{[^}]*font-size: 11px/s); + assert.equal(packageJson.scripts["a11y:check"], "node scripts/verify-a11y.mjs"); + assert.equal(packageJson.devDependencies["axe-core"], "4.12.1"); +}); + +test("Theme Manager global action shortcut leaves selection and controls alone", function () { + const neutralTarget = { closest: () => null }; + const editableTarget = { closest: () => ({}) }; + const contentEditableTarget = { isContentEditable: true, closest: () => null }; + const selectedText = { isCollapsed: false, toString: () => "selected diagnostic" }; + const selectedWhitespace = { isCollapsed: false, toString: () => " " }; + const emptySelection = { isCollapsed: true, toString: () => "" }; + + assert.equal(canUseGlobalActionShortcut({ dialogOpen: false, selection: emptySelection, target: neutralTarget }), true); + assert.equal(canUseGlobalActionShortcut({ dialogOpen: true, selection: emptySelection, target: neutralTarget }), false); + assert.equal(canUseGlobalActionShortcut({ dialogOpen: false, selection: selectedText, target: neutralTarget }), false); + assert.equal(canUseGlobalActionShortcut({ dialogOpen: false, selection: selectedWhitespace, target: neutralTarget }), false); + assert.equal(canUseGlobalActionShortcut({ dialogOpen: false, selection: emptySelection, target: editableTarget }), false); + assert.equal(canUseGlobalActionShortcut({ dialogOpen: false, selection: emptySelection, target: contentEditableTarget }), false); +}); + +test("CI reuses canonical generated assets with pinned, least-privilege workflows", async function () { + const [desktopWorkflow, supplyChainWorkflow, dependabot] = await Promise.all([ + readFile(path.join(ROOT, ".github", "workflows", "desktop.yml"), "utf8"), + readFile(path.join(ROOT, ".github", "workflows", "supply-chain.yml"), "utf8"), + readFile(path.join(ROOT, ".github", "dependabot.yml"), "utf8"), + ]); + + assert.match(desktopWorkflow, /run: npm run test:ci/); + assert.match(desktopWorkflow, /name: act-generated-theme-assets/); + assert.match(desktopWorkflow, /uses: actions\/download-artifact@[0-9a-f]{40}/); + assert.match(desktopWorkflow, /run: npm run gallery:check/); + assert.match(desktopWorkflow, /a11y:\n\s+name: Verify Theme Manager accessibility/); + assert.match(desktopWorkflow, /run: npm run a11y:check --prefix apps\/theme-manager/); + assert.match(desktopWorkflow, /ACT_CHROME_BIN: google-chrome/); + assert.match(desktopWorkflow, /cancel-in-progress: true/); + assert.match(desktopWorkflow, /- "screenshots\/\*\*"/); + assert.match(desktopWorkflow, /- "LICENSE"/); + assert.match(desktopWorkflow, /- "NOTICE\.md"/); + const desktopJob = desktopWorkflow.slice( + desktopWorkflow.indexOf("\n desktop:"), + desktopWorkflow.indexOf("\n pages:"), + ); + const releaseJob = desktopWorkflow.slice(desktopWorkflow.indexOf("\n release:")); + assert.match(releaseJob, /permissions:\n\s+contents: write/); + assert.doesNotMatch(desktopJob, /contents: write/); + assert.doesNotMatch(desktopWorkflow, /cache: npm/); + assert.match(desktopWorkflow, /package-manager-cache: false/); + assert.doesNotMatch(desktopWorkflow, /swatinem\/rust-cache/); + assert.match(desktopWorkflow, /uses: actions\/checkout@[0-9a-f]{40} # v7/); + assert.match(supplyChainWorkflow, /reviewdog\/action-actionlint@[0-9a-f]{40}/); + assert.match(supplyChainWorkflow, /zizmorcore\/zizmor-action@[0-9a-f]{40}/); + assert.match(dependabot, /package-ecosystem: github-actions/); + assert.match(dependabot, /package-ecosystem: cargo/); + for (const workflow of [desktopWorkflow, supplyChainWorkflow]) { + for (const action of workflow.matchAll(/^\s*uses:\s+[^@\s]+@([^\s#]+)/gm)) { + assert.match(action[1], /^[0-9a-f]{40}$/); + } + } +}); + +test("incremental generator options stay bounded and explicit", function () { + assert.deepEqual( + parseGenerationOptions([ + "--ids=qinglan-odyssey,jiangnan-rain-courtyard", + "--concurrency=1", + "--force", + "--json-progress", + "--summary", + ]), + { + check: false, + concurrency: 1, + force: true, + help: false, + ids: ["qinglan-odyssey", "jiangnan-rain-courtyard"], + jsonProgress: true, + summary: true, + }, + ); + assert.throws(() => parseGenerationOptions(["--concurrency=5"]), /from 1 to 4/); + assert.throws(() => parseGenerationOptions(["--concurrency=1.5"]), /from 1 to 4/); + assert.throws(() => parseGenerationOptions(["--concurrency=1junk"]), /from 1 to 4/); + assert.throws(() => parseGenerationOptions(["--ids="]), /at least one theme id/); +}); + +test("render fingerprints invalidate every declared visual input", function () { + const theme = { + art: { focusX: 0.6, focusY: 0.4, safeArea: "left", taskMode: "ambient" }, + light: { + tokens: { + background: "#123456", + surface: "#234567", + surfaceAlt: "#345678", + text: "#FFFFFF", + muted: "#CCCCCC", + accent: "#AA5500", + accentContrast: "#FFFFFF", + border: "#778899", + }, + }, + }; + const provenance = { sourceSha256: "a".repeat(64) }; + const baseline = renderFingerprint(theme, "light", provenance, 2560, 1440); + const mutations = [ + (candidate) => { candidate.art.focusX = 0.2; }, + (candidate) => { candidate.art.focusY = 0.8; }, + (candidate) => { candidate.art.safeArea = "right"; }, + (candidate) => { candidate.art.taskMode = "banner"; }, + (candidate) => { candidate.light.tokens.accent = "#0055AA"; }, + ]; + + for (const mutate of mutations) { + const candidate = structuredClone(theme); + mutate(candidate); + assert.notEqual(renderFingerprint(candidate, "light", provenance, 2560, 1440), baseline); + } + assert.notEqual(renderFingerprint(theme, "light", provenance, 960, 540), baseline); + assert.ok(Object.keys(SOURCE_ART_RENDERER_CONSTANTS.grading).length > 8); +}); + +test("focal cover crops preserve the focal coordinate while clamping to image edges", function () { + assert.deepEqual( + computeCoverCrop({ + sourceWidth: 400, + sourceHeight: 100, + targetWidth: 100, + targetHeight: 100, + focusX: 0, + focusY: 0.5, + }), + { cropX: 0, cropY: 0, cropWidth: 100, cropHeight: 100, focusX: 0, focusY: 0.5 }, + ); + assert.deepEqual( + computeCoverCrop({ + sourceWidth: 400, + sourceHeight: 100, + targetWidth: 100, + targetHeight: 100, + focusX: 1, + focusY: 0.5, + }), + { cropX: 300, cropY: 0, cropWidth: 100, cropHeight: 100, focusX: 1, focusY: 0.5 }, + ); + assert.deepEqual( + computeCoverCrop({ + sourceWidth: 100, + sourceHeight: 400, + targetWidth: 100, + targetHeight: 100, + focusX: -2, + focusY: 2, + }), + { cropX: 0, cropY: 300, cropWidth: 100, cropHeight: 100, focusX: 0, focusY: 1 }, + ); + assert.throws( + () => computeCoverCrop({ sourceWidth: 0, sourceHeight: 1, targetWidth: 1, targetHeight: 1 }), + /positive numbers/, + ); +}); + +test("directional safe-area grading has distinct left, right, center, and none profiles", function () { + assert.ok(safeAreaWeight(0.05, "left") > safeAreaWeight(0.95, "left")); + assert.ok(safeAreaWeight(0.95, "right") > safeAreaWeight(0.05, "right")); + assert.ok(safeAreaWeight(0.5, "center") > safeAreaWeight(0.05, "center")); + assert.equal(safeAreaWeight(0.5, "none"), 0); + assert.throws(() => safeAreaWeight(0.5, "diagonal"), /Unsupported safeArea/); +}); + +test("focal rendering changes pixels on each crop axis without changing safe-area semantics", function () { + const pixels = encodePng(40, 40, (x, y) => [x * 6, y * 6, 120, 255]); + const image = decodePng(pixels); + const baseTheme = { + art: { focusX: 0.5, focusY: 0.5, safeArea: "none" }, + light: { tokens: { background: "#FFFFFF" } }, + dark: { tokens: { background: "#101010" } }, + }; + const wide = { ...baseTheme, art: { ...baseTheme.art, focusX: 0.1 } }; + const wideOther = { ...baseTheme, art: { ...baseTheme.art, focusX: 0.9 } }; + const tall = { ...baseTheme, art: { ...baseTheme.art, focusY: 0.1 } }; + const tallOther = { ...baseTheme, art: { ...baseTheme.art, focusY: 0.9 } }; + + assert.notDeepEqual( + renderSourceArtImage(image, wide, "light", 20, 40), + renderSourceArtImage(image, wideOther, "light", 20, 40), + ); + assert.notDeepEqual( + renderSourceArtImage(image, tall, "light", 40, 20), + renderSourceArtImage(image, tallOther, "light", 40, 20), + ); +}); + +test("source-art policy accepts canonical sources and only named legacy exceptions", function () { + const policy = { + minimum: { width: 2560, height: 1440, aspectRatio: "16:9" }, + reviewedUpscaleExceptions: [{ + id: "legacy-review", + reviewedAt: "2026-07-26", + sourceSize: "1536x1024", + themeIds: ["legacy-theme"], + }], + }; + assert.deepEqual(parseImageSize("2560x1440"), { width: 2560, height: 1440 }); + assert.equal(assessSourceArt(policy, "new-theme", { width: 2560, height: 1440 }).kind, "canonical"); + assert.equal( + assessSourceArt(policy, "legacy-theme", { width: 1536, height: 1024 }).kind, + "reviewed-upscale-exception", + ); + assert.equal(assessSourceArt(policy, "new-theme", { width: 1536, height: 1024 }).accepted, false); + assert.throws(() => parseImageSize("2560:1440"), /WIDTHxHEIGHT/); +}); + +test("generator rejects source art whose bytes no longer match provenance", async function () { + const temporary = await mkdtemp(path.join(os.tmpdir(), "awesome-codex-theme-source-provenance-")); + const sourceDirectory = path.join(temporary, "themes", "source-art"); + const source = Buffer.from("verified-source-art", "utf8"); + const sourceSha256 = createHash("sha256").update(source).digest("hex"); + + try { + await mkdir(sourceDirectory, { recursive: true }); + await writeFile(path.join(sourceDirectory, "fixture.png"), source); + await writeFile( + path.join(sourceDirectory, "fixture.provenance.json"), + JSON.stringify({ sourceSha256 }) + "\n", + "utf8", + ); + await assert.doesNotReject(readVerifiedSourceProvenance(temporary, "fixture")); + + await writeFile(path.join(sourceDirectory, "fixture.png"), Buffer.from("unexpected-source-art", "utf8")); + await assert.rejects( + readVerifiedSourceProvenance(temporary, "fixture"), + /Source-art hash does not match provenance/, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +}); + +test("targeted generation refuses a mixed legacy Registry migration", async function () { + const temporary = await mkdtemp(path.join(os.tmpdir(), "awesome-codex-theme-partial-migration-")); + const themesDirectory = path.join(temporary, "themes"); + + try { + await mkdir(themesDirectory, { recursive: true }); + await writeFile( + path.join(themesDirectory, "catalog.json"), + JSON.stringify({ collections: [], themes: [{ id: "fixture" }] }) + "\n", + "utf8", + ); + await writeFile( + path.join(themesDirectory, "registry.json"), + JSON.stringify({ generatedBy: "act-theme-generator-v4.0", themes: [{ id: "fixture" }] }) + "\n", + "utf8", + ); + await assert.rejects( + generateRepository({ root: temporary, options: { ids: ["fixture"] } }), + /Partial generation requires a current v5 Registry/, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +}); diff --git a/themes/catalog.json b/themes/catalog.json index 559ee94..d66c3b1 100644 --- a/themes/catalog.json +++ b/themes/catalog.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "catalogRevision": 2026072201, + "catalogRevision": 2026072701, "defaults": { "author": "Awesome Codex Theme", "license": "CC0-1.0", @@ -1484,7 +1484,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#DFE5DF", "text": "#24302D", - "muted": "#5E6B67", + "muted": "#5B6764", "accent": "#8C3E35", "accentContrast": "#FFFFFF", "border": "#B8C2BC" @@ -1559,7 +1559,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#D9E6EB", "text": "#19313B", - "muted": "#586C75", + "muted": "#556871", "accent": "#285E7A", "accentContrast": "#FFFFFF", "border": "#ACC1CA" @@ -1634,7 +1634,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#DEE7E7", "text": "#243238", - "muted": "#5D6B70", + "muted": "#5B686D", "accent": "#8D3D2C", "accentContrast": "#FFFFFF", "border": "#B5C2C2" @@ -1709,7 +1709,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#D8E7DE", "text": "#1D322A", - "muted": "#586C64", + "muted": "#566962", "accent": "#2F6D5E", "accentContrast": "#FFFFFF", "border": "#AFC5BA" @@ -1934,7 +1934,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#DDE3E0", "text": "#27302E", - "muted": "#616C69", + "muted": "#5C6664", "accent": "#3B6763", "accentContrast": "#FFFFFF", "border": "#B5C0BC" @@ -2009,7 +2009,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#EADBC5", "text": "#3A2A20", - "muted": "#756354", + "muted": "#6F5E50", "accent": "#8E4D26", "accentContrast": "#FFFFFF", "border": "#C9B69D" @@ -2084,7 +2084,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#D9E7EA", "text": "#1D3037", - "muted": "#596D74", + "muted": "#55696F", "accent": "#8D2F50", "accentContrast": "#FFFFFF", "border": "#ADC3CA" @@ -2159,7 +2159,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#D7E9DE", "text": "#1A3429", - "muted": "#566F63", + "muted": "#536B60", "accent": "#28705E", "accentContrast": "#FFFFFF", "border": "#A9C8B8" @@ -2234,7 +2234,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#D8E9ED", "text": "#19343D", - "muted": "#57717A", + "muted": "#526A73", "accent": "#2F6786", "accentContrast": "#FFFFFF", "border": "#AAC6CE" @@ -3309,7 +3309,7 @@ "surface": "#FFF9F0", "surfaceAlt": "#E9CDA7", "text": "#342116", - "muted": "#735744", + "muted": "#6F5442", "accent": "#8A3524", "accentContrast": "#FFFFFF", "border": "#C39972" @@ -3485,7 +3485,7 @@ "surface": "#FAFCFC", "surfaceAlt": "#D4DFE1", "text": "#17272B", - "muted": "#52666B", + "muted": "#516469", "accent": "#2C6170", "accentContrast": "#FFFFFF", "border": "#91ABB1" @@ -3599,7 +3599,7 @@ "surface": "#FFF9FE", "surfaceAlt": "#E4D1E2", "text": "#35203A", - "muted": "#735A77", + "muted": "#6D5571", "accent": "#8B2F72", "accentContrast": "#FFFFFF", "border": "#BB91B5" @@ -3713,7 +3713,7 @@ "surface": "#FCFEFC", "surfaceAlt": "#DCE4E0", "text": "#1C2B27", - "muted": "#586A64", + "muted": "#576862", "accent": "#35675B", "accentContrast": "#FFFFFF", "border": "#9FB4AD" @@ -3770,7 +3770,7 @@ "surface": "#FFF9EF", "surfaceAlt": "#E9D0AD", "text": "#392319", - "muted": "#755746", + "muted": "#725444", "accent": "#913C28", "accentContrast": "#FFFFFF", "border": "#C39872" @@ -3827,7 +3827,7 @@ "surface": "#FFF9EC", "surfaceAlt": "#DFC9A5", "text": "#35271B", - "muted": "#716047", + "muted": "#62543E", "accent": "#405E79", "accentContrast": "#FFFFFF", "border": "#B49A75" @@ -3884,7 +3884,7 @@ "surface": "#FBFDF7", "surfaceAlt": "#D8E2D1", "text": "#1D2A1A", - "muted": "#596854", + "muted": "#586653", "accent": "#42663B", "accentContrast": "#FFFFFF", "border": "#9EAF97" @@ -3941,7 +3941,7 @@ "surface": "#FAFCFD", "surfaceAlt": "#D5E0E4", "text": "#192A31", - "muted": "#536872", + "muted": "#50656E", "accent": "#3B6257", "accentContrast": "#FFFFFF", "border": "#98ADB5" @@ -3999,7 +3999,7 @@ "surface": "#FFFCF7", "surfaceAlt": "#E5DBC9", "text": "#30271B", - "muted": "#6D604F", + "muted": "#6B5E4E", "accent": "#79572B", "accentContrast": "#FFFFFF", "border": "#BAA786" @@ -4056,7 +4056,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#E9DDCF", "text": "#32251B", - "muted": "#755F4A", + "muted": "#745E4A", "accent": "#A45D32", "accentContrast": "#FFFFFF", "border": "#C4A68E" @@ -4172,7 +4172,7 @@ "surface": "#FFF9F2", "surfaceAlt": "#EAD8BF", "text": "#35291E", - "muted": "#705D49", + "muted": "#6F5C49", "accent": "#9C552B", "accentContrast": "#FFFFFF", "border": "#C79D74" @@ -4229,7 +4229,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#DEE5F0", "text": "#1C2839", - "muted": "#576B83", + "muted": "#54677F", "accent": "#49668F", "accentContrast": "#FFFFFF", "border": "#A1B2C9" @@ -4287,7 +4287,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#D7E4EA", "text": "#1A2F3B", - "muted": "#536A77", + "muted": "#516774", "accent": "#276F8E", "accentContrast": "#FFFFFF", "border": "#92B3C1" @@ -4759,7 +4759,7 @@ "surface": "#FFFFFF", "surfaceAlt": "#DDE2E7", "text": "#202833", - "muted": "#596976", + "muted": "#566672", "accent": "#97603A", "accentContrast": "#FFFFFF", "border": "#B3B9C0" diff --git a/themes/source-art/jobs.json b/themes/source-art/jobs.json index 761e64f..c384e1e 100644 --- a/themes/source-art/jobs.json +++ b/themes/source-art/jobs.json @@ -8,6 +8,92 @@ "responseFormat": "b64_json", "outputFormat": "png" }, + "sourceArtPolicy": { + "minimum": { + "width": 2560, + "height": 1440, + "aspectRatio": "16:9" + }, + "newThemePrompt": "Deliver an exactly 16:9 production source image at 2560x1440 or larger. Do not rely on a later crop or upscale to preserve the declared focal subject and safe area.", + "reviewedUpscaleExceptions": [ + { + "id": "act-legacy-1536x1024-review-2026-07-26", + "reviewedAt": "2026-07-26", + "sourceSize": "1536x1024", + "reason": "The pre-policy catalog was visually reviewed with focal-aware 16:9 output. These existing sources may remain as explicit reviewed legacy upscale inputs; new themes may not use this exception.", + "themeIds": [ + "qinglan-odyssey", + "qinglan-sprite", + "starbound-rebel", + "starbound-sprite", + "mountain-sword", + "mountain-swordling", + "cloudsea-pact", + "cloudsea-mochi", + "beijing-meridian", + "shanghai-afterglow", + "shenzhen-circuit-rain", + "guangzhou-riverwind", + "chengdu-teahouse-rain", + "hangzhou-lake-letter", + "chongqing-vertical-night", + "nanjing-wall-plum", + "new-york-rainline", + "san-francisco-fogline", + "chicago-lakewind", + "toronto-blue-hour", + "vancouver-rain-harbour", + "london-rainlight", + "paris-rooftop-dawn", + "berlin-night-grid", + "rome-terrace-gold", + "tokyo-rain-signal", + "singapore-garden-rain", + "sydney-harbour-morning", + "mortal-journey-duo", + "mortal-journey-chibi", + "renegade-immortal-duo", + "renegade-immortal-chibi", + "sword-of-coming-duo", + "sword-of-coming-chibi", + "battle-through-duo", + "battle-through-chibi", + "mortal-void-hall", + "renegade-wedding-storm", + "sword-confession-alley", + "battle-three-year-pact", + "tibo-reset-saint", + "tibo-reset-immortal", + "retro-boss-2007", + "rainy-library", + "orbital-dawn", + "deep-ocean-lab", + "synthwave-night-drive", + "alpine-cabin-code", + "jiangnan-rain-courtyard", + "changan-lanterns", + "dunhuang-starlight", + "bamboo-tea-rain", + "northeast-snow-train", + "appalachian-cabin-focus", + "cloud-forge-quiet", + "coastal-studio-morning", + "desert-observatory-night", + "firefly-river-pact", + "great-lakes-rainroom", + "jade-archive-dawn", + "late-diner-afterhours", + "mortal-bamboo-thunder", + "mortal-herb-lantern", + "mortal-moon-market", + "mortal-river-ferry", + "mortal-talisman-rain", + "plum-sword-snow", + "sky-garden-herbarium" + ] + } + ] + }, "commonPrompt": "Create original premium key art for a desktop coding workspace theme. Use contemporary Chinese animation background painting, refined ink-wash atmosphere, mineral-pigment color, cinematic depth, and precise environmental detail. The result must be a new composition, not an imitation of any named artist, studio, series, game, film, character, or franchise. Keep the entire left 46 percent calm and low contrast for readable interface controls. Put the main visual story in the right third. Keep important content inside the middle 82 percent vertically because the source will be cropped to 16:9. No text, letters, numbers, logo, watermark, signature, interface, border, frame, mockup, diptych, or split panel. No photorealism. Landscape 3:2.", "cityPrompt": "Create original premium key art for a desktop coding workspace theme. Use contemporary Chinese animation background painting, refined atmospheric perspective, cinematic depth, and precise environmental detail. The result must be a new composition, not an imitation of any named artist, studio, series, game, film, character, franchise, photograph, poster, or tourist campaign. For a city brief, preserve the real city's geography, urban morphology, climate, vegetation, and recognisable public skyline or landscape cues; stylise them into a new viewpoint instead of inventing fantasy architecture or copying a known photograph. Keep the entire left 46 percent calm and low contrast for readable interface controls. Put the main visual story in the right third. Keep important content inside the middle 82 percent vertically because the source will be cropped to 16:9. No text, letters, numbers, logo, watermark, signature, interface, border, frame, mockup, diptych, or split panel. No photorealism. Landscape 3:2.", "fanArtPrompt": "Create premium unofficial fan-art key art for a desktop coding workspace theme. Depict only the declared work and characters in the scene brief, with recognizable canonical identity and signature props, but make a completely new composition rather than copying any official still, poster, key visual, camera angle, or promotional asset. Use polished cinematic Chinese fantasy animation rendering with refined ink atmosphere, mineral-pigment color, dimensional lighting, and believable materials; do not imitate a named artist or studio's signature style. Keep the entire left 46 percent calm, low contrast, and mostly environmental for readable interface controls. Put the main characters or scene action in the right third. Keep faces, bodies, and important action inside the middle 82 percent vertically because the source will be cropped to 16:9. No text, title, letters, numbers, logo, watermark, signature, interface, border, frame, mockup, diptych, split panel, or copied franchise emblem. No photorealism. Landscape 3:2.", diff --git a/themes/source-art/reviews.json b/themes/source-art/reviews.json new file mode 100644 index 0000000..09f91ec --- /dev/null +++ b/themes/source-art/reviews.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "review": { + "reviewer": "Awesome Codex Theme source-art review", + "reviewedAt": "2026-07-25T00:00:00Z", + "checks": { + "noThirdPartyLogo": true, + "noSignatureOrWatermark": true, + "noUnintendedText": true, + "rightsProfileMatches": true, + "safeAreaReviewed": true, + "lightDarkCaptureReviewed": true + }, + "notes": "Structured migration of the existing source-specific provenance review disclosure and the 2026-07-25 audit corpus evidence. This ledger does not create a claim of third-party rights clearance." + }, + "attestations": [ + { "themeId": "qinglan-odyssey", "sourceSha256": "a4aa321572bc6e0f7c0499ef5bee9604bec8ecb0f6118700ee6fb1140f390fad", "decision": "approved" }, + { "themeId": "qinglan-sprite", "sourceSha256": "24914a9023a0ed9fbc0e91609b601ff28b1fb5b8e356cd9e362caa8c8b3dca25", "decision": "approved" }, + { "themeId": "starbound-rebel", "sourceSha256": "dd68bf22d055224eb2c7f6b2acc17cdd7547d44d8480d0907db4deb15481cf6b", "decision": "approved" }, + { "themeId": "starbound-sprite", "sourceSha256": "8b068fbe714747114e894384d38f99ea7e48988c1c2fd0452dcf27e89cd7e661", "decision": "approved" }, + { "themeId": "mountain-sword", "sourceSha256": "8ebeaecf4cf56d5815f5848a0a3d29f8fac47cf2e50cf92ff1159609f92105bc", "decision": "approved" }, + { "themeId": "mountain-swordling", "sourceSha256": "4c1aa94fc4d4c882134a7d8e560b1da454cd6b18066880174d39bdba58c37243", "decision": "approved" }, + { "themeId": "cloudsea-pact", "sourceSha256": "dc7c3fc5de6d8b8b0a4651664f5eb56b9521e8d05c00c7cbc1c07539d9eb42c9", "decision": "approved" }, + { "themeId": "cloudsea-mochi", "sourceSha256": "f5ef9da3beb95c737147f933e3466be16e5708568fa20be1013eb33ca413bac1", "decision": "approved" }, + { "themeId": "beijing-meridian", "sourceSha256": "b8142726fac12d7367e6e3ee4cc99da83ede3bdba954fd158564717131e4f5b4", "decision": "approved" }, + { "themeId": "shanghai-afterglow", "sourceSha256": "fd0d2b8ff411bc53930590c97f102b14b3720f7a5382af0a87db0fc101b7f15b", "decision": "approved" }, + { "themeId": "shenzhen-circuit-rain", "sourceSha256": "fe02957766cbcbae22966e86cd93e9dec90e00ba3af79c00bdc889ff4c3bebbe", "decision": "approved" }, + { "themeId": "guangzhou-riverwind", "sourceSha256": "14eb4ec1e73004c2c838fa7e4fefa8e9495ced908a1168516b71e500c76e2395", "decision": "approved" }, + { "themeId": "chengdu-teahouse-rain", "sourceSha256": "4f28fe473e04ba709faab35061f49b04ff1cfa4abf72ea4380eceb50bb26a484", "decision": "approved" }, + { "themeId": "hangzhou-lake-letter", "sourceSha256": "0ae96df48939d86fe28ef67c236fc08904e13bbc29bd5858c1ffb329a5f4e013", "decision": "approved" }, + { "themeId": "chongqing-vertical-night", "sourceSha256": "e96d38ec1d79a1c59a41396445be0c4e46c634f7aac2e77d40dca3104fe65475", "decision": "approved" }, + { "themeId": "nanjing-wall-plum", "sourceSha256": "813706847e3d95e722b3d31748a1c6b9a0b14481f72fbe496e411f70f89a5517", "decision": "approved" }, + { "themeId": "new-york-rainline", "sourceSha256": "088f2a7d1bb1c174074903a93c5dc8ddef61ecb14ce66470b1267f6aae6ef76b", "decision": "approved" }, + { "themeId": "san-francisco-fogline", "sourceSha256": "a0aa639ab2de1edb04a87b817589c7b5ad6bab74619116063a742dd9e4c88a08", "decision": "approved" }, + { "themeId": "chicago-lakewind", "sourceSha256": "ead77a3bb826d123eb5622021eddaa2f434a1f1bc4e8ad58c3772de732b18853", "decision": "approved" }, + { "themeId": "toronto-blue-hour", "sourceSha256": "430d32e58f85f92c90f60508860bc401e8ae67fd659194b32a79cacf35ad8136", "decision": "approved" }, + { "themeId": "vancouver-rain-harbour", "sourceSha256": "cef1bc1b5a7d5d73827e6668bfea27074e1db3ca3241a998f5b440990630927d", "decision": "approved" }, + { "themeId": "london-rainlight", "sourceSha256": "4b6fec54dd3dcef09977b1b30e5a10d35d4c8b0fb1b1cc7fc50dd4afac03c001", "decision": "approved" }, + { "themeId": "paris-rooftop-dawn", "sourceSha256": "0e88d6e78ebd37089616560861fca879198372063056e1ca9bf337528e36cccb", "decision": "approved" }, + { "themeId": "berlin-night-grid", "sourceSha256": "e2ba597974ae373f18747ed2981471af70d3aba206b2455c2bd74115d8bb993a", "decision": "approved" }, + { "themeId": "rome-terrace-gold", "sourceSha256": "d08bdc0c4afcad2a937caf72a28ae2f4eb06bb1582ecf907f6f618d61f3c1e15", "decision": "approved" }, + { "themeId": "tokyo-rain-signal", "sourceSha256": "05e9dd74fab41b653ba092c16db8fda2db1c139879f3300e4045c0d892030e70", "decision": "approved" }, + { "themeId": "singapore-garden-rain", "sourceSha256": "2b011ccb907494d14c11ca863aa440a32a122eaac650f5f6de0413e9b226616a", "decision": "approved" }, + { "themeId": "sydney-harbour-morning", "sourceSha256": "7088abbefd7b298722cf1f7d619cd8322d4082323a7967a442b840eada2cb9b4", "decision": "approved" }, + { "themeId": "retro-boss-2007", "sourceSha256": "8eb319040e9ea259222f086fc9d6efa2e910e66020c719e809e597bffb007207", "decision": "approved" }, + { "themeId": "rainy-library", "sourceSha256": "8fef3ada2a2a68e5b1e4b15bf286714f0368db0d1f2ced87a683a78c05f5f499", "decision": "approved" }, + { "themeId": "orbital-dawn", "sourceSha256": "24a646821c0b99903c4c3902d0aff750fe2a38414e9ce5785f6d23611338c77e", "decision": "approved" }, + { "themeId": "deep-ocean-lab", "sourceSha256": "04e7e503c417998108213dbca3e77778e08c024e351eab6d26e4f52d0df9f7de", "decision": "approved" }, + { "themeId": "synthwave-night-drive", "sourceSha256": "4bd9a5625708adf0b9b33ecb4bf35fc1ea702f0087e2971aadffdc5b35c42a12", "decision": "approved" }, + { "themeId": "alpine-cabin-code", "sourceSha256": "3059992ca351345e0b2e484d57e077bcc7695a10ab956ec395dce0b650aa391c", "decision": "approved" }, + { "themeId": "jiangnan-rain-courtyard", "sourceSha256": "4f927db5183332d694185075714c6e52c9093e493e38369d5925d59a8ab7a6a1", "decision": "approved" }, + { "themeId": "changan-lanterns", "sourceSha256": "07871b73b171ddccc9dae7cd5e200263ef9f775d198be7684c932ffa3a360900", "decision": "approved" }, + { "themeId": "dunhuang-starlight", "sourceSha256": "62d5e1fdbdd6c5c0c739a7221086294d63a49d3e10b02b0a6c1c99e09ac2ed72", "decision": "approved" }, + { "themeId": "bamboo-tea-rain", "sourceSha256": "11d98c363d0302954874a7f01231075a4036c602dac4919b8873ce95e98017e7", "decision": "approved" }, + { "themeId": "northeast-snow-train", "sourceSha256": "d1b90373be709a954cc0136f4fb66a9f81e1baef2c5f14ea1f0fb877be6241a8", "decision": "approved" }, + { "themeId": "appalachian-cabin-focus", "sourceSha256": "9d586d9717c74cbb0a780763f5fefce788fc0f70838d2550f1c7ac0de7cf2b76", "decision": "approved" }, + { "themeId": "cloud-forge-quiet", "sourceSha256": "152833b53eaf14c870ad6df5a60bc9dd3ebfb3504b9e48af26e1d0ff62ab8181", "decision": "approved" }, + { "themeId": "coastal-studio-morning", "sourceSha256": "60a7797c7ca393e3b27a5da7a52b9c3f61c94358b47ae718ea1e75f699b0c1d7", "decision": "approved" }, + { "themeId": "desert-observatory-night", "sourceSha256": "5306564b8a931e00a971a1d6700c9d0a62c40e702fed4f93d91f013c43ed7383", "decision": "approved" }, + { "themeId": "firefly-river-pact", "sourceSha256": "19a1e78781c570425fc28ad915847120dbdc616a998153a215e4bb84fb78f490", "decision": "approved" }, + { "themeId": "great-lakes-rainroom", "sourceSha256": "9b3596a8b21e657ff402692150114c684be4175d22e83c2061c2bca8bc0a1d48", "decision": "approved" }, + { "themeId": "jade-archive-dawn", "sourceSha256": "e1cd53e9a1952adda1d761b2b7f64c03c420223e56650350d0622d03ca9afb58", "decision": "approved" }, + { "themeId": "late-diner-afterhours", "sourceSha256": "39a525aeca2f7fa5d5ecfeec798f6203e16222a8d80fa08cbc0147cac7923860", "decision": "approved" }, + { "themeId": "plum-sword-snow", "sourceSha256": "0db74a9df358991d4261236a9ad94a31a963e187b75ea2c6693c2d1e01015a1a", "decision": "approved" }, + { "themeId": "sky-garden-herbarium", "sourceSha256": "69fd6fdfaafe97821391859e5d07a2a160ceba6f06f3ff0f1dd1d62f47babeba", "decision": "approved" } + ] +} diff --git a/themes/source-art/reviews/focal-aware-v3.json b/themes/source-art/reviews/focal-aware-v3.json new file mode 100644 index 0000000..47b601d --- /dev/null +++ b/themes/source-art/reviews/focal-aware-v3.json @@ -0,0 +1,1302 @@ +{ + "schemaVersion": 1, + "reviewId": "act-focal-aware-v3-2026-07-26", + "reviewedAt": "2026-07-26", + "renderer": { + "before": "act-source-art-renderer-v2", + "after": "act-source-art-renderer-v3" + }, + "tileLayout": { + "columns": 3, + "tileSize": { + "width": 144, + "height": 81 + }, + "order": [ + "legacy-light", + "focal-light", + "legacy-dark", + "focal-dark" + ], + "focalFrame": "green", + "legacyFrame": "gray" + }, + "contactSheets": [ + { + "path": "docs/assets/source-art-review/focal-aware-v3-01.png", + "sha256": "5fac6cf51788c9a697ed669708bd6889382526b3e947452f5fb683f9904c7d91", + "bytes": 1528540, + "themeIds": [ + "qinglan-odyssey", + "qinglan-sprite", + "starbound-rebel", + "starbound-sprite", + "mountain-sword", + "mountain-swordling", + "cloudsea-pact", + "cloudsea-mochi", + "beijing-meridian", + "shanghai-afterglow", + "shenzhen-circuit-rain", + "guangzhou-riverwind", + "chengdu-teahouse-rain", + "hangzhou-lake-letter", + "chongqing-vertical-night", + "nanjing-wall-plum", + "new-york-rainline", + "san-francisco-fogline" + ] + }, + { + "path": "docs/assets/source-art-review/focal-aware-v3-02.png", + "sha256": "cbfd27a4a5a0c1e960ed940478653339d4befe92d669a1713fe348d670117a28", + "bytes": 1492983, + "themeIds": [ + "chicago-lakewind", + "toronto-blue-hour", + "vancouver-rain-harbour", + "london-rainlight", + "paris-rooftop-dawn", + "berlin-night-grid", + "rome-terrace-gold", + "tokyo-rain-signal", + "singapore-garden-rain", + "sydney-harbour-morning", + "mortal-journey-duo", + "mortal-journey-chibi", + "renegade-immortal-duo", + "renegade-immortal-chibi", + "sword-of-coming-duo", + "sword-of-coming-chibi", + "battle-through-duo", + "battle-through-chibi" + ] + }, + { + "path": "docs/assets/source-art-review/focal-aware-v3-03.png", + "sha256": "c0290c0df52bf6ba83f03107d865ac1e545820128d33060c82f8889505d44426", + "bytes": 1628573, + "themeIds": [ + "mortal-void-hall", + "renegade-wedding-storm", + "sword-confession-alley", + "battle-three-year-pact", + "tibo-reset-saint", + "tibo-reset-immortal", + "retro-boss-2007", + "rainy-library", + "orbital-dawn", + "deep-ocean-lab", + "synthwave-night-drive", + "alpine-cabin-code", + "jiangnan-rain-courtyard", + "changan-lanterns", + "dunhuang-starlight", + "bamboo-tea-rain", + "northeast-snow-train", + "appalachian-cabin-focus" + ] + }, + { + "path": "docs/assets/source-art-review/focal-aware-v3-04.png", + "sha256": "388faf7aec5482f0f84002530c0bdc347ac2f5a99b11bd27267e62ad95b10713", + "bytes": 1223824, + "themeIds": [ + "cloud-forge-quiet", + "coastal-studio-morning", + "desert-observatory-night", + "firefly-river-pact", + "great-lakes-rainroom", + "jade-archive-dawn", + "late-diner-afterhours", + "mortal-bamboo-thunder", + "mortal-herb-lantern", + "mortal-moon-market", + "mortal-river-ferry", + "mortal-talisman-rain", + "plum-sword-snow", + "sky-garden-herbarium" + ] + } + ], + "fixtureSheet": { + "path": "docs/assets/source-art-review/focal-aware-v3-fixtures.png", + "sha256": "e248b4cc6391bdf864dcf6501d65d89887e553d9de4caaf87e2ccc4c4fb19f53", + "bytes": 130230, + "themeId": "qinglan-odyssey", + "order": { + "rows": [ + "left", + "right", + "center", + "none" + ], + "columns": [ + "home", + "task", + "diff", + "settings", + "dialog", + "composer" + ] + } + }, + "decisions": [ + { + "themeId": "qinglan-odyssey", + "decision": "approved", + "focus": { + "x": 0.76, + "y": 0.42 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 0, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.76, + "focusY": 0.42 + } + }, + { + "themeId": "qinglan-sprite", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.56 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 141.44000000000005, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.56 + } + }, + { + "themeId": "starbound-rebel", + "decision": "approved", + "focus": { + "x": 0.76, + "y": 0.4 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 0, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.76, + "focusY": 0.4 + } + }, + { + "themeId": "starbound-sprite", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.54 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 120.96000000000004, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.54 + } + }, + { + "themeId": "mountain-sword", + "decision": "approved", + "focus": { + "x": 0.75, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.75, + "focusY": 0.44 + } + }, + { + "themeId": "mountain-swordling", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.58 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 160, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.58 + } + }, + { + "themeId": "cloudsea-pact", + "decision": "approved", + "focus": { + "x": 0.74, + "y": 0.42 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 0, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.74, + "focusY": 0.42 + } + }, + { + "themeId": "cloudsea-mochi", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.57 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 151.67999999999995, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.57 + } + }, + { + "themeId": "beijing-meridian", + "decision": "approved", + "focus": { + "x": 0.76, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.76, + "focusY": 0.43 + } + }, + { + "themeId": "shanghai-afterglow", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.45 + } + }, + { + "themeId": "shenzhen-circuit-rain", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.44 + } + }, + { + "themeId": "guangzhou-riverwind", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.47 + } + }, + { + "themeId": "chengdu-teahouse-rain", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.48 + } + }, + { + "themeId": "hangzhou-lake-letter", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.44 + } + }, + { + "themeId": "chongqing-vertical-night", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.46 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 39.04000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.46 + } + }, + { + "themeId": "nanjing-wall-plum", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.45 + } + }, + { + "themeId": "new-york-rainline", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.44 + } + }, + { + "themeId": "san-francisco-fogline", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.43 + } + }, + { + "themeId": "chicago-lakewind", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.43 + } + }, + { + "themeId": "toronto-blue-hour", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.43 + } + }, + { + "themeId": "vancouver-rain-harbour", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.44 + } + }, + { + "themeId": "london-rainlight", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.43 + } + }, + { + "themeId": "paris-rooftop-dawn", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.42 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 0, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.42 + } + }, + { + "themeId": "berlin-night-grid", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.43 + } + }, + { + "themeId": "rome-terrace-gold", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.42 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 0, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.42 + } + }, + { + "themeId": "tokyo-rain-signal", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.44 + } + }, + { + "themeId": "singapore-garden-rain", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.43 + } + }, + { + "themeId": "sydney-harbour-morning", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.42 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 0, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.42 + } + }, + { + "themeId": "mortal-journey-duo", + "decision": "approved", + "focus": { + "x": 0.76, + "y": 0.46 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 39.04000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.76, + "focusY": 0.46 + } + }, + { + "themeId": "mortal-journey-chibi", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.48 + } + }, + { + "themeId": "renegade-immortal-duo", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.45 + } + }, + { + "themeId": "renegade-immortal-chibi", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.5 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 80, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.5 + } + }, + { + "themeId": "sword-of-coming-duo", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.46 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 39.04000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.46 + } + }, + { + "themeId": "sword-of-coming-chibi", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.5 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 80, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.5 + } + }, + { + "themeId": "battle-through-duo", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.45 + } + }, + { + "themeId": "battle-through-chibi", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.5 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 80, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.5 + } + }, + { + "themeId": "mortal-void-hall", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.44 + } + }, + { + "themeId": "renegade-wedding-storm", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.43 + } + }, + { + "themeId": "sword-confession-alley", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.48 + } + }, + { + "themeId": "battle-three-year-pact", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.43 + } + }, + { + "themeId": "tibo-reset-saint", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.45 + } + }, + { + "themeId": "tibo-reset-immortal", + "decision": "approved", + "focus": { + "x": 0.76, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.76, + "focusY": 0.45 + } + }, + { + "themeId": "retro-boss-2007", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.48 + } + }, + { + "themeId": "rainy-library", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.47 + } + }, + { + "themeId": "orbital-dawn", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.43 + } + }, + { + "themeId": "deep-ocean-lab", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.47 + } + }, + { + "themeId": "synthwave-night-drive", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.49 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 69.75999999999999, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.49 + } + }, + { + "themeId": "alpine-cabin-code", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.46 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 39.04000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.46 + } + }, + { + "themeId": "jiangnan-rain-courtyard", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.47 + } + }, + { + "themeId": "changan-lanterns", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.43 + } + }, + { + "themeId": "dunhuang-starlight", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.44 + } + }, + { + "themeId": "bamboo-tea-rain", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.48 + } + }, + { + "themeId": "northeast-snow-train", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.47 + } + }, + { + "themeId": "appalachian-cabin-focus", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.47 + } + }, + { + "themeId": "cloud-forge-quiet", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.47 + } + }, + { + "themeId": "coastal-studio-morning", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.46 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 39.04000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.46 + } + }, + { + "themeId": "desert-observatory-night", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.43 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 8.319999999999993, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.43 + } + }, + { + "themeId": "firefly-river-pact", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.51 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 90.24000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.51 + } + }, + { + "themeId": "great-lakes-rainroom", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.48 + } + }, + { + "themeId": "jade-archive-dawn", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.44 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 18.560000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.44 + } + }, + { + "themeId": "late-diner-afterhours", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.48 + } + }, + { + "themeId": "mortal-bamboo-thunder", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.45 + } + }, + { + "themeId": "mortal-herb-lantern", + "decision": "approved", + "focus": { + "x": 0.79, + "y": 0.48 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 59.51999999999998, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.79, + "focusY": 0.48 + } + }, + { + "themeId": "mortal-moon-market", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.49 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 69.75999999999999, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.49 + } + }, + { + "themeId": "mortal-river-ferry", + "decision": "approved", + "focus": { + "x": 0.78, + "y": 0.5 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 80, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.78, + "focusY": 0.5 + } + }, + { + "themeId": "mortal-talisman-rain", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.47 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 49.27999999999997, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.47 + } + }, + { + "themeId": "plum-sword-snow", + "decision": "approved", + "focus": { + "x": 0.8, + "y": 0.46 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 39.04000000000002, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.8, + "focusY": 0.46 + } + }, + { + "themeId": "sky-garden-herbarium", + "decision": "approved", + "focus": { + "x": 0.77, + "y": 0.45 + }, + "safeArea": "left", + "crop": { + "cropX": 0, + "cropY": 28.80000000000001, + "cropWidth": 1536, + "cropHeight": 864, + "focusX": 0.77, + "focusY": 0.45 + } + } + ] +}